Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add twilio webhook endpoint for failed SMS deliveries #3110

Merged
merged 15 commits into from
Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/app/modules/twilio/__tests__/twilio.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-disable import/first */
import { mocked } from 'ts-jest/utils'

import * as LoggerModule from 'src/app/config/logger'

import expressHandler from 'tests/unit/backend/helpers/jest-express'
import getMockLogger from 'tests/unit/backend/helpers/jest-logger'

const MockLoggerModule = mocked(LoggerModule, true)
const mockLogger = getMockLogger()

jest.mock('src/app/config/logger')
MockLoggerModule.createLoggerWithLabel.mockReturnValue(mockLogger)

import { twilioSmsUpdates } from 'src/app/modules/twilio/twilio.controller'
import { ITwilioSmsWebhookBody } from 'src/types'

describe('twilio.controller', () => {
beforeEach(() => {
jest.resetAllMocks()
})

const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = {
SmsSid: '12345',
SmsStatus: 'delivered',
MessageStatus: 'delivered',
To: '+12345678',
MessageSid: 'SM212312',
AccountSid: 'AC123456',
From: '+12345678',
ApiVersion: '2011-11-01',
}

const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = {
SmsSid: '12345',
SmsStatus: 'failed',
MessageStatus: 'failed',
To: '+12345678',
MessageSid: 'SM212312',
AccountSid: 'AC123456',
From: '+12345678',
ApiVersion: '2011-11-01',
ErrorCode: 30001,
ErrorMessage: 'Twilio is down!',
}

describe('twilioSmsUpdates', () => {
it('should return 200 when successfully delivered message is sent', async () => {
const mockReq = expressHandler.mockRequest({
body: MOCK_SUCCESSFUL_MESSAGE,
})
const mockRes = expressHandler.mockResponse()
await twilioSmsUpdates(mockReq, mockRes, jest.fn())

expect(mockLogger.info).toBeCalledWith({
message: 'Sms Delivery update',
meta: {
action: 'twilioSmsUpdates',
body: MOCK_SUCCESSFUL_MESSAGE,
},
})
expect(mockLogger.error).not.toBeCalled()
expect(mockRes.sendStatus).toBeCalledWith(200)
})

it('should return 200 when failed delivered message is sent', async () => {
const mockReq = expressHandler.mockRequest({
body: MOCK_FAILED_MESSAGE,
})
const mockRes = expressHandler.mockResponse()
await twilioSmsUpdates(mockReq, mockRes, jest.fn())

expect(mockLogger.info).not.toBeCalled()
expect(mockLogger.error).toBeCalledWith({
message: 'Error occurred when attempting to send SMS on twillio',
meta: {
action: 'twilioSmsUpdates',
body: MOCK_FAILED_MESSAGE,
},
})
expect(mockRes.sendStatus).toBeCalledWith(200)
})
})
})
56 changes: 56 additions & 0 deletions src/app/modules/twilio/twilio.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { StatusCodes } from 'http-status-codes'
import Twilio from 'twilio'

import { ITwilioSmsWebhookBody } from 'src/types/twilio'

import { isDev } from '../../config/config'
import { createLoggerWithLabel } from '../../config/logger'
import { ControllerHandler } from '../core/core.types'

const logger = createLoggerWithLabel(module)

/**
* Middleware which validates that a request came from Twilio Webhook
*/
const validateTwilioWebhook = Twilio.webhook({ validate: !isDev })

/**
* Process the Webhook requests if they are failed or unsuccessful, ignoring the rest
zatkiller marked this conversation as resolved.
Show resolved Hide resolved
*
* @param req Express request object
* @param res - Express response object
*/
export const twilioSmsUpdates: ControllerHandler<
unknown,
never,
ITwilioSmsWebhookBody
> = async (req, res) => {
/**
* Currently, it seems like the status are provided as string values, theres
* no other documentation stating the properties and values in the Node SDK
*
* Example: https://www.twilio.com/docs/usage/webhooks/sms-webhooks.
*/

if (req.body.ErrorCode || req.body.ErrorMessage) {
logger.error({
message: 'Error occurred when attempting to send SMS on twillio',
meta: {
action: 'twilioSmsUpdates',
body: req.body,
},
})
} else {
logger.info({
message: 'Sms Delivery update',
meta: {
action: 'twilioSmsUpdates',
body: req.body,
},
})
}

return res.sendStatus(StatusCodes.OK)
}

export const handleTwilioSmsUpdates = [validateTwilioWebhook, twilioSmsUpdates]
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import supertest, { Session } from 'supertest-session'

import { setupApp } from 'tests/integration/helpers/express-setup'

import { ITwilioSmsWebhookBody } from './../../../../../../types/twilio'
import { NotificationsRouter } from './../notifications.routes'

// Prevent rate limiting.
jest.mock('src/app/utils/limit-rate')

const app = setupApp('/notifications', NotificationsRouter, {
setupWithAuth: true,
})

describe('notifications.routes', () => {
let request: Session
beforeEach(async () => {
request = supertest(app)
})
afterEach(async () => {
jest.clearAllMocks()
})

const MOCK_SUCCESSFUL_MESSAGE: ITwilioSmsWebhookBody = {
SmsSid: '12345',
SmsStatus: 'delivered',
MessageStatus: 'delivered',
To: '+12345678',
MessageSid: 'SM212312',
AccountSid: 'AC123456',
From: '+12345678',
ApiVersion: '2011-11-01',
}

const MOCK_FAILED_MESSAGE: ITwilioSmsWebhookBody = {
SmsSid: '12345',
SmsStatus: 'failed',
MessageStatus: 'failed',
To: '+12345678',
MessageSid: 'SM212312',
AccountSid: 'AC123456',
From: '+12345678',
ApiVersion: '2011-11-01',
ErrorCode: 30001,
ErrorMessage: 'Twilio is down!',
}

describe('POST notifications/twilio', () => {
it('should return 200 on sending successful delivery status message', async () => {
const response = await request
.post('/notifications/twilio')
.send(MOCK_SUCCESSFUL_MESSAGE)

expect(response.status).toEqual(200)
expect(response.body).toBeEmpty()
})

it('should return 200 on sending failed delivery status message', async () => {
const response = await request
.post('/notifications/twilio')
.send(MOCK_FAILED_MESSAGE)

expect(response.status).toEqual(200)
expect(response.body).toBeEmpty()
})
})
})
16 changes: 16 additions & 0 deletions src/app/routes/api/v3/notifications/notifications.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { Router } from 'express'

import { handleTwilioSmsUpdates } from '../../../../modules/twilio/twilio.controller'

import { BouncesRouter } from './bounces'

export const NotificationsRouter = Router()

NotificationsRouter.use('/bounces', BouncesRouter)

/**
* Receives SMS delivery status updates from Twilio webhook
*
* Logs any errors or failures in SMS delivery while ignoring succesful
* status updates
*
* @route POST /api/v3/notifications/twilio
*
* @returns 200 when message succesfully received and logged
* @returns 400 when request is not coming from Twilio
* @returns 403 when twilio request validation failed
*/
NotificationsRouter.post('/twilio', handleTwilioSmsUpdates)
8 changes: 8 additions & 0 deletions src/app/services/sms/__tests__/sms.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import dbHandler from 'tests/unit/backend/helpers/jest-db'

import { FormResponseMode } from '../../../../../shared/types'
import { VfnErrors } from '../../../../../shared/utils/verification'
import config from '../../../config/config'
import { InvalidNumberError } from '../sms.errors'
import * as SmsService from '../sms.service'
import { LogType, SmsType, TwilioConfig } from '../sms.types'
Expand All @@ -35,6 +36,9 @@ const MOCK_ADMIN_ID = new ObjectId().toHexString()
const MOCK_FORM_ID = new ObjectId().toHexString()
const MOCK_FORM_TITLE = 'formTitle'

const MOCK_TWILIO_WEBHOOK_ENDPOINT =
config.app.appUrl + '/api/v3/notifications/twilio'

const twilioSuccessSpy = jest.fn().mockResolvedValue({
status: 'testStatus',
sid: 'testSid',
Expand Down Expand Up @@ -99,6 +103,7 @@ describe('sms.service', () => {
body: expectedMessage,
from: MOCK_VALID_CONFIG.msgSrvcSid,
forceDelivery: true,
statusCallback: MOCK_TWILIO_WEBHOOK_ENDPOINT,
})
expect(smsCountSpy).toHaveBeenCalledWith({
smsData: {
Expand Down Expand Up @@ -140,6 +145,7 @@ describe('sms.service', () => {
body: expectedMessage,
from: MOCK_INVALID_CONFIG.msgSrvcSid,
forceDelivery: true,
statusCallback: MOCK_TWILIO_WEBHOOK_ENDPOINT,
})
expect(smsCountSpy).toHaveBeenCalledWith({
smsData: {
Expand Down Expand Up @@ -179,6 +185,7 @@ describe('sms.service', () => {
body: expectedMessage,
from: MOCK_VALID_CONFIG.msgSrvcSid,
forceDelivery: true,
statusCallback: MOCK_TWILIO_WEBHOOK_ENDPOINT,
})
expect(smsCountSpy).toHaveBeenCalledWith({
smsData: {
Expand Down Expand Up @@ -220,6 +227,7 @@ describe('sms.service', () => {
body: expectedMessage,
from: MOCK_INVALID_CONFIG.msgSrvcSid,
forceDelivery: true,
statusCallback: MOCK_TWILIO_WEBHOOK_ENDPOINT,
})
expect(smsCountSpy).toHaveBeenCalledWith({
smsData: {
Expand Down
3 changes: 3 additions & 0 deletions src/app/services/sms/sms.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,15 @@ const sendSms = (
smsType,
}

const statusCallbackRoute = '/api/v3/notifications/twilio'

return ResultAsync.fromPromise(
client.messages.create({
to: recipient,
body: message,
from: msgSrvcSid,
forceDelivery: true,
statusCallback: config.app.appUrl + statusCallbackRoute,
zatkiller marked this conversation as resolved.
Show resolved Hide resolved
}),
(error) => {
logger.error({
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './admin_verification'
export * from './config'
export * from './routing'
export * from './email_mode_data'
export * from './twilio'
13 changes: 13 additions & 0 deletions src/types/twilio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Based off Twilio documentation - https://www.twilio.com/docs/usage/webhooks/sms-webhooks
export interface ITwilioSmsWebhookBody {
SmsSid: string
SmsStatus: string
MessageStatus: string
zatkiller marked this conversation as resolved.
Show resolved Hide resolved
To: string
MessageSid: string
AccountSid: string
From: string
ApiVersion: string
ErrorCode?: number // Only filled when it is 'failed' or 'undelivered'
ErrorMessage?: string // Only filled when it is 'failed' or 'undelivered'
}