Add E.164 input validation for phone numbers (FRE-4506)
- Extract phone validation to reusable utility (src/utils/phone-validation.ts) - Use libphonenumber-js for strict E.164 format validation - Normalize accepted numbers to canonical E.164 format - Add 22 comprehensive validation tests covering valid/invalid formats - Update existing tests to use valid E.164 test numbers - All 56 tests passing Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<Array<{ id: string; pattern: string }>> {
|
||||
|
||||
25
services/spamshield/src/utils/phone-validation.ts
Normal file
25
services/spamshield/src/utils/phone-validation.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user