whatsapp-webhook.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import { expect, test } from '@playwright/test'
  2. import { createHmac } from 'node:crypto'
  3. import {
  4. deleteInboundWhatsapp,
  5. forgetConnections,
  6. inboundWhatsappCount,
  7. insertConnection,
  8. ownerOrganizationId,
  9. userIdFor,
  10. } from '../../support/db'
  11. import { sealCredentials } from '../../support/webhooks'
  12. /**
  13. * A WhatsApp message has to come from Meta.
  14. *
  15. * The webhook URL names only the workshop's id, which every share link and
  16. * public logo carries, so the signature Meta puts on each delivery is the
  17. * whole of the proof. The app secret that checks it used to be optional, and
  18. * a workshop that left it blank had a webhook anyone could post to: a made-up
  19. * message landed in the inbox as if a customer had written it. A delivery
  20. * that cannot be checked is not read now.
  21. *
  22. * The route answers 200 whatever happens, so that Meta does not retry, and
  23. * the outcome is read from the database.
  24. */
  25. test.describe.configure({ mode: 'serial' })
  26. const stamp = Date.now()
  27. const APP_SECRET = `e2e-meta-app-secret-${stamp}`
  28. const BODY = `E2E WhatsApp inbound ${stamp}`
  29. const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
  30. let organizationId = ''
  31. let webhook = ''
  32. /** What Meta posts for one text message. */
  33. function delivery(body: string): string {
  34. return JSON.stringify({
  35. entry: [
  36. {
  37. changes: [
  38. {
  39. value: {
  40. metadata: { display_phone_number: '15550009999' },
  41. contacts: [{ profile: { name: 'E2E Customer' } }],
  42. messages: [
  43. {
  44. id: `wamid.e2e.${stamp}.${body.length}`,
  45. from: '15551230000',
  46. timestamp: String(Math.floor(Date.now() / 1000)),
  47. type: 'text',
  48. text: { body },
  49. },
  50. ],
  51. },
  52. },
  53. ],
  54. },
  55. ],
  56. })
  57. }
  58. function signature(raw: string, secret: string): string {
  59. return `sha256=${createHmac('sha256', secret).update(raw).digest('hex')}`
  60. }
  61. async function plantConnection(withSecret: boolean): Promise<void> {
  62. await forgetConnections(['whatsapp-meta'])
  63. await insertConnection({
  64. organizationId,
  65. connectorId: 'whatsapp-meta',
  66. credentials: sealCredentials({
  67. phoneNumberId: '123456789012345',
  68. accessToken: 'e2e-access-token',
  69. verifyToken: 'e2e-verify-token',
  70. ...(withSecret ? { appSecret: APP_SECRET } : {}),
  71. }),
  72. settings: { enabled: true, phoneNumber: '+15550009999' },
  73. createdById: await userIdFor('demo@torqvoice.com'),
  74. })
  75. }
  76. test.beforeAll(async () => {
  77. organizationId = await ownerOrganizationId()
  78. webhook = `${baseURL}/api/webhooks/whatsapp/meta/${organizationId}`
  79. })
  80. test.afterAll(async () => {
  81. await forgetConnections(['whatsapp-meta'])
  82. await deleteInboundWhatsapp(organizationId, BODY)
  83. })
  84. test.describe('a message posted to the Meta webhook', () => {
  85. test('is dropped when the workshop has no app secret, even with a signature', async ({
  86. request,
  87. }) => {
  88. await plantConnection(false)
  89. const raw = delivery(`${BODY} unguarded`)
  90. const bare = await request.post(webhook, {
  91. data: raw,
  92. headers: { 'content-type': 'application/json' },
  93. })
  94. expect(bare.status(), 'Meta is told not to retry').toBe(200)
  95. // Whatever the poster signs it with: there is nothing to check it against.
  96. const signed = await request.post(webhook, {
  97. data: raw,
  98. headers: {
  99. 'content-type': 'application/json',
  100. 'x-hub-signature-256': signature(raw, 'guess'),
  101. },
  102. })
  103. expect(signed.status()).toBe(200)
  104. expect(
  105. await inboundWhatsappCount(organizationId, `${BODY} unguarded`),
  106. 'nothing was filed'
  107. ).toBe(0)
  108. })
  109. test('is dropped without Meta’s signature once the secret is set', async ({ request }) => {
  110. await plantConnection(true)
  111. const raw = delivery(`${BODY} unsigned`)
  112. await request.post(webhook, { data: raw, headers: { 'content-type': 'application/json' } })
  113. await request.post(webhook, {
  114. data: raw,
  115. headers: {
  116. 'content-type': 'application/json',
  117. 'x-hub-signature-256': signature(raw, 'wrong'),
  118. },
  119. })
  120. expect(await inboundWhatsappCount(organizationId, `${BODY} unsigned`)).toBe(0)
  121. })
  122. test('is filed once when signed with the app secret', async ({ request }) => {
  123. const raw = delivery(`${BODY} genuine`)
  124. const response = await request.post(webhook, {
  125. data: raw,
  126. headers: {
  127. 'content-type': 'application/json',
  128. 'x-hub-signature-256': signature(raw, APP_SECRET),
  129. },
  130. })
  131. expect(response.status()).toBe(200)
  132. expect(await inboundWhatsappCount(organizationId, `${BODY} genuine`)).toBe(1)
  133. })
  134. test('is dropped when the body was changed after signing', async ({ request }) => {
  135. const signedRaw = delivery(`${BODY} original`)
  136. await request.post(webhook, {
  137. data: delivery(`${BODY} tampered`),
  138. headers: {
  139. 'content-type': 'application/json',
  140. 'x-hub-signature-256': signature(signedRaw, APP_SECRET),
  141. },
  142. })
  143. expect(await inboundWhatsappCount(organizationId, `${BODY} tampered`)).toBe(0)
  144. })
  145. })