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