sms-webhook.spec.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. import { expect, test } from '@playwright/test'
  2. import {
  3. deleteInboundSms,
  4. forgetConnections,
  5. inboundSmsCount,
  6. insertConnection,
  7. ownerOrganizationId,
  8. userIdFor,
  9. } from '../../support/db'
  10. import { sealCredentials, twilioSignature, webhookSecretHash } from '../../support/webhooks'
  11. /**
  12. * An inbound text message has to come from the vendor.
  13. *
  14. * The SMS webhooks authenticated on a secret in the URL and nothing else, and
  15. * a URL is something a vendor's dashboard, a log line or a support ticket
  16. * shows to people. Anyone who had seen it could post a message attributed to
  17. * any customer. Twilio signs every delivery with the account's auth token,
  18. * and the route checks that signature now; the secret in the URL only says
  19. * which workshop the call is for.
  20. *
  21. * The connection is planted with sealed keys rather than connected through
  22. * the page, because connecting tests the keys against Twilio.
  23. */
  24. test.describe.configure({ mode: 'serial' })
  25. const stamp = Date.now()
  26. const AUTH_TOKEN = `e2e-twilio-token-${stamp}`
  27. const URL_SECRET = `e2e-url-secret-${stamp}`
  28. const FROM = '+15551230000'
  29. const BODY = `E2E inbound ${stamp}`
  30. const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
  31. const webhook = `${baseURL}/api/webhooks/sms/twilio?org_secret=${URL_SECRET}`
  32. let organizationId = ''
  33. /** What Twilio posts: the message as form fields. */
  34. function message(body = BODY): Record<string, string> {
  35. return { From: FROM, To: '+15550009999', Body: body, MessageSid: `SM${stamp}` }
  36. }
  37. test.beforeAll(async () => {
  38. organizationId = await ownerOrganizationId()
  39. await forgetConnections(['twilio-sms'])
  40. await insertConnection({
  41. organizationId,
  42. connectorId: 'twilio-sms',
  43. credentials: sealCredentials({
  44. accountSid: 'ACe2e',
  45. authToken: AUTH_TOKEN,
  46. webhookSecret: URL_SECRET,
  47. }),
  48. settings: { webhookSecretHash: webhookSecretHash(URL_SECRET) },
  49. createdById: await userIdFor('demo@torqvoice.com'),
  50. })
  51. })
  52. test.afterAll(async () => {
  53. await forgetConnections(['twilio-sms'])
  54. await deleteInboundSms(organizationId, BODY)
  55. })
  56. test.describe('a text message posted to the Twilio webhook', () => {
  57. test('with the wrong URL secret is for nobody', async ({ request }) => {
  58. const response = await request.post(`${baseURL}/api/webhooks/sms/twilio?org_secret=wrong`, {
  59. form: message(),
  60. })
  61. expect(response.status()).toBe(403)
  62. expect(await response.json()).toEqual({ error: 'Invalid org_secret' })
  63. })
  64. test('without Twilio’s signature is dropped', async ({ request }) => {
  65. const response = await request.post(webhook, { form: message() })
  66. expect(response.status()).toBe(403)
  67. expect(await response.json()).toEqual({ error: 'Invalid signature' })
  68. expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
  69. })
  70. test('with a signature made with the wrong token is dropped', async ({ request }) => {
  71. const response = await request.post(webhook, {
  72. form: message(),
  73. headers: { 'x-twilio-signature': twilioSignature('not-the-token', webhook, message()) },
  74. })
  75. expect(response.status()).toBe(403)
  76. expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
  77. })
  78. test('with a signature Twilio would make is received', async ({ request }) => {
  79. const response = await request.post(webhook, {
  80. form: message(),
  81. headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, message()) },
  82. })
  83. expect(response.status()).toBe(200)
  84. expect(response.headers()['content-type']).toContain('text/xml')
  85. expect(await inboundSmsCount(organizationId, BODY), 'the message is filed once').toBe(1)
  86. })
  87. test('with a body that was changed after signing is dropped', async ({ request }) => {
  88. // The signature covers every field, so a message cannot be altered in
  89. // flight either.
  90. const signed = message()
  91. const response = await request.post(webhook, {
  92. form: message(`${BODY} tampered`),
  93. headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, signed) },
  94. })
  95. expect(response.status()).toBe(403)
  96. expect(await inboundSmsCount(organizationId, `${BODY} tampered`)).toBe(0)
  97. })
  98. })