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		BandwidthIris::TnOptions.stub :create_tn_option_order,
428			->(client, data) { {order_id: 'test-order-123', processing_status: 'RECEIVED', error_list: {}} } do
429			BandwidthIris::TnOptions.stub :get_tn_option_order,
430				->(client, order_id) { {order_id: order_id, order_status: 'COMPLETE', error_list: {}} } do
431
432				process_stanza(iq)
433
434				assert_equal 1, written.length
435
436				stanza = Blather::XMPPNode.parse(written.first.to_xml)
437				refute stanza.error?
438			end
439		end
440	end
441	em :test_port_out_pin
442
443	def test_port_out_pin_mismatch
444		iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
445			iq.from = 'test@example.com'
446			iq.node = 'set-port-out-pin'
447			iq.sessionid = 'test-session-mismatch'
448			iq.action = :complete
449			iq.form.type = :submit
450			iq.form.fields = [
451				{
452					var: 'pin',
453					value: '1234'
454				},
455				{
456					var: 'confirm_pin',
457					value: '5678'
458				}
459			]
460		end
461
462		process_stanza(iq)
463
464		assert_equal 1, written.length
465
466		stanza = Blather::XMPPNode.parse(written.first.to_xml)
467		assert_equal :error, stanza.type
468		error = stanza.find_first("error")
469		assert_equal "modify", error["type"]
470		assert_equal "bad-request", xmpp_error_name(error)
471		assert_equal "PIN confirmation does not match", xmpp_error_text(error)
472	end
473	em :test_port_out_pin_mismatch
474
475	def test_port_out_pin_validation
476		[
477			['123', 'PIN must be 4-10 alphanumeric characters'],
478			['12345678901', 'PIN must be 4-10 alphanumeric characters'],
479			['123!', 'PIN must be 4-10 alphanumeric characters'],
480			['pin with spaces', 'PIN must be 4-10 alphanumeric characters']
481		].each do |invalid_pin, expected_error|
482			iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
483				iq.from = 'test@example.com'
484				iq.node = 'set-port-out-pin'
485				iq.sessionid = "test-session-validation-#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}"
486				iq.action = :complete
487				iq.form.type = :submit
488				iq.form.fields = [
489					{
490						var: 'pin',
491						value: invalid_pin
492					},
493					{
494						var: 'confirm_pin',
495						value: invalid_pin
496					}
497				]
498			end
499
500			process_stanza(iq)
501
502			assert_equal 1, written.length, "Failed for PIN: #{invalid_pin}"
503
504			stanza = Blather::XMPPNode.parse(written.first.to_xml)
505			assert_equal :error, stanza.type, "Expected error for PIN: #{invalid_pin}"
506			error = stanza.find_first("error")
507			assert_equal "modify", error["type"]
508			assert_equal "bad-request", xmpp_error_name(error)
509			assert_equal expected_error, xmpp_error_text(error),
510			             "Wrong error message for PIN: #{invalid_pin}"
511
512			SGXbwmsgsv2.instance_variable_set(:@written, [])
513		end
514	end
515	em :test_port_out_pin_validation
516
517	def test_outbound_message_emits_to_stream
518		stub_request(
519			:post,
520			"https://messaging.bandwidth.com/api/v2/users/account/messages"
521		).with(body: hash_including(
522			from: "+15550000000",
523			to: "+15551234567",
524			text: "Hello world"
525		)).to_return(status: 201, body: JSON.dump(id: "bw-msg-123"))
526
527		m = Blather::Stanza::Message.new("+15551234567@component", "Hello world")
528		m.from = "test@example.com/resource"
529		m['id'] = "stanza-123"
530		process_stanza(m)
531
532		entries = REDIS.stream_entries("messages").sync
533		assert_equal 1, entries.length
534
535		event = entries.first[:fields]
536		assert_equal "out", event["event"]
537		assert_equal "+15550000000", event["from"]
538		assert_equal JSON.dump(["+15551234567"]), event["to"]
539		assert_equal "bw-msg-123", event["message_id"]
540		assert_equal "false", event["has_media"]
541		assert_equal "Hello world", event["body"]
542	end
543	em :test_outbound_message_emits_to_stream
544
545	def test_passthrough_message_emits_to_stream
546		REDIS.set("catapult_jid-+15559999999", "other@example.com")
547		REDIS.set("catapult_cred-other@example.com", [
548			'other_acct', 'other_user', 'other_pw', '+15559999999'
549		])
550
551		m = Blather::Stanza::Message.new("+15559999999@component", "Pass through")
552		m.from = "test@example.com/resource"
553		m['id'] = "passthru-stanza-456"
554		process_stanza(m)
555
556		entries = REDIS.stream_entries("messages").sync
557		assert_equal 1, entries.length
558
559		event = entries.first[:fields]
560		assert_equal "thru", event["event"]
561		assert_equal "+15550000000", event["from"]
562		assert_equal JSON.dump(["+15559999999"]), event["to"]
563		assert_equal "passthru-stanza-456", event["message_id"]
564		assert_equal "false", event["has_media"]
565		assert_equal "Pass through", event["body"]
566	end
567	em :test_passthrough_message_emits_to_stream
568
569	def invoke_webhook(payload)
570		handler = WebhookHandler.new
571		env = {
572			"REQUEST_URI" => "/",
573			"REQUEST_METHOD" => "POST",
574			"params" => {"_json" => [payload]}
575		}
576		handler.instance_variable_set(:@env, env)
577		def handler.params
578			@env["params"]
579		end
580
581		EMPromise.resolve(nil).then {
582			handler.response(env)
583		}.sync
584	end
585
586	def test_inbound_sms_emits_to_stream
587		payload = {
588			"type" => "message-received",
589			"message" => {
590				"id" => "bw-in-123",
591				"direction" => "in",
592				"owner" => "+15550000000",
593				"from" => "+15551234567",
594				"to" => ["+15550000000"],
595				"time" => "2025-01-13T10:00:00Z",
596				"text" => "Hello from outside"
597			}
598		}
599
600		invoke_webhook(payload)
601
602		entries = REDIS.stream_entries("messages").sync
603		assert_equal 1, entries.length
604
605		event = entries.first[:fields]
606		assert_equal "in", event["event"]
607		assert_equal "+15551234567", event["from"]
608		assert_equal JSON.dump(["+15550000000"]), event["to"]
609		assert_equal "bw-in-123", event["message_id"]
610		assert_equal "false", event["has_media"]
611		assert_equal "Hello from outside", event["body"]
612		assert_equal JSON.dump([]), event["media_urls"]
613	end
614	em :test_inbound_sms_emits_to_stream
615
616	def test_inbound_mms_emits_to_stream_and_filters_smil
617		payload = {
618			"type" => "message-received",
619			"message" => {
620				"id" => "bw-mms-456",
621				"direction" => "in",
622				"owner" => "+15550000000",
623				"from" => "+15551234567",
624				"to" => ["+15550000000"],
625				"time" => "2025-01-13T10:05:00Z",
626				"text" => "Check this out",
627				"media" => [
628					"https://example.com/image.jpg",
629					"https://example.com/file.smil",
630					"https://example.com/data.txt",
631					"https://example.com/meta.xml"
632				]
633			}
634		}
635
636		invoke_webhook(payload)
637
638		entries = REDIS.stream_entries("messages").sync
639		assert_equal 1, entries.length
640
641		event = entries.first[:fields]
642		assert_equal "in", event["event"]
643		assert_equal "true", event["has_media"]
644		assert_equal JSON.dump(["https://example.com/image.jpg"]), event["media_urls"]
645	end
646	em :test_inbound_mms_emits_to_stream_and_filters_smil
647
648	def test_message_delivered_emits_to_stream
649		payload = {
650			"type" => "message-delivered",
651			"message" => {
652				"id" => "bw-out-789",
653				"direction" => "out",
654				"owner" => "+15550000000",
655				"from" => "+15550000000",
656				"to" => ["+15551234567"],
657				"time" => "2025-01-13T10:10:00Z",
658				"tag" => "stanza-id-abc%20extra-data"
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 "delivered", event["event"]
669		assert_equal "stanza-id-abc", event["message_id"]
670		assert_equal "2025-01-13T10:10:00Z", event["timestamp"]
671	end
672	em :test_message_delivered_emits_to_stream
673
674	def test_message_failed_emits_to_stream
675		payload = {
676			"type" => "message-failed",
677			"message" => {
678				"id" => "bw-out-999",
679				"direction" => "out",
680				"owner" => "+15550000000",
681				"from" => "+15550000000",
682				"to" => ["+15551234567"],
683				"time" => "2025-01-13T10:15:00Z",
684				"tag" => "failed-stanza-xyz%20extra",
685				"errorCode" => 4720,
686				"description" => "Carrier rejected message"
687			}
688		}
689
690		invoke_webhook(payload)
691
692		entries = REDIS.stream_entries("messages").sync
693		assert_equal 1, entries.length
694
695		event = entries.first[:fields]
696		assert_equal "failed", event["event"]
697		assert_equal "failed-stanza-xyz", event["message_id"]
698		assert_equal "4720", event["error_code"]
699		assert_equal "Carrier rejected message", event["error_description"]
700		assert_equal "2025-01-13T10:15:00Z", event["timestamp"]
701	end
702	em :test_message_failed_emits_to_stream
703end