fix(otp): fall back to email for foreign phone numbers

The SMS gateway is domestic-only, but OTP sends fanned out to any phone
on the account - a foreign number meant a code queued into the void
while the response claimed success. isDomesticPhone (+2519/+2517 E.164)
now gates SMS: dual-channel sends with a foreign phone go email-only
(the phone stays on the row so verify still matches it), and a
phone-only foreign target still tries SMS as the only route. The
staff-triggered reset exposes phoneIsDomestic so the backoffice disables
the SMS channel with an explanation, and the API refuses the channel
directly for foreign numbers.

EDRFREIGHT-186
This commit is contained in:
Nathnael
2026-07-21 09:09:45 +00:00
parent 4afffca3d2
commit 649316070d
5 changed files with 91 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { OtpService, normalizeOtpTarget } from './otp.service';
import { OtpService, isDomesticPhone, normalizeOtpTarget } from './otp.service';
describe('normalizeOtpTarget', () => {
it('canonicalises Ethiopian forms to one E.164 key', () => {
@@ -28,6 +28,18 @@ describe('normalizeOtpTarget', () => {
});
});
describe('isDomesticPhone', () => {
it.each(['+251986680099', '0986680099', '0712345678', '251986680099'])(
'accepts Ethiopian mobile form %s',
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each(['+14155550123', '+447911123456', '+2519866', '12345'])(
'rejects non-domestic or malformed %s',
(phone) => expect(isDomesticPhone(phone)).toBe(false),
);
});
interface FakeRow {
id: string;
phone?: string;
@@ -184,6 +196,26 @@ describe('OtpService — dual-channel send', () => {
expect(email.sendEmail).not.toHaveBeenCalled();
});
it('skips SMS for a foreign number when email is available', async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp({ phone: '+14155550123', email: 'user@example.com' });
// The gateway is domestic-only — email is the delivery route, but the
// foreign phone stays on the row so verify still matches either channel.
expect(sms.sendSms).not.toHaveBeenCalled();
expect(email.sendEmail).toHaveBeenCalledTimes(1);
await expect(
service.verifyOtpForAction({ phone: '+14155550123' }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it('still attempts SMS for a foreign number when it is the only channel', async () => {
const { service, sms } = makeService();
await service.sendOtp({ phone: '+14155550123' });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
});
it('still succeeds when one transport throws', async () => {
const { service, rows } = makeService({
sms: async () => {