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