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