diff --git a/services/spamshield/src/services/spamshield.service.ts b/services/spamshield/src/services/spamshield.service.ts index 93a961c..0cc4f08 100644 --- a/services/spamshield/src/services/spamshield.service.ts +++ b/services/spamshield/src/services/spamshield.service.ts @@ -2,6 +2,7 @@ import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/clie import { FieldEncryptionService } from '@shieldai/db'; import { spamConfig, spamFeatureFlags } from '../config/spamshield.config'; import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker'; +import { validatePhoneNumber as validateE164 } from '../utils/phone-validation'; const prisma = new PrismaClient() as PrismaClient & { spamFeedback: { @@ -269,11 +270,7 @@ export class SpamShieldService { } private validatePhoneNumber(phoneNumber: string): string { - if (phoneNumber.length < spamConfig.minPhoneNumberLength || - phoneNumber.length > spamConfig.maxPhoneNumberLength) { - throw new Error(`Invalid phone number format: ${phoneNumber}`); - } - return phoneNumber; + return validateE164(phoneNumber); } private async getActiveRules(): Promise> { diff --git a/services/spamshield/src/utils/phone-validation.ts b/services/spamshield/src/utils/phone-validation.ts new file mode 100644 index 0000000..857cfac --- /dev/null +++ b/services/spamshield/src/utils/phone-validation.ts @@ -0,0 +1,25 @@ +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js'; + +export class PhoneNumberValidationError extends Error { + constructor(public readonly originalInput: string) { + super( + `Invalid E.164 phone number format: ${originalInput}. Expected format: +[country code][number] (e.g., +14155552671)` + ); + this.name = 'PhoneNumberValidationError'; + } +} + +export function validatePhoneNumber(phoneNumber: string): string { + const trimmed = phoneNumber.trim(); + + if (!isValidPhoneNumber(trimmed)) { + throw new PhoneNumberValidationError(phoneNumber); + } + + const parsed = parsePhoneNumber(trimmed); + if (!parsed || !parsed.number) { + throw new PhoneNumberValidationError(phoneNumber); + } + + return parsed.number; +} diff --git a/services/spamshield/test/spamshield.test.ts b/services/spamshield/test/spamshield.test.ts index 8b62260..57dab71 100644 --- a/services/spamshield/test/spamshield.test.ts +++ b/services/spamshield/test/spamshield.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SpamShieldService } from '../src/services/spamshield.service'; import { spamConfig } from '../src/config/spamshield.config'; +import { validatePhoneNumber } from '../src/utils/phone-validation'; const mockFetch = vi.fn(); global.fetch = mockFetch as unknown as typeof global.fetch; @@ -27,7 +28,7 @@ describe('SpamShieldService', () => { json: async () => ({ spamProbability: 0.9 }), } as Response); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.source).toBe('combined'); expect(result.score).toBeCloseTo(0.85, 2); @@ -49,7 +50,7 @@ describe('SpamShieldService', () => { statusText: 'Internal Server Error', } as Response); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.source).toBe('combined'); expect(result.hiyaScore).toBe(0.6); @@ -68,7 +69,7 @@ describe('SpamShieldService', () => { json: async () => ({ spamProbability: 0.3 }), } as Response); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.source).toBe('combined'); expect(result.hiyaScore).toBe(0.5); @@ -88,7 +89,7 @@ describe('SpamShieldService', () => { statusText: 'Service Unavailable', } as Response); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.source).toBe('combined'); expect(result.hiyaScore).toBe(0.5); @@ -97,10 +98,10 @@ describe('SpamShieldService', () => { expect(result.isSpam).toBe(false); }); - it('validates phone number length', async () => { + it('validates phone number E.164 format', async () => { const shortNumber = '123'; const result = service.checkReputation(shortNumber); - await expect(result).rejects.toThrow('Invalid phone number format'); + await expect(result).rejects.toThrow('Invalid E.164 phone number format'); }); it('returns non-spam for low scores', async () => { @@ -114,7 +115,7 @@ describe('SpamShieldService', () => { json: async () => ({ spamProbability: 0.1 }), } as Response); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.isSpam).toBe(false); expect(result.score).toBeCloseTo(0.15, 2); @@ -138,7 +139,7 @@ describe('SpamShieldService', () => { status: 500, statusText: 'Server Error', } as Response); - await service.checkReputation('+1234567890'); + await service.checkReputation('+14155552671'); } const metricsAfter = service.getCircuitMetrics(); @@ -184,16 +185,125 @@ describe('SpamShieldService', () => { status: 500, statusText: 'Server Error', } as Response); - await service.checkReputation('+1234567890'); + await service.checkReputation('+14155552671'); } const metrics = service.getCircuitMetrics(); expect(metrics.hiya.state).toBe('OPEN'); expect(metrics.truecaller.state).toBe('OPEN'); - const result = await service.checkReputation('+1234567890'); + const result = await service.checkReputation('+14155552671'); expect(result.hiyaScore).toBe(0.5); expect(result.truecallerScore).toBe(0.5); }); }); + + describe('E.164 phone number validation', () => { + describe('valid E.164 formats', () => { + it('accepts US number in E.164 format', () => { + expect(() => validatePhoneNumber('+14155552671')).not.toThrow(); + }); + + it('accepts UK number in E.164 format', () => { + expect(() => validatePhoneNumber('+442071234567')).not.toThrow(); + }); + + it('accepts German number in E.164 format', () => { + expect(() => validatePhoneNumber('+4930123456789')).not.toThrow(); + }); + + it('accepts Japanese number in E.164 format', () => { + expect(() => validatePhoneNumber('+81312345678')).not.toThrow(); + }); + + it('accepts Australian number in E.164 format', () => { + expect(() => validatePhoneNumber('+61412345678')).not.toThrow(); + }); + + it('accepts Indian number in E.164 format', () => { + expect(() => validatePhoneNumber('+919876543210')).not.toThrow(); + }); + + it('accepts Brazilian number in E.164 format', () => { + expect(() => validatePhoneNumber('+5511987654321')).not.toThrow(); + }); + + it('accepts number with spaces and normalizes', () => { + const result = validatePhoneNumber('+1 415 555 2671'); + expect(result).toBe('+14155552671'); + }); + + it('accepts number with dashes and normalizes', () => { + const result = validatePhoneNumber('+1-415-555-2671'); + expect(result).toBe('+14155552671'); + }); + + it('accepts number with parentheses and normalizes', () => { + const result = validatePhoneNumber('+1 (415) 555-2671'); + expect(result).toBe('+14155552671'); + }); + + it('trims whitespace from input', () => { + expect(() => validatePhoneNumber(' +14155552671 ')).not.toThrow(); + }); + }); + + describe('invalid E.164 formats', () => { + it('rejects number without plus sign', () => { + expect(() => validatePhoneNumber('14155552671')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects number with letters', () => { + expect(() => validatePhoneNumber('+1415555ABCD')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects empty string', () => { + expect(() => validatePhoneNumber('')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects too-short number', () => { + expect(() => validatePhoneNumber('+123')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects number exceeding 15 digits', () => { + expect(() => validatePhoneNumber('+1234567890123456')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects number with special characters in middle', () => { + expect(() => validatePhoneNumber('+1@4155552671')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects double plus sign', () => { + expect(() => validatePhoneNumber('++14155552671')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects only whitespace', () => { + expect(() => validatePhoneNumber(' ')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects number starting with zero after plus', () => { + expect(() => validatePhoneNumber('+01234567890')).toThrow('Invalid E.164 phone number format'); + }); + + it('rejects negative number format', () => { + expect(() => validatePhoneNumber('-14155552671')).toThrow('Invalid E.164 phone number format'); + }); + }); + + describe('integration with service methods', () => { + it('rejects invalid format in checkReputation', async () => { + await expect(service.checkReputation('4155552671')).rejects.toThrow('Invalid E.164 phone number format'); + }); + + it('rejects invalid format in analyzeCall', async () => { + const result = service.analyzeCall('4155552671', new Date()); + await expect(result).rejects.toThrow('Invalid E.164 phone number format'); + }); + + it('rejects invalid format in recordFeedback', async () => { + const result = service.recordFeedback('user123', '4155552671', true); + await expect(result).rejects.toThrow('Invalid E.164 phone number format'); + }); + }); + }); });