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