test_component.rb

  1# frozen_string_literal: true
  2
  3require "test_helper"
  4require_relative "../sgx-bwmsgsv2"
  5
  6def panic(e)
  7	$panic = e
  8end
  9
 10class ComponentTest < Minitest::Test
 11	def setup
 12		ARGV[0] = 'component'
 13		SGXbwmsgsv2.instance_variable_set(:@written, [])
 14
 15		def SGXbwmsgsv2.write_to_stream(s)
 16			@written ||= []
 17			@written << s
 18		end
 19
 20		REDIS.reset!
 21		REDIS.set("catapult_jid-", "HERE")
 22		REDIS.set("catapult_jid-+15550000000", "test@example.com")
 23		REDIS.set("catapult_cred-test@example.com", [
 24			'account', 'user', 'password', '+15550000000'
 25		])
 26	end
 27
 28	def written
 29		SGXbwmsgsv2.instance_variable_get(:@written)
 30	end
 31
 32	def xmpp_error_name(error)
 33		error.find_first(
 34			"child::*[name()!='text']",
 35			Blather::StanzaError::STANZA_ERR_NS
 36		).element_name
 37	end
 38
 39	def xmpp_error_text(error)
 40		error.find_first("ns:text", ns: Blather::StanzaError::STANZA_ERR_NS)&.text
 41	end
 42
 43	def process_stanza(s)
 44		SGXbwmsgsv2.send(:client).receive_data(s)
 45		raise $panic if $panic
 46	end
 47
 48	def test_message_unregistered
 49		m = Blather::Stanza::Message.new("+15551234567@component", "a"*4096)
 50		m.from = "unknown@example.com"
 51		process_stanza(m)
 52
 53		assert_equal 1, written.length
 54
 55		stanza = Blather::XMPPNode.parse(written.first.to_xml)
 56		assert stanza.error?
 57		error = stanza.find_first("error")
 58		assert_equal "auth", error["type"]
 59		assert_equal "registration-required", xmpp_error_name(error)
 60	end
 61	em :test_message_unregistered
 62
 63	def test_message_too_long
 64		req = stub_request(
 65			:post,
 66			"https://messaging.bandwidth.com/api/v2/users/account/messages"
 67		).with(body: {
 68			from: "+15550000000",
 69			to: "+15551234567",
 70			text: "a"*4096,
 71			applicationId: nil,
 72			tag: " "
 73		}).to_return(status: 400, body: JSON.dump(
 74			description: "Bad text.",
 75			fieldErrors: [{ description: "4096 not allowed" }]
 76		))
 77
 78		m = Blather::Stanza::Message.new("+15551234567@component", "a"*4096)
 79		m.from = "test@example.com"
 80		process_stanza(m)
 81
 82		assert_requested req
 83		assert_equal 1, written.length
 84
 85		stanza = Blather::XMPPNode.parse(written.first.to_xml)
 86		assert stanza.error?
 87		error = stanza.find_first("error")
 88		assert_equal "cancel", error["type"]
 89		assert_equal "internal-server-error", xmpp_error_name(error)
 90		assert_equal "Bad text. 4096 not allowed", xmpp_error_text(error)
 91	end
 92	em :test_message_too_long
 93
 94	def test_message_to_component_not_group
 95		m = Blather::Stanza::Message.new("component", "a"*4096)
 96		m.from = "test@example.com"
 97		process_stanza(m)
 98
 99		assert_equal 1, written.length
100
101		stanza = Blather::XMPPNode.parse(written.first.to_xml)
102		assert stanza.error?
103		error = stanza.find_first("error")
104		assert_equal "cancel", error["type"]
105		assert_equal "item-not-found", xmpp_error_name(error)
106	end
107	em :test_message_to_component_not_group
108
109	def test_message_to_invalid_num
110		m = Blather::Stanza::Message.new("123@component", "a"*4096)
111		m.from = "test@example.com"
112		process_stanza(m)
113
114		assert_equal 1, written.length
115
116		stanza = Blather::XMPPNode.parse(written.first.to_xml)
117		assert stanza.error?
118		error = stanza.find_first("error")
119		assert_equal "cancel", error["type"]
120		assert_equal "item-not-found", xmpp_error_name(error)
121	end
122	em :test_message_to_invalid_num
123
124	def test_message_to_anonymous
125		m = Blather::Stanza::Message.new(
126			"1;phone-context=anonymous.phone-context.soprani.ca@component",
127			"a"*4096
128		)
129		m.from = "test@example.com"
130		process_stanza(m)
131
132		assert_equal 1, written.length
133
134		stanza = Blather::XMPPNode.parse(written.first.to_xml)
135		assert stanza.error?
136		error = stanza.find_first("error")
137		assert_equal "cancel", error["type"]
138		assert_equal "gone", xmpp_error_name(error)
139	end
140	em :test_message_to_anonymous
141
142	def test_blank_message
143		m = Blather::Stanza::Message.new("+15551234567@component", " ")
144		m.from = "test@example.com"
145		process_stanza(m)
146
147		assert_equal 1, written.length
148
149		stanza = Blather::XMPPNode.parse(written.first.to_xml)
150		assert stanza.error?
151		error = stanza.find_first("error")
152		assert_equal "modify", error["type"]
153		assert_equal "policy-violation", xmpp_error_name(error)
154	end
155	em :test_blank_message
156
157	def test_ibr_bad_tel
158		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
159		iq.from = "newuser@example.com"
160		iq.phone = "5551234567"
161		process_stanza(iq)
162
163		assert_equal 1, written.length
164
165		stanza = Blather::XMPPNode.parse(written.first.to_xml)
166		assert stanza.error?
167		error = stanza.find_first("error")
168		assert_equal "cancel", error["type"]
169		assert_equal "item-not-found", xmpp_error_name(error)
170	end
171	em :test_ibr_bad_tel
172
173	def test_ibr_bad_creds
174		stub_request(
175			:get,
176			"https://messaging.bandwidth.com/api/v2/users/acct/media"
177		).with(basic_auth: ["user", "pw"]).to_return(status: 401)
178
179		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
180		iq.from = "newuser@example.com"
181		iq.phone = "+15551234567"
182		iq.nick = "acct"
183		iq.username = "user"
184		iq.password = "pw"
185		process_stanza(iq)
186
187		assert_equal 1, written.length
188
189		stanza = Blather::XMPPNode.parse(written.first.to_xml)
190		assert stanza.error?
191		error = stanza.find_first("error")
192		assert_equal "auth", error["type"]
193		assert_equal "not-authorized", xmpp_error_name(error)
194	end
195	em :test_ibr_bad_creds
196
197	def test_ibr_number_not_found
198		stub_request(
199			:get,
200			"https://messaging.bandwidth.com/api/v2/users/acct/media"
201		).with(basic_auth: ["user", "pw"]).to_return(status: 404)
202
203		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
204		iq.from = "newuser@example.com"
205		iq.phone = "+15551234567"
206		iq.nick = "acct"
207		iq.username = "user"
208		iq.password = "pw"
209		process_stanza(iq)
210
211		assert_equal 1, written.length
212
213		stanza = Blather::XMPPNode.parse(written.first.to_xml)
214		assert stanza.error?
215		error = stanza.find_first("error")
216		assert_equal "cancel", error["type"]
217		assert_equal "item-not-found", xmpp_error_name(error)
218	end
219	em :test_ibr_number_not_found
220
221	def test_ibr_other_error
222		stub_request(
223			:get,
224			"https://messaging.bandwidth.com/api/v2/users/acct/media"
225		).with(basic_auth: ["user", "pw"]).to_return(status: 400)
226
227		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
228		iq.from = "newuser@example.com"
229		iq.phone = "+15551234567"
230		iq.nick = "acct"
231		iq.username = "user"
232		iq.password = "pw"
233		process_stanza(iq)
234
235		assert_equal 1, written.length
236
237		stanza = Blather::XMPPNode.parse(written.first.to_xml)
238		assert stanza.error?
239		error = stanza.find_first("error")
240		assert_equal "modify", error["type"]
241		assert_equal "not-acceptable", xmpp_error_name(error)
242	end
243	em :test_ibr_other_error
244
245	def test_ibr_new
246		stub_request(
247			:get,
248			"https://messaging.bandwidth.com/api/v2/users/acct/media"
249		).with(basic_auth: ["user", "pw"]).to_return(status: 200, body: "[]")
250
251		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
252		iq.from = "test9@example.com"
253		iq.phone = "+15550000009"
254		iq.nick = "acct"
255		iq.username = "user"
256		iq.password = "pw"
257		process_stanza(iq)
258
259		assert_equal 1, written.length
260
261		stanza = Blather::XMPPNode.parse(written.first.to_xml)
262		refute stanza.error?
263		assert_equal(
264			["acct", "user", "pw", "+15550000009"],
265			REDIS.get("catapult_cred-test9@example.com").sync
266		)
267		assert_equal "test9@example.com", REDIS.get("catapult_jid-+15550000009").sync
268		assert REDIS.get("catapult_jid-").sync
269	end
270	em :test_ibr_new
271
272	def test_ibr_update
273		stub_request(
274			:get,
275			"https://messaging.bandwidth.com/api/v2/users/acct/media"
276		).with(basic_auth: ["user", "pw"]).to_return(status: 200, body: "[]")
277
278		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
279		iq.from = "test@example.com"
280		iq.phone = "+15550000009"
281		iq.nick = "acct"
282		iq.username = "user"
283		iq.password = "pw"
284		process_stanza(iq)
285
286		assert_equal 1, written.length
287
288		stanza = Blather::XMPPNode.parse(written.first.to_xml)
289		refute stanza.error?
290		assert_equal(
291			["acct", "user", "pw", "+15550000009"],
292			REDIS.get("catapult_cred-test@example.com").sync
293		)
294		assert_equal "test@example.com", REDIS.get("catapult_jid-+15550000009").sync
295		refute REDIS.get("catapult_jid-+15550000000").sync
296	end
297	em :test_ibr_update
298
299	def test_ibr_conflict
300		stub_request(
301			:get,
302			"https://messaging.bandwidth.com/api/v2/users/acct/media"
303		).with(basic_auth: ["user", "pw"]).to_return(status: 200, body: "[]")
304
305		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
306		iq.from = "test2@example.com"
307		iq.phone = "+15550000000"
308		iq.nick = "acct"
309		iq.username = "user"
310		iq.password = "pw"
311		process_stanza(iq)
312
313		assert_equal 1, written.length
314
315		stanza = Blather::XMPPNode.parse(written.first.to_xml)
316		assert stanza.error?
317		error = stanza.find_first("error")
318		assert_equal "cancel", error["type"]
319		assert_equal "conflict", xmpp_error_name(error)
320		assert_equal(
321			"Another user exists for +15550000000",
322			xmpp_error_text(error)
323		)
324	end
325	em :test_ibr_conflict
326
327	def test_ibr_remove
328		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
329		iq.from = "test@example.com"
330		iq.remove!
331		process_stanza(iq)
332
333		refute REDIS.get("catapult_cred-test@example.com").sync
334
335		assert_equal 1, written.length
336
337		stanza = Blather::XMPPNode.parse(written.first.to_xml)
338		assert stanza.result?
339	end
340	em :test_ibr_remove
341
342	def test_ibr_form
343		stub_request(
344			:get,
345			"https://messaging.bandwidth.com/api/v2/users/acct/media"
346		).with(basic_auth: ["user", "pw"]).to_return(status: 200, body: "[]")
347
348		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
349		iq.from = "formuser@example.com"
350		form = Blather::Stanza::X.find_or_create(iq.query)
351		form.fields = [
352			{
353				var: "nick",
354				value: "acct"
355			},
356			{
357				var: "username",
358				value: "user"
359			},
360			{
361				var: "password",
362				value: "pw"
363			},
364			{
365				var: "phone",
366				value: "+15551234567"
367			}
368		]
369		process_stanza(iq)
370
371		assert_equal(
372			["acct", "user", "pw", "+15551234567"],
373			REDIS.get("catapult_cred-formuser@example.com").sync
374		)
375
376		assert_equal(
377			"formuser@example.com",
378			REDIS.get("catapult_jid-+15551234567").sync
379		)
380
381		assert_equal 1, written.length
382		stanza = Blather::XMPPNode.parse(written.first.to_xml)
383		assert stanza.result?
384	end
385	em :test_ibr_form
386
387	def test_ibr_get_form_registered
388		iq = Blather::Stanza::Iq::IBR.new(:get, "component")
389		iq.from = "test@example.com"
390		process_stanza(iq)
391
392		assert_equal 1, written.length
393		stanza = Blather::XMPPNode.parse(written.first.to_xml)
394		assert stanza.result?
395		assert stanza.registered?
396		assert_equal(
397			["nick", "username", "password", "phone"],
398			stanza.form.fields.map(&:var)
399		)
400		assert stanza.instructions
401		assert stanza.nick
402		assert stanza.username
403		assert stanza.password
404		assert stanza.phone
405		refute stanza.email
406	end
407	em :test_ibr_get_form_registered
408
409	def test_port_out_pin
410		iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
411			iq.from = 'test@example.com'
412			iq.node = 'set-port-out-pin'
413			iq.sessionid = 'test-session-123'
414			iq.action = :complete
415			iq.form.type = :submit
416			iq.form.fields = [
417				{
418					var: 'pin',
419					value: '1234'
420				},
421				{
422					var: 'confirm_pin',
423					value: '1234'
424				}
425			]
426		end
427
428		tn_mock = Minitest::Mock.new
429		tn_mock.expect(:get_details, { tier: 0.0, on_net_vendor: true })
430		with_stubs([
431			[
432				BandwidthIris::TnOptions,
433				:create_tn_option_order,
434				->(client, data) { {order_id: 'test-order-123', processing_status: 'RECEIVED', error_list: {}} }
435			],
436			[
437				BandwidthIris::TnOptions,
438				:get_tn_option_order,
439				->(client, order_id) { {order_id: order_id, order_status: 'COMPLETE', error_list: {}} }
440			],
441			[
442				BandwidthIris::Tn,
443				:get,
444				tn_mock
445			]
446		]) do
447			process_stanza(iq)
448
449			assert_equal 1, written.length
450
451			stanza = Blather::XMPPNode.parse(written.first.to_xml)
452			refute stanza.error?
453			assert_mock tn_mock
454		end
455	end
456	em :test_port_out_pin
457
458	def test_port_out_pin_mismatch
459		iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
460			iq.from = 'test@example.com'
461			iq.node = 'set-port-out-pin'
462			iq.sessionid = 'test-session-mismatch'
463			iq.action = :complete
464			iq.form.type = :submit
465			iq.form.fields = [
466				{
467					var: 'pin',
468					value: '1234'
469				},
470				{
471					var: 'confirm_pin',
472					value: '5678'
473				}
474			]
475		end
476
477		process_stanza(iq)
478
479		assert_equal 1, written.length
480
481		stanza = Blather::XMPPNode.parse(written.first.to_xml)
482		assert_equal :error, stanza.type
483		error = stanza.find_first("error")
484		assert_equal "modify", error["type"]
485		assert_equal "bad-request", xmpp_error_name(error)
486		assert_equal "PIN confirmation does not match", xmpp_error_text(error)
487	end
488	em :test_port_out_pin_mismatch
489
490	def test_port_out_pin_validation
491		[
492			['123', 'PIN must be 4-10 alphanumeric characters'],
493			['12345678901', 'PIN must be 4-10 alphanumeric characters'],
494			['123!', 'PIN must be 4-10 alphanumeric characters'],
495			['pin with spaces', 'PIN must be 4-10 alphanumeric characters']
496		].each do |invalid_pin, expected_error|
497			iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
498				iq.from = 'test@example.com'
499				iq.node = 'set-port-out-pin'
500				iq.sessionid = "test-session-validation-#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}"
501				iq.action = :complete
502				iq.form.type = :submit
503				iq.form.fields = [
504					{
505						var: 'pin',
506						value: invalid_pin
507					},
508					{
509						var: 'confirm_pin',
510						value: invalid_pin
511					}
512				]
513			end
514
515			process_stanza(iq)
516
517			assert_equal 1, written.length, "Failed for PIN: #{invalid_pin}"
518
519			stanza = Blather::XMPPNode.parse(written.first.to_xml)
520			assert_equal :error, stanza.type, "Expected error for PIN: #{invalid_pin}"
521			error = stanza.find_first("error")
522			assert_equal "modify", error["type"]
523			assert_equal "bad-request", xmpp_error_name(error)
524			assert_equal expected_error, xmpp_error_text(error),
525			             "Wrong error message for PIN: #{invalid_pin}"
526
527			SGXbwmsgsv2.instance_variable_set(:@written, [])
528		end
529	end
530	em :test_port_out_pin_validation
531
532	def test_outbound_message_emits_to_stream
533		stub_request(
534			:post,
535			"https://messaging.bandwidth.com/api/v2/users/account/messages"
536		).with(body: hash_including(
537			from: "+15550000000",
538			to: "+15551234567",
539			text: "Hello world"
540		)).to_return(status: 201, body: JSON.dump(id: "bw-msg-123"))
541
542		m = Blather::Stanza::Message.new("+15551234567@component", "Hello world")
543		m.from = "test@example.com/resource"
544		m['id'] = "stanza-123"
545		process_stanza(m)
546
547		entries = REDIS.stream_entries("messages").sync
548		assert_equal 1, entries.length
549
550		event = entries.first[:fields]
551		assert_equal "out", event["event"]
552		assert_equal "+15550000000", event["from"]
553		assert_equal "+15550000000", event["owner"]
554		assert_equal JSON.dump(["+15551234567"]), event["to"]
555		assert_equal "stanza-123", event["stanza_id"]
556		assert_equal "bw-msg-123", event["bandwidth_id"]
557		assert_equal "Hello world", event["body"]
558	end
559	em :test_outbound_message_emits_to_stream
560
561	def test_passthrough_message_emits_to_stream
562		REDIS.set("catapult_jid-+15559999999", "other@example.com")
563		REDIS.set("catapult_cred-other@example.com", [
564			'other_acct', 'other_user', 'other_pw', '+15559999999'
565		])
566
567		m = Blather::Stanza::Message.new("+15559999999@component", "Pass through")
568		m.from = "test@example.com/resource"
569		m['id'] = "passthru-stanza-456"
570		process_stanza(m)
571
572		entries = REDIS.stream_entries("messages").sync
573		assert_equal 1, entries.length
574
575		event = entries.first[:fields]
576		assert_equal "thru", event["event"]
577		assert_equal "+15550000000", event["from"]
578		assert_equal JSON.dump(["+15559999999"]), event["to"]
579		assert_equal "passthru-stanza-456", event["stanza_id"]
580		assert_equal "Pass through", event["body"]
581		refute event.key?("timestamp"), "Thru events should not have a timestamp field"
582	end
583	em :test_passthrough_message_emits_to_stream
584
585	def invoke_webhook(payload, extra_env: {})
586		with_stubs([
587			[
588				SGXbwmsgsv2,
589				:write,
590				->(data) { SGXbwmsgsv2.write_to_stream(data) }
591			]
592		]) do
593			handler = WebhookHandler.new
594			env = {
595				"REQUEST_URI" => "/",
596				"REQUEST_METHOD" => "POST",
597				"params" => {"_json" => [payload]}
598			}.merge(extra_env)
599			handler.instance_variable_set(:@env, env)
600			def handler.params
601				@env["params"]
602			end
603
604			EMPromise.resolve(nil).then {
605				handler.response(env)
606			}.sync
607		end
608	end
609
610	def test_inbound_sms_emits_to_stream
611		payload = {
612			"type" => "message-received",
613			"to" => "+15550000000",
614			"message" => {
615				"id" => "bw-in-123",
616				"direction" => "in",
617				"owner" => "+15550000000",
618				"from" => "+15551234567",
619				"to" => ["+15550000000"],
620				"time" => "2025-01-13T10:00:00Z",
621				"text" => "Hello from outside"
622			}
623		}
624
625		invoke_webhook(payload)
626
627		entries = REDIS.stream_entries("messages").sync
628		assert_equal 1, entries.length
629
630		event = entries.first[:fields]
631		assert_equal "in", event["event"]
632		assert_equal "+15551234567", event["from"]
633		assert_equal JSON.dump(["+15550000000"]), event["to"]
634		assert_equal "bw-in-123", event["bandwidth_id"]
635		assert_equal "+15550000000", event["owner"]
636		assert_equal "Hello from outside", event["body"]
637		assert_equal JSON.dump([]), event["media_urls"]
638	end
639	em :test_inbound_sms_emits_to_stream
640
641	def test_inbound_mms_emits_to_stream_and_filters_smil
642		payload = {
643			"type" => "message-received",
644			"to" => "+15550000000",
645			"message" => {
646				"id" => "bw-mms-456",
647				"direction" => "in",
648				"owner" => "+15550000000",
649				"from" => "+15551234567",
650				"to" => ["+15550000000"],
651				"time" => "2025-01-13T10:05:00Z",
652				"text" => "Check this out",
653				"media" => [
654					"https://example.com/image.jpg",
655					"https://example.com/file.smil",
656					"https://example.com/data.txt",
657					"https://example.com/meta.xml"
658				]
659			}
660		}
661
662		invoke_webhook(payload)
663
664		entries = REDIS.stream_entries("messages").sync
665		assert_equal 1, entries.length
666
667		event = entries.first[:fields]
668		assert_equal "in", event["event"]
669		assert_equal JSON.dump(["https://example.com/image.jpg"]), event["media_urls"]
670	end
671	em :test_inbound_mms_emits_to_stream_and_filters_smil
672
673	def test_message_delivered_emits_to_stream
674		payload = {
675			"type" => "message-delivered",
676			"to" => "+15550000000",
677			"message" => {
678				"id" => "bw-out-789",
679				"direction" => "out",
680				"owner" => "+15550000000",
681				"from" => "+15550000000",
682				"to" => ["+15551234567"],
683				"time" => "2025-01-13T10:10:00Z",
684				"tag" => "stanza-id-abc extra-data"
685			}
686		}
687
688		invoke_webhook(payload)
689
690		entries = REDIS.stream_entries("messages").sync
691		assert_equal 1, entries.length
692
693		event = entries.first[:fields]
694		assert_equal "delivered", event["event"]
695		assert_equal "stanza-id-abc", event["stanza_id"]
696		assert_equal "bw-out-789", event["bandwidth_id"]
697		assert_equal "2025-01-13T10:10:00Z", event["timestamp"]
698	end
699	em :test_message_delivered_emits_to_stream
700
701	def test_message_failed_emits_to_stream
702		payload = {
703			"type" => "message-failed",
704			"to" => "+15551234567",
705			"message" => {
706				"id" => "bw-out-999",
707				"direction" => "out",
708				"owner" => "+15550000000",
709				"from" => "+15550000000",
710				"to" => ["+15551234567"],
711				"time" => "2025-01-13T10:15:00Z",
712				"tag" => "failed-stanza-xyz extra",
713				"errorCode" => 4720,
714				"description" => "Carrier rejected message"
715			}
716		}
717
718		invoke_webhook(payload)
719
720		entries = REDIS.stream_entries("messages").sync
721		assert_equal 1, entries.length
722
723		event = entries.first[:fields]
724		assert_equal "failed", event["event"]
725		assert_equal "failed-stanza-xyz", event["stanza_id"]
726		assert_equal "bw-out-999", event["bandwidth_id"]
727		assert_equal "4720", event["error_code"]
728		assert_equal "Carrier rejected message", event["error_description"]
729		assert_equal "2025-01-13T10:15:00Z", event["timestamp"]
730	end
731	em :test_message_failed_emits_to_stream
732
733	def test_resend_emits_resend_event_instead_of_in
734		payload = {
735			"type" => "message-received",
736			"message" => {
737				"id" => "bw-in-resend-001",
738				"direction" => "in",
739				"owner" => "+15550000000",
740				"from" => "+15551234567",
741				"to" => ["+15550000000"],
742				"time" => "2025-01-13T10:00:00Z",
743				"text" => "Resent message"
744			}
745		}
746
747		invoke_webhook(
748			payload,
749			extra_env: { "HTTP_X_JMP_RESEND_OF" => "1736762400000-0" }
750		)
751
752		entries = REDIS.stream_entries("messages").sync
753		assert_equal 1, entries.length
754
755		event = entries.first[:fields]
756		assert_equal "resend", event["event"]
757		assert_equal "bwmsgsv2", event["source"]
758		assert_equal "+15550000000", event["owner"]
759		assert_equal "1736762400000-0", event["original_stream_id"]
760		assert_equal "bw-in-resend-001", event["original_bandwidth_id"]
761		refute event.key?("from"), "Resend events should not duplicate message fields"
762		refute event.key?("body"), "Resend events should not duplicate message fields"
763	end
764	em :test_resend_emits_resend_event_instead_of_in
765
766	def test_sentry_captures_handler_exception
767		captured_exceptions = []
768		repo = SGXbwmsgsv2.instance_variable_get(:@registration_repo)
769
770		Sentry.stub :capture_exception, ->(*args, **) { captured_exceptions << args.first } do
771			# repo.find is called during message processing to look up creds;
772			# raising here bubbles up to SGXClient#handle_error
773			repo.stub :find, ->(_) { raise StandardError, "test error" } do
774				m = Blather::Stanza::Message.new("+15551234567@component", "hello")
775				m.from = "test@example.com"
776				process_stanza(m)
777			end
778		end
779
780		assert_equal 1, captured_exceptions.length
781		assert_instance_of StandardError, captured_exceptions.first
782		assert_equal "test error", captured_exceptions.first.message
783
784		assert_equal 1, written.length
785		stanza = Blather::XMPPNode.parse(written.first.to_xml)
786		assert stanza.error?
787		error = stanza.find_first("error")
788		assert_equal "cancel", error["type"]
789		assert_equal "internal-server-error", xmpp_error_name(error)
790
791		SGXbwmsgsv2.instance_variable_set(:@written, [])
792	end
793	em :test_sentry_captures_handler_exception
794end