test_webhook_handler.rb

  1# frozen_string_literal: true
  2
  3require "test_helper"
  4require_relative "../sgx-bwmsgsv2"
  5
  6def panic(e)
  7	$panic = e
  8end
  9
 10class WebhookHandlerTest < Minitest::Test
 11	def setup
 12		SGXbwmsgsv2.instance_variable_set(:@written, [])
 13
 14		def SGXbwmsgsv2.write_to_stream(s)
 15			@written ||= []
 16			@written << s
 17		end
 18
 19		REDIS.reset!
 20		REDIS.set("catapult_jid-", "HERE")
 21		REDIS.set("catapult_jid-+15550000000", "test@example.com")
 22		REDIS.set("catapult_cred-test@example.com", [
 23			'account', 'user', 'password', '+15550000000'
 24		])
 25	end
 26
 27	def written
 28		SGXbwmsgsv2.instance_variable_get(:@written)
 29	end
 30
 31	def invoke_webhook(payload, extra_env: {})
 32		with_stubs([
 33			[
 34				SGXbwmsgsv2,
 35				:write,
 36				->(data) { SGXbwmsgsv2.write_to_stream(data) }
 37			]
 38		]) do
 39			handler = WebhookHandler.new
 40			env = {
 41				"REQUEST_URI" => "/",
 42				"REQUEST_METHOD" => "POST",
 43				"params" => {"_json" => [payload]}
 44			}.merge(extra_env)
 45			handler.instance_variable_set(:@env, env)
 46			def handler.params
 47				@env["params"]
 48			end
 49
 50			EMPromise.resolve(nil).then {
 51				handler.response(env)
 52			}.sync
 53		end
 54	end
 55
 56	def test_inbound_sms_emits_to_stream
 57		payload = {
 58			"type" => "message-received",
 59			"to" => "+15550000000",
 60			"message" => {
 61				"id" => "bw-in-123",
 62				"direction" => "in",
 63				"owner" => "+15550000000",
 64				"from" => "+15551234567",
 65				"to" => ["+15550000000"],
 66				"time" => "2025-01-13T10:00:00Z",
 67				"text" => "Hello from outside"
 68			}
 69		}
 70
 71		invoke_webhook(payload)
 72
 73		entries = REDIS.stream_entries("messages").sync
 74		assert_equal 1, entries.length
 75
 76		event = entries.first[:fields]
 77		assert_equal "in", event["event"]
 78		assert_equal "+15551234567", event["from"]
 79		assert_equal JSON.dump(["+15550000000"]), event["to"]
 80		assert_equal "bw-in-123", event["bandwidth_id"]
 81		assert_equal "+15550000000", event["owner"]
 82		assert_equal "Hello from outside", event["body"]
 83		assert_equal JSON.dump([]), event["media_urls"]
 84	end
 85	em :test_inbound_sms_emits_to_stream
 86
 87	def test_inbound_mms_emits_to_stream_and_filters_smil
 88		payload = {
 89			"type" => "message-received",
 90			"to" => "+15550000000",
 91			"message" => {
 92				"id" => "bw-mms-456",
 93				"direction" => "in",
 94				"owner" => "+15550000000",
 95				"from" => "+15551234567",
 96				"to" => ["+15550000000"],
 97				"time" => "2025-01-13T10:05:00Z",
 98				"text" => "Check this out",
 99				"media" => [
100					"https://example.com/image.jpg",
101					"https://example.com/file.smil",
102					"https://example.com/data.txt",
103					"https://example.com/meta.xml"
104				]
105			}
106		}
107
108		invoke_webhook(payload)
109
110		entries = REDIS.stream_entries("messages").sync
111		assert_equal 1, entries.length
112
113		event = entries.first[:fields]
114		assert_equal "in", event["event"]
115		assert_equal JSON.dump(["https://example.com/image.jpg"]), event["media_urls"]
116	end
117	em :test_inbound_mms_emits_to_stream_and_filters_smil
118
119	def test_message_delivered_emits_to_stream
120		payload = {
121			"type" => "message-delivered",
122			"to" => "+15550000000",
123			"message" => {
124				"id" => "bw-out-789",
125				"direction" => "out",
126				"owner" => "+15550000000",
127				"from" => "+15550000000",
128				"to" => ["+15551234567"],
129				"time" => "2025-01-13T10:10:00Z",
130				"tag" => "stanza-id-abc extra-data"
131			}
132		}
133
134		invoke_webhook(payload)
135
136		entries = REDIS.stream_entries("messages").sync
137		assert_equal 1, entries.length
138
139		event = entries.first[:fields]
140		assert_equal "delivered", event["event"]
141		assert_equal "stanza-id-abc", event["stanza_id"]
142		assert_equal "bw-out-789", event["bandwidth_id"]
143		assert_equal "2025-01-13T10:10:00Z", event["timestamp"]
144	end
145	em :test_message_delivered_emits_to_stream
146
147	def test_message_failed_emits_to_stream
148		payload = {
149			"type" => "message-failed",
150			"to" => "+15551234567",
151			"message" => {
152				"id" => "bw-out-999",
153				"direction" => "out",
154				"owner" => "+15550000000",
155				"from" => "+15550000000",
156				"to" => ["+15551234567"],
157				"time" => "2025-01-13T10:15:00Z",
158				"tag" => "failed-stanza-xyz extra",
159				"errorCode" => 4720,
160				"description" => "Carrier rejected message"
161			}
162		}
163
164		invoke_webhook(payload)
165
166		entries = REDIS.stream_entries("messages").sync
167		assert_equal 1, entries.length
168
169		event = entries.first[:fields]
170		assert_equal "failed", event["event"]
171		assert_equal "failed-stanza-xyz", event["stanza_id"]
172		assert_equal "bw-out-999", event["bandwidth_id"]
173		assert_equal "4720", event["error_code"]
174		assert_equal "Carrier rejected message", event["error_description"]
175		assert_equal "2025-01-13T10:15:00Z", event["timestamp"]
176	end
177	em :test_message_failed_emits_to_stream
178
179	def test_resend_emits_resend_event_instead_of_in
180		payload = {
181			"type" => "message-received",
182			"message" => {
183				"id" => "bw-in-resend-001",
184				"direction" => "in",
185				"owner" => "+15550000000",
186				"from" => "+15551234567",
187				"to" => ["+15550000000"],
188				"time" => "2025-01-13T10:00:00Z",
189				"text" => "Resent message"
190			}
191		}
192
193		invoke_webhook(
194			payload,
195			extra_env: { "HTTP_X_JMP_RESEND_OF" => "1736762400000-0" }
196		)
197
198		entries = REDIS.stream_entries("messages").sync
199		assert_equal 1, entries.length
200
201		event = entries.first[:fields]
202		assert_equal "resend", event["event"]
203		assert_equal "bwmsgsv2", event["source"]
204		assert_equal "+15550000000", event["owner"]
205		assert_equal "1736762400000-0", event["original_stream_id"]
206		assert_equal "bw-in-resend-001", event["original_bandwidth_id"]
207		refute event.key?("from"), "Resend events should not duplicate message fields"
208		refute event.key?("body"), "Resend events should not duplicate message fields"
209	end
210	em :test_resend_emits_resend_event_instead_of_in
211
212	def test_message_received_empty_params_writes_no_stanza
213		result = invoke_webhook(nil, extra_env: { "params" => {} })
214		assert_equal [200, {}, "OK"], result
215		assert_empty written
216		entries = REDIS.stream_entries("messages").sync
217		assert_equal 0, entries.length
218	end
219	em :test_message_received_empty_params_writes_no_stanza
220
221	def test_message_received_unregistered_jid_writes_no_stanza
222		payload = {
223			"type" => "message-received",
224			"to" => "+15559999999",
225			"message" => {
226				"id" => "bw-unreg-001",
227				"direction" => "in",
228				"owner" => "+15559999999",
229				"from" => "+15551234567",
230				"to" => ["+15559999999"],
231				"time" => "2025-01-13T10:00:00Z",
232				"text" => "Hello"
233			}
234		}
235
236		result = invoke_webhook(payload)
237		assert_equal [403, {}, "Customer not found\n"], result
238		assert_empty written
239		entries = REDIS.stream_entries("messages").sync
240		assert_equal 0, entries.length
241	end
242	em :test_message_received_unregistered_jid_writes_no_stanza
243
244	def test_message_received_single_recipient_text_stanza
245		payload = {
246			"type" => "message-received",
247			"to" => "+15550000000",
248			"message" => {
249				"id" => "bw-in-txt-001",
250				"direction" => "in",
251				"owner" => "+15550000000",
252				"from" => "+15551234567",
253				"to" => ["+15550000000"],
254				"time" => "2025-01-13T10:00:00Z",
255				"text" => "Hello from outside"
256			}
257		}
258
259		invoke_webhook(payload)
260
261		assert_equal 1, written.length
262		msg = written.first
263		assert_instance_of Blather::Stanza::Message, msg
264		assert_equal "test@example.com", msg.to.to_s
265		assert_equal "+15551234567@component", msg.from.to_s
266		assert_equal "Hello from outside", msg.body
267		refute msg.find_first("ns:x", ns: "jabber:x:oob")
268		refute msg.find_first(
269			"ns:addresses",
270			ns: "http://jabber.org/protocol/address"
271		)
272	end
273	em :test_message_received_single_recipient_text_stanza
274
275	def test_message_received_zero_recipients_writes_no_stanza
276		payload = {
277			"type" => "message-received",
278			"to" => "+15550000000",
279			"message" => {
280				"id" => "bw-in-zero-001",
281				"direction" => "in",
282				"owner" => "+15550000000",
283				"from" => "+15551234567",
284				"to" => [],
285				"time" => "2025-01-13T10:00:00Z",
286				"text" => "Hello with empty to"
287			}
288		}
289
290		result = invoke_webhook(payload)
291		assert_equal [400, {}, "Missing params\n"], result
292		assert_empty written
293		entries = REDIS.stream_entries("messages").sync
294		assert_equal 0, entries.length
295	end
296	em :test_message_received_zero_recipients_writes_no_stanza
297
298	def test_message_received_group_stanza_has_addresses
299		payload = {
300			"type" => "message-received",
301			"to" => "+15550000000",
302			"message" => {
303				"id" => "bw-in-grp-001",
304				"direction" => "in",
305				"owner" => "+15550000000",
306				"from" => "+15551234567",
307				"to" => ["+15550000000", "+15559999999"],
308				"time" => "2025-01-13T10:00:00Z",
309				"text" => "Hello group"
310			}
311		}
312
313		invoke_webhook(payload)
314
315		assert_equal 1, written.length
316		msg = written.first
317		assert_equal "example.com", msg.to.to_s
318		assert_equal "+15551234567@component", msg.from.to_s
319		assert_equal "Hello group", msg.body
320
321		addrs = msg.find_first("addresses")
322		refute_nil addrs
323		assert_equal(
324			"http://jabber.org/protocol/address", addrs['xmlns']
325		)
326
327		address_nodes = addrs.find("address")
328		assert_equal 2, address_nodes.length
329
330		jid_addr = address_nodes.detect { |a| a['jid'] }
331		assert_equal "to", jid_addr['type']
332		assert_equal "test@example.com", jid_addr['jid']
333
334		uri_addr = address_nodes.detect { |a| a['uri'] }
335		assert_equal "to", uri_addr['type']
336		assert_equal "sms:+15559999999", uri_addr['uri']
337		assert_equal "true", uri_addr['delivered']
338	end
339	em :test_message_received_group_stanza_has_addresses
340
341	def test_message_received_single_recipient_with_media_stanza
342		payload = {
343			"type" => "message-received",
344			"to" => "+15550000000",
345			"message" => {
346				"id" => "bw-in-media-001",
347				"direction" => "in",
348				"owner" => "+15550000000",
349				"from" => "+15551234567",
350				"to" => ["+15550000000"],
351				"time" => "2025-01-13T10:00:00Z",
352				"text" => "Check this",
353				"media" => ["https://example.com/image.jpg"]
354			}
355		}
356
357		invoke_webhook(payload)
358
359		assert_equal 2, written.length
360
361		oob_msg = written[0]
362		assert_equal "test@example.com", oob_msg.to.to_s
363		assert_equal "+15551234567@component", oob_msg.from.to_s
364		oob_x = oob_msg.find_first("x")
365		refute_nil oob_x
366		assert_equal "jabber:x:oob", oob_x['xmlns']
367		oob_url = oob_x.find_first("url")
368		refute_nil oob_url
369		assert_equal "https://example.com/image.jpg", oob_url.text
370
371		text_msg = written[1]
372		assert_equal "test@example.com", text_msg.to.to_s
373		assert_equal "+15551234567@component", text_msg.from.to_s
374		assert_equal "Check this", text_msg.body
375	end
376	em :test_message_received_single_recipient_with_media_stanza
377
378	def test_message_received_empty_body_no_media_returns_400
379		payload = {
380			"type" => "message-received",
381			"to" => "+15550000000",
382			"message" => {
383				"id" => "bw-in-empty-001",
384				"direction" => "in",
385				"owner" => "+15550000000",
386				"from" => "+15551234567",
387				"to" => ["+15550000000"],
388				"time" => "2025-01-13T10:00:00Z",
389				"text" => ""
390			}
391		}
392
393		result = invoke_webhook(payload)
394		assert_equal [400, {}, "Missing params\n"], result
395		assert_empty written
396		entries = REDIS.stream_entries("messages").sync
397		assert_equal 0, entries.length
398	end
399	em :test_message_received_empty_body_no_media_returns_400
400
401	def test_message_received_missing_body_no_media_returns_400
402		payload = {
403			"type" => "message-received",
404			"to" => "+15550000000",
405			"message" => {
406				"id" => "bw-in-nil-001",
407				"direction" => "in",
408				"owner" => "+15550000000",
409				"from" => "+15551234567",
410				"to" => ["+15550000000"],
411				"time" => "2025-01-13T10:00:00Z"
412			}
413		}
414
415		result = invoke_webhook(payload)
416		assert_equal [400, {}, "Missing params\n"], result
417		assert_empty written
418		entries = REDIS.stream_entries("messages").sync
419		assert_equal 0, entries.length
420	end
421	em :test_message_received_missing_body_no_media_returns_400
422end