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