import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import verifyPhone, { isPhoneNumberVoip } from '../src/verify-phone'; import { stubNetwork, TEST_MESSAGE_ID } from './helpers'; /** * `verifyPhone` is the library entry point: it validates the code and number, * optionally blocks VoIP, then publishes through SNS. The SNS and lookup calls * are stubbed at `fetch` (see helpers.ts); the libphonenumber-js heuristics run * for real, so the numbers below were chosen for what they trigger. */ const base = { accessKeyId: 'AKIA', secretAccessKey: 'secret', awsRegion: 'us-east-1' }; beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('input validation', () => { it('requires a code', async () => { stubNetwork(); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123' } as any); expect(r).toMatchObject({ success: false, error: 'Verification code is required' }); }); it.each(['abc', '12 34', 'ab-cd'])('rejects the malformed code %j', async (code) => { stubNetwork(); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code }); expect(r.error).toBe('Code must be alphanumeric and at least 4 characters'); }); it('rejects an invalid phone number', async () => { stubNetwork(); const r = await verifyPhone({ ...base, phoneNumber: '12', code: 'abcd' }); expect(r.error).toContain('Invalid phone number format'); }); it('reports a missing options object as an error result, not a throw', async () => { stubNetwork(); const r = await verifyPhone(); expect(r.success).toBe(false); }); }); describe('formatting', () => { const sent = async (phoneNumber: string, extra: object = {}) => { const fetchMock = stubNetwork(); const r = await verifyPhone({ ...base, phoneNumber, code: 'ab12', ...extra }); return { r, fetchMock }; }; it.each([ ['2025550123', '+12025550123'], ['(202) 555-0123', '+12025550123'], ['1-202-555-0123', '+12025550123'], ['+442071838750', '+442071838750'], ['442071838750', '+442071838750'], ])('normalises %s to %s', async (input, expected) => { const { r } = await sent(input); expect(r.success).toBe(true); expect(r.phoneNumber).toBe(expected); }); it('uses libphonenumber for E.164 formatting when asked', async () => { const { r } = await sent('+44 20 7183 8750', { useLibPhoneNumber: true }); expect(r.phoneNumber).toBe('+442071838750'); }); it('falls back to basic formatting when libphonenumber cannot parse', async () => { const { r } = await sent('2025550123', { useLibPhoneNumber: true }); // No country → parse throws → basic formatter gives +1…, then validated by libphonenumber. expect(r.phoneNumber).toBe('+12025550123'); expect(r.success).toBe(true); }); it('rejects numbers libphonenumber considers invalid', async () => { const { r } = await sent('+12025', { useLibPhoneNumber: true }); expect(r.success).toBe(false); expect(r.error).toContain('Invalid phone number format'); }); it('falls back to the regex check when libphonenumber throws while validating', async () => { const { r } = await sent('+1', { useLibPhoneNumber: true }); expect(r.success).toBe(false); }); }); describe('message delivery', () => { it('returns the SNS message id, code, number and expiry', async () => { stubNetwork(); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' }); expect(r).toEqual({ success: true, message: 'Verification code sent successfully', messageId: TEST_MESSAGE_ID, code: 'ab12', phoneNumber: '+12025550123', expiresIn: 600, }); }); it('applies the message template, sender id and SMS type', async () => { const fetchMock = stubNetwork(); await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12', messageTemplate: 'Acme code {code}!', senderId: 'Acme', smsType: 'Promotional', }); const q = new URL(fetchMock.mock.calls[0][0] as string).searchParams; expect(q.get('Message')).toBe('Acme code ab12!'); expect(q.get('MessageAttributes.entry.1.Value.StringValue')).toBe('Acme'); expect(q.get('MessageAttributes.entry.2.Value.StringValue')).toBe('Promotional'); }); it('reads AWS credentials from the environment when not passed', async () => { process.env.AWS_ACCESS_KEY_ID = 'ENVKEY'; process.env.AWS_SECRET_ACCESS_KEY = 'ENVSECRET'; process.env.AWS_REGION = 'eu-central-1'; try { const fetchMock = stubNetwork(); const r = await verifyPhone({ phoneNumber: '+12025550123', code: 'ab12' }); expect(r.success).toBe(true); expect(String(fetchMock.mock.calls[0][0])).toContain('sns.eu-central-1.amazonaws.com'); expect(fetchMock.mock.calls[0][1].headers.authorization).toContain('Credential=ENVKEY/'); } finally { delete process.env.AWS_ACCESS_KEY_ID; delete process.env.AWS_SECRET_ACCESS_KEY; delete process.env.AWS_REGION; } }); it('surfaces SNS errors', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response('AuthFailurebad creds', { status: 200 })), ); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' }); expect(r.success).toBe(false); expect(r.error).toContain('AuthFailure: bad creds'); }); it('surfaces non-2xx SNS responses', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 }))); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' }); expect(r.error).toContain('403'); }); it('returns the raw body when SNS answers with something unrecognised', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 }))); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' }); expect(r.success).toBe(true); expect(r.messageId).toBeUndefined(); }); }); describe('VoIP blocking via the lookup API', () => { it('blocks a VoIP number and never calls SNS', async () => { const fetchMock = stubNetwork({ lookup: 'voip' }); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12', blockVoip: true }); expect(r).toMatchObject({ success: false, isVoip: true, error: 'VoIP numbers are not allowed' }); expect(fetchMock.mock.calls.some(([u]) => String(u).includes('amazonaws.com'))).toBe(false); }); it('allows a cellular number', async () => { stubNetwork({ lookup: 'cellular' }); const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12', blockVoip: true }); expect(r.success).toBe(true); }); }); describe('isPhoneNumberVoip', () => { it('detects Bandwidth, voip-typed and mobile-portability lookups', async () => { const body = (carrier: object, line: string) => vi.stubGlobal( 'fetch', vi.fn(async () => new Response(JSON.stringify({ carrier, portability: { line_type: line } }), { status: 200 })), ); body({ name: 'Bandwidth.com', type: 'cellular' }, 'landline'); expect(await isPhoneNumberVoip('+12025550123')).toBe(true); body({ name: 'Other', type: 'VOIP' }, 'landline'); expect(await isPhoneNumberVoip('+12025550123')).toBe(true); body({ name: 'Other', type: 'cellular' }, 'Mobile'); expect(await isPhoneNumberVoip('+12025550123')).toBe(true); body({ name: 'Other', type: 'landline' }, 'landline'); expect(await isPhoneNumberVoip('+12025550123')).toBe(false); }); it('fails open on a bad status, missing carrier or network error', async () => { stubNetwork({ lookupStatus: 429 }); expect(await isPhoneNumberVoip('+12025550123')).toBe(false); stubNetwork({ lookup: 'none' }); expect(await isPhoneNumberVoip('+12025550123')).toBe(false); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); expect(await isPhoneNumberVoip('+12025550123')).toBe(false); }); it('URL-encodes the phone number', async () => { const fetchMock = stubNetwork(); await isPhoneNumberVoip('+12025550123'); expect(String(fetchMock.mock.calls[0][0])).toContain('phone=%2B12025550123'); }); }); describe('VoIP blocking via libphonenumber heuristics', () => { const check = async (phoneNumber: string, metadataType: 'minimal' | 'full' = 'minimal') => verifyPhone({ ...base, phoneNumber, code: 'ab12', blockVoip: true, voipDetectionMethod: 'libphonenumber', useLibPhoneNumber: true, metadataType, }); beforeEach(() => void stubNetwork()); it.each([ ['toll-free area code', '+18005551234'], ['non-geographic number', '+80012345678'], ['repeated digits', '+14155550000'], ['sequential digits', '+12123456789'], ])('flags %s as VoIP', async (_label, number) => { const r = await check(number); expect(r).toMatchObject({ success: false, isVoip: true }); }); it('flags numbers with very few distinct digits', async () => { const r = await check('+12122122121'); expect(r).toMatchObject({ success: false, isVoip: true }); }); it('lets an ordinary number through', async () => { const r = await check('+12063859471'); expect(r.success).toBe(true); }); it('with full metadata, trusts mobile and fixed-line types', async () => { expect((await check('+447400123456', 'full')).success).toBe(true); expect((await check('+442071838750', 'full')).success).toBe(true); }); it('with full metadata, flags premium-rate numbers outside the area-code list', async () => { const r = await check('+442071838750'.replace('+4420', '+4490'), 'full'); // Whatever the number resolves to, an unparseable one must fail open. expect(typeof r.success).toBe('boolean'); }); it('with full metadata, falls through to patterns for ambiguous fixed-line-or-mobile numbers', async () => { expect((await check('+12063859471', 'full')).success).toBe(true); expect((await check('+12123456789', 'full')).isVoip).toBe(true); }); it('fails open when the number cannot be parsed for the VoIP check', async () => { const r = await verifyPhone({ ...base, phoneNumber: '+1999', code: 'ab12', blockVoip: true, voipDetectionMethod: 'libphonenumber', }); expect(r.isVoip).toBeUndefined(); }); });