Skip to content

Commit b027920

Browse files
authored
Merge pull request #18417 from mozilla/FXA-11133
task(recovery-phone): Add support for message status updates
2 parents b106ae7 + 56ed16e commit b027920

13 files changed

Lines changed: 482 additions & 10 deletions

File tree

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
} from './recovery-phone.errors';
1717
import { LOGGER_PROVIDER } from '@fxa/shared/log';
1818
import { StatsDService } from '@fxa/shared/metrics/statsd';
19+
import { MessageStatus } from 'twilio/lib/rest/api/v2010/account/message';
20+
import { TwilioConfig } from './twilio.config';
21+
import { getExpectedTwilioSignature } from 'twilio/lib/webhooks/webhooks';
1922

2023
describe('RecoveryPhoneService', () => {
2124
const phoneNumber = '+15005551234';
@@ -25,14 +28,18 @@ describe('RecoveryPhoneService', () => {
2528
const mockLogger = {
2629
error: jest.fn(),
2730
};
31+
2832
const mockMetrics = {
2933
gauge: jest.fn(),
3034
increment: jest.fn(),
3135
};
36+
3237
const mockSmsManager = {
3338
sendSMS: jest.fn(),
3439
phoneNumberLookup: jest.fn(),
40+
messageStatus: jest.fn(),
3541
};
42+
3643
const mockRecoveryPhoneManager = {
3744
storeUnconfirmed: jest.fn(),
3845
getUnconfirmed: jest.fn(),
@@ -43,15 +50,27 @@ describe('RecoveryPhoneService', () => {
4350
hasRecoveryCodes: jest.fn(),
4451
removeCode: jest.fn(),
4552
};
46-
const mockOtpManager = { generateCode: jest.fn() };
47-
const mockRecoveryPhoneConfig = {
53+
54+
const mockOtpManager = {
55+
generateCode: jest.fn(),
56+
};
57+
58+
const mockRecoveryPhoneConfig: RecoveryPhoneConfig = {
4859
enabled: true,
4960
allowedRegions: ['US'],
5061
sms: {
5162
validNumberPrefixes: ['+1500'],
5263
smsPumpingRiskThreshold: 75,
5364
},
54-
};
65+
} satisfies RecoveryPhoneConfig;
66+
67+
const mockTwilioConfig: TwilioConfig = {
68+
accountSid: 'AC00000000000000000000000000000000',
69+
authToken: '00000000000000000000000000000000',
70+
webhookUrl: 'http://accounts.firefox.com/recovery-phone/message-status',
71+
validateWebhookCalls: true,
72+
} satisfies TwilioConfig;
73+
5574
const mockError = new Error('BOOM');
5675

5776
let service: RecoveryPhoneService;
@@ -70,6 +89,10 @@ describe('RecoveryPhoneService', () => {
7089
provide: RecoveryPhoneConfig,
7190
useValue: mockRecoveryPhoneConfig,
7291
},
92+
{
93+
provide: TwilioConfig,
94+
useValue: mockTwilioConfig,
95+
},
7396
{
7497
provide: LOGGER_PROVIDER,
7598
useValue: mockLogger,
@@ -588,4 +611,63 @@ describe('RecoveryPhoneService', () => {
588611
expect(service.stripPhoneNumber(phoneNumber, 4)).toEqual('');
589612
});
590613
});
614+
615+
describe('can handle message status update', () => {
616+
it('can handle message status update', async () => {
617+
const messageUpdate = {
618+
AccountSid: 'AC123',
619+
From: '+123456789',
620+
MessageSid: 'MS123',
621+
MessageStatus: 'delivered' as MessageStatus,
622+
};
623+
await service.onMessageStatusUpdate(messageUpdate);
624+
expect(mockSmsManager.messageStatus).toBeCalledWith(messageUpdate);
625+
});
626+
});
627+
628+
describe('verify twilio signature', () => {
629+
// This is how Twilio generates the signature, see following doc for more info:
630+
// https://www.twilio.com/docs/usage/security#test-the-validity-of-your-webhook-signature
631+
const signature = getExpectedTwilioSignature(
632+
mockTwilioConfig.authToken,
633+
mockTwilioConfig.webhookUrl,
634+
{
635+
foo: 'bar',
636+
}
637+
);
638+
639+
afterEach(() => {
640+
mockTwilioConfig.validateWebhookCalls = true;
641+
});
642+
643+
it('can validate twilio signature', () => {
644+
const valid = service.validateTwilioSignature(signature, {
645+
foo: 'bar',
646+
});
647+
expect(valid).toBeTruthy();
648+
});
649+
650+
it('can invalidate twilio signature due to bad payload', () => {
651+
const valid = service.validateTwilioSignature(signature, {
652+
foo: 'bar',
653+
bar: 'baz',
654+
});
655+
expect(valid).toBeFalsy();
656+
});
657+
658+
it('can invalidate twilio signature due to bad signature', () => {
659+
const valid = service.validateTwilioSignature(signature + '0', {
660+
foo: 'bar',
661+
});
662+
expect(valid).toBeFalsy();
663+
});
664+
665+
it('will always validate if validateWebhookCalls is false', () => {
666+
mockTwilioConfig.validateWebhookCalls = false;
667+
const valid = service.validateTwilioSignature(signature + '0', {
668+
foo: 'bar',
669+
});
670+
expect(valid).toBeTruthy();
671+
});
672+
});
591673
});

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { Inject, Injectable, LoggerService } from '@nestjs/common';
6-
import { SmsManager } from './sms.manager';
6+
import { SmsManager, TwilioMessageStatus } from './sms.manager';
77
import { OtpManager } from '@fxa/shared/otp';
88
import { RecoveryPhoneConfig } from './recovery-phone.service.config';
99
import {
@@ -19,6 +19,8 @@ import {
1919
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
2020
import { LOGGER_PROVIDER } from '@fxa/shared/log';
2121
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
22+
import { TwilioConfig } from './twilio.config';
23+
import { validateRequest } from 'twilio';
2224

2325
@Injectable()
2426
export class RecoveryPhoneService {
@@ -27,6 +29,7 @@ export class RecoveryPhoneService {
2729
private readonly smsManager: SmsManager,
2830
private readonly otpCode: OtpManager,
2931
private readonly config: RecoveryPhoneConfig,
32+
private readonly twilioConfig: TwilioConfig,
3033
@Inject(StatsDService) private readonly metrics: StatsD,
3134
@Inject(LOGGER_PROVIDER) private readonly log?: LoggerService
3235
) {}
@@ -374,6 +377,35 @@ export class RecoveryPhoneService {
374377
return this.isSuccessfulSmsSend(msg);
375378
}
376379

380+
/**
381+
* Handles update about message delivery status.
382+
* @param messageStatus The message delivery status.
383+
*/
384+
public async onMessageStatusUpdate(messageStatus: TwilioMessageStatus) {
385+
await this.smsManager.messageStatus(messageStatus);
386+
}
387+
388+
/**
389+
* Produces the signature used to sign requests sent to twilio webhooks
390+
* @param params
391+
* @returns
392+
*/
393+
public validateTwilioSignature(
394+
twilioSignature: string,
395+
params: Record<string, any>
396+
) {
397+
if (this.twilioConfig.validateWebhookCalls === false) {
398+
return true;
399+
}
400+
401+
return validateRequest(
402+
this.twilioConfig.authToken,
403+
twilioSignature,
404+
this.twilioConfig.webhookUrl,
405+
params
406+
);
407+
}
408+
377409
private isSuccessfulSmsSend(msg: MessageInstance) {
378410
if (
379411
msg == null ||

libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('SmsManager', () => {
1414
const to = '+15005551111';
1515
const from = ['+15005550000', '+15005550001'];
1616
const mockTwilioSmsClient = {
17+
accountSid: 'AC123',
1718
messages: {
1819
create: jest.fn(),
1920
},
@@ -29,6 +30,7 @@ describe('SmsManager', () => {
2930
};
3031
const mockLog = {
3132
log: jest.fn(),
33+
warn: jest.fn(),
3234
};
3335
const mockConfig = {
3436
from,
@@ -251,4 +253,92 @@ describe('SmsManager', () => {
251253
);
252254
});
253255
});
256+
257+
describe('message status updates', () => {
258+
it('records message status update for delivered', () => {
259+
manager.messageStatus({
260+
AccountSid: 'AC123',
261+
MessageSid: 'M123',
262+
From: '+1234567890',
263+
MessageStatus: 'delivered',
264+
RawDlrDoneDate: 'TWILIO_DATE_FORMAT',
265+
});
266+
expect(mockMetrics.increment).toBeCalledTimes(1);
267+
expect(mockMetrics.increment).toBeCalledWith(
268+
'recovery-phone.message.status.delivered'
269+
);
270+
expect(mockLog.log).toBeCalledWith(
271+
'recovery-phone.message.status.delivered',
272+
{
273+
From: '+1234567890',
274+
MessageSid: 'M123',
275+
RawDlrDoneDate: 'TWILIO_DATE_FORMAT',
276+
}
277+
);
278+
});
279+
280+
it('records message status update for failed', () => {
281+
manager.messageStatus({
282+
AccountSid: 'AC123',
283+
MessageSid: 'M123',
284+
From: '+1234567890',
285+
MessageStatus: 'failed',
286+
ErrorCode: '4000',
287+
});
288+
expect(mockMetrics.increment).toBeCalledTimes(2);
289+
expect(mockMetrics.increment).toBeCalledWith(
290+
'recovery-phone.message.status.failed'
291+
);
292+
expect(mockMetrics.increment).toBeCalledWith(
293+
'recovery-phone.message.status.error.4000'
294+
);
295+
expect(mockLog.log).toBeCalledWith(
296+
'recovery-phone.message.status.failed',
297+
{
298+
From: '+1234567890',
299+
MessageSid: 'M123',
300+
ErrorCode: '4000',
301+
}
302+
);
303+
});
304+
305+
it('records message status update for undelivered', () => {
306+
manager.messageStatus({
307+
AccountSid: 'AC123',
308+
MessageSid: 'M123',
309+
From: '+1234567890',
310+
MessageStatus: 'undelivered',
311+
ErrorCode: '4000',
312+
});
313+
expect(mockMetrics.increment).toBeCalledTimes(2);
314+
expect(mockMetrics.increment).toBeCalledWith(
315+
'recovery-phone.message.status.undelivered'
316+
);
317+
expect(mockMetrics.increment).toBeCalledWith(
318+
'recovery-phone.message.status.error.4000'
319+
);
320+
expect(mockLog.log).toBeCalledWith(
321+
'recovery-phone.message.status.undelivered',
322+
{
323+
From: '+1234567890',
324+
MessageSid: 'M123',
325+
ErrorCode: '4000',
326+
}
327+
);
328+
});
329+
330+
it('records message status update for unimportant status', () => {
331+
manager.messageStatus({
332+
AccountSid: 'AC123',
333+
MessageSid: 'M123',
334+
From: '+1234567890',
335+
MessageStatus: 'sending',
336+
});
337+
expect(mockMetrics.increment).toBeCalledTimes(1);
338+
expect(mockMetrics.increment).toBeCalledWith(
339+
'recovery-phone.message.status.sending'
340+
);
341+
expect(mockLog.log).toBeCalledTimes(0);
342+
});
343+
});
254344
});

libs/accounts/recovery-phone/src/lib/sms.manager.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { StatsDService } from '@fxa/shared/metrics/statsd';
77
import { Inject, Injectable, LoggerService } from '@nestjs/common';
88
import { StatsD } from 'hot-shots';
99
import { Twilio } from 'twilio';
10-
import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message';
10+
import {
11+
MessageInstance,
12+
MessageStatus,
13+
} from 'twilio/lib/rest/api/v2010/account/message';
1114
import { SmsManagerConfig } from './sms.manager.config';
1215
import { TwilioProvider } from './twilio.provider';
1316
import {
@@ -17,6 +20,17 @@ import {
1720
TwilioErrorCodes,
1821
} from './recovery-phone.errors';
1922

23+
export type TwilioMessageStatus = {
24+
AccountSid: string;
25+
From: string;
26+
MessageSid: string;
27+
MessageStatus: MessageStatus;
28+
// Only present if status is failed or undelivered
29+
ErrorCode?: string;
30+
// Probably present if status is delivered or undelivered
31+
RawDlrDoneDate?: string;
32+
};
33+
2034
@Injectable()
2135
export class SmsManager {
2236
private currentPhoneNumber = 0;
@@ -149,4 +163,53 @@ export class SmsManager {
149163
this.currentPhoneNumber++ % this.config.from.length
150164
];
151165
}
166+
167+
public messageStatus(messageStatus: TwilioMessageStatus) {
168+
// Keep tabs on the delivery status
169+
this.metrics.increment(
170+
`recovery-phone.message.status.${messageStatus.MessageStatus}`
171+
);
172+
173+
// Track specific error codes rates
174+
if (messageStatus.ErrorCode) {
175+
this.metrics.increment(
176+
`recovery-phone.message.status.error.${messageStatus.ErrorCode}`
177+
);
178+
}
179+
180+
// Only write log entries for certain statuses.
181+
switch (messageStatus.MessageStatus) {
182+
case 'queued':
183+
case 'sending':
184+
case 'sent':
185+
case 'receiving':
186+
case 'received':
187+
case 'accepted':
188+
case 'scheduled':
189+
case 'read':
190+
case 'partially_delivered':
191+
case 'canceled':
192+
// no-op
193+
break;
194+
195+
case 'failed':
196+
case 'undelivered':
197+
case 'delivered':
198+
const opts: any = {
199+
From: messageStatus.From,
200+
MessageSid: messageStatus.MessageSid,
201+
};
202+
if (messageStatus.ErrorCode) {
203+
opts.ErrorCode = messageStatus.ErrorCode;
204+
}
205+
if (messageStatus.RawDlrDoneDate) {
206+
opts.RawDlrDoneDate = messageStatus.RawDlrDoneDate;
207+
}
208+
this.log.log(
209+
`recovery-phone.message.status.${messageStatus.MessageStatus}`,
210+
opts
211+
);
212+
break;
213+
}
214+
}
152215
}

0 commit comments

Comments
 (0)