diff --git a/Gemfile b/Gemfile index b9f21dd6e898ec4f724a87906ac92f4bff15a872..706acf7184ab13ece60d1034b69f7e0c28202b28 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,11 @@ gem 'blather', git: "https://github.com/adhearsion/blather.git", branch: "develo gem 'em-hiredis' gem 'em-http-request' gem 'em_promise.rb' +gem 'em-synchrony' gem 'eventmachine' +gem 'faraday', '~> 1.0' +gem 'faraday-em_synchrony' +gem 'ruby-bandwidth-iris' gem 'goliath' gem 'lazy_object' gem 'log4r' diff --git a/lib/bandwidth_tn_options.rb b/lib/bandwidth_tn_options.rb new file mode 100644 index 0000000000000000000000000000000000000000..630976f11aa49f9cb943b799229d8b6da1a12de8 --- /dev/null +++ b/lib/bandwidth_tn_options.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "em_promise" +require "ruby-bandwidth-iris" +Faraday.default_adapter = :em_synchrony + +class BandwidthTNOptions + def self.set_port_out_pin(user_id, token, secret, pin, tel) + client = BandwidthIris::Client.new( + account_id: user_id, + username: token, + password: secret + ) + + data = { + tn_option_groups: { + tn_option_group: [ + { + port_out_passcode: pin, + telephone_numbers: { + telephone_number: [tel] + } + } + ] + } + } + + EMPromise.resolve(nil).then do + order = BandwidthIris::TnOptions.create_tn_option_order(client, data) + order_id = order[:order_id] + + unless order_id + raise StandardError.new("Missing OrderId in create response") + end + + poll_order(order_id, client) + end + end + + def self.poll_order(order_id, client, attempt=1, max_attempts=30) + if attempt > max_attempts + return EMPromise.reject(StandardError.new( + "TnOptions polling timeout after #{max_attempts} attempts" + )) + end + + EMPromise.resolve(nil).then do + order = BandwidthIris::TnOptions.get_tn_option_order(client, order_id) + + error_list = order[:error_list] + if error_list&.any? + errors = [error_list].flatten + msgs = errors.map { |e| e.to_s }.reject(&:empty?).join('; ') + raise StandardError.new("Dashboard tnOptions errors: #{msgs}") + end + + warnings = order[:warnings] + if warnings&.any? + warns = [warnings].flatten + descs = warns.map { |w| w.to_s }.reject(&:empty?).join('; ') + raise StandardError.new("Dashboard tnOptions warnings: #{descs}") + end + + status = order[:order_status] || order[:processing_status] + + case status&.to_s&.upcase + when 'COMPLETE', 'PARTIAL' + true + when 'FAILED' + raise StandardError.new("TnOptions order failed with status: #{status}") + when 'RECEIVED', 'PROCESSING' + EMPromise.new { |resolve, reject| + EM.add_timer(2) do + poll_order(order_id, client, attempt + 1, max_attempts) + .then { |result| resolve.call(result) } + .catch { |error| reject.call(error) } + end + } + else + raise StandardError.new("Unexpected poll status: #{status.inspect}") + end + end + end +end diff --git a/sgx-bwmsgsv2.rb b/sgx-bwmsgsv2.rb index 7f27b44230eb20e9710a27a1e82c17922ef4e575..fe35e809312b2e75dcc22acbbffa20cfcbe936f9 100755 --- a/sgx-bwmsgsv2.rb +++ b/sgx-bwmsgsv2.rb @@ -36,8 +36,10 @@ require 'goliath/server' require 'log4r' require 'em_promise' +require 'em-synchrony' require_relative 'lib/bandwidth_error' +require_relative 'lib/bandwidth_tn_options' require_relative 'lib/registration_repo' Sentry.init @@ -150,7 +152,8 @@ module SGXbwmsgsv2 @gateway_features = [ "http://jabber.org/protocol/disco#info", "http://jabber.org/protocol/address/", - "jabber:iq:register" + "jabber:iq:register", + "http://jabber.org/protocol/commands" ] def self.run @@ -534,6 +537,35 @@ module SGXbwmsgsv2 write_to_stream msg end + disco_items( + to: Blather::JID.new(ARGV[0]), + node: "http://jabber.org/protocol/commands" + ) do |i| + # Ensure user is registered, but discard their credentials because we don't + # need them yet + fetch_catapult_cred_for(i.from).then { |_creds| + reply = i.reply + reply.node = 'http://jabber.org/protocol/commands' + + reply.items = [ + Blather::Stanza::DiscoItems::Item.new( + i.to, + 'set-port-out-pin', + 'Set Port-Out PIN' + ) + ] + + puts 'RESPONSE_CMD_DISCO: ' + reply.inspect + write_to_stream reply + }.catch { |e| + if e.is_a?(Array) && [2, 3].include?(e.length) + write_to_stream i.as_error(e[1], e[0], e[2]) + else + EMPromise.reject(e) + end + } + end + iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i| # TODO: return error if i.type is :set - if it is :reply or # :error it should be ignored (as the below does currently); @@ -720,6 +752,119 @@ module SGXbwmsgsv2 }.catch(&method(:panic)) end + command :execute?, node: "set-port-out-pin", sessionid: nil do |iq| + # Ensure user is registered, but discard their credentials because we don't + # need them yet + fetch_catapult_cred_for(iq.from).then { |_creds| + reply = iq.reply + reply.node = 'set-port-out-pin' + reply.sessionid = SecureRandom.uuid + reply.status = :executing + + form = Blather::Stanza::X.find_or_create(reply.command) + form.type = "form" + form.fields = [ + { + var: 'pin', + type: 'text-private', + label: 'Port-Out PIN', + required: true + }, + { + var: 'confirm_pin', + type: 'text-private', + label: 'Confirm PIN', + required: true + } + ] + + reply.command.add_child(form) + reply.allowed_actions = [:complete] + + puts "RESPONSE_CMD_FORM: #{reply.inspect}" + write_to_stream reply + }.catch { |e| + if e.is_a?(Array) && [2, 3].include?(e.length) + write_to_stream iq.as_error(e[1], e[0], e[2]) + else + EMPromise.reject(e) + end + }.catch(&method(:panic)) + end + + command :complete?, node: "set-port-out-pin", sessionid: /./ do |iq| + pin = iq.form.field('pin')&.value + confirm_pin = iq.form.field('confirm_pin')&.value + + if pin.nil? || confirm_pin.nil? + write_to_stream iq.as_error( + 'bad-request', + :modify, + 'PIN fields are required' + ) + next + end + + if pin != confirm_pin + write_to_stream iq.as_error( + 'bad-request', + :modify, + 'PIN confirmation does not match' + ) + next + end + + if pin !~ /\A[a-zA-Z0-9]{4,10}\z/ + write_to_stream iq.as_error( + 'bad-request', + :modify, + 'PIN must be 4-10 alphanumeric characters' + ) + next + end + + fetch_catapult_cred_for(iq.from).then { |creds| + user_id, token, secret, phone_num = creds + + # Stripping +1 like this feels janky, but Bandwidth only deals in +1 + # numbers and this is a Bandwidth SGX, so it's fine. + phone_num_local = phone_num.sub(/^\+1/, '') + BandwidthTNOptions.set_port_out_pin(user_id, token, secret, pin, phone_num_local).then { + reply = iq.reply + reply.node = 'set-port-out-pin' + reply.sessionid = iq.sessionid + reply.status = :completed + reply.note_type = :info + reply.note_text = 'Port-out PIN has been set successfully.' + + write_to_stream reply + }.catch { |e| + reply = iq.reply + reply.node = 'set-port-out-pin' + reply.sessionid = iq.sessionid + reply.status = :completed + reply.note_type = :error + error_msg = if e.respond_to?(:message) && e.message.include?('not valid') + "Invalid phone number format. "\ + "Please check your registered phone number." + elsif e.respond_to?(:message) && e.message.include?('ErrorCode') + "Bandwidth API error: #{e.message}" + else + "Failed to set port-out PIN. Please try again later." + end + reply.note_text = error_msg + + write_to_stream reply + } + }.catch { |e| + if e.is_a?(Array) && [2, 3].include?(e.length) + write_to_stream iq.as_error(e[1], e[0], e[2]) + else + EMPromise.reject(e) + end + }.catch(&method(:panic)) + end + iq type: [:get, :set] do |iq| write_to_stream(Blather::StanzaError.new( iq, diff --git a/test/test_component.rb b/test/test_component.rb index 0e985e912c986765f080f5a4230e553c6f98e91d..9ee3d95cd50b270b6f36f072635bf5012366d7ae 100644 --- a/test/test_component.rb +++ b/test/test_component.rb @@ -404,4 +404,113 @@ class ComponentTest < Minitest::Test refute stanza.email end em :test_ibr_get_form_registered + + def test_port_out_pin + iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq| + iq.from = 'test@example.com' + iq.node = 'set-port-out-pin' + iq.sessionid = 'test-session-123' + iq.action = :complete + iq.form.type = :submit + iq.form.fields = [ + { + var: 'pin', + value: '1234' + }, + { + var: 'confirm_pin', + value: '1234' + } + ] + end + + BandwidthIris::TnOptions.stub :create_tn_option_order, + ->(client, data) { {order_id: 'test-order-123', processing_status: 'RECEIVED', error_list: {}} } do + BandwidthIris::TnOptions.stub :get_tn_option_order, + ->(client, order_id) { {order_id: order_id, order_status: 'COMPLETE', error_list: {}} } do + + process_stanza(iq) + + assert_equal 1, written.length + + stanza = Blather::XMPPNode.parse(written.first.to_xml) + refute stanza.error? + end + end + end + em :test_port_out_pin + + def test_port_out_pin_mismatch + iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq| + iq.from = 'test@example.com' + iq.node = 'set-port-out-pin' + iq.sessionid = 'test-session-mismatch' + iq.action = :complete + iq.form.type = :submit + iq.form.fields = [ + { + var: 'pin', + value: '1234' + }, + { + var: 'confirm_pin', + value: '5678' + } + ] + end + + process_stanza(iq) + + assert_equal 1, written.length + + stanza = Blather::XMPPNode.parse(written.first.to_xml) + assert_equal :error, stanza.type + error = stanza.find_first("error") + assert_equal "modify", error["type"] + assert_equal "bad-request", xmpp_error_name(error) + assert_equal "PIN confirmation does not match", xmpp_error_text(error) + end + em :test_port_out_pin_mismatch + + def test_port_out_pin_validation + [ + ['123', 'PIN must be 4-10 alphanumeric characters'], + ['12345678901', 'PIN must be 4-10 alphanumeric characters'], + ['123!', 'PIN must be 4-10 alphanumeric characters'], + ['pin with spaces', 'PIN must be 4-10 alphanumeric characters'] + ].each do |invalid_pin, expected_error| + iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq| + iq.from = 'test@example.com' + iq.node = 'set-port-out-pin' + iq.sessionid = "test-session-validation-#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}" + iq.action = :complete + iq.form.type = :submit + iq.form.fields = [ + { + var: 'pin', + value: invalid_pin + }, + { + var: 'confirm_pin', + value: invalid_pin + } + ] + end + + process_stanza(iq) + + assert_equal 1, written.length, "Failed for PIN: #{invalid_pin}" + + stanza = Blather::XMPPNode.parse(written.first.to_xml) + assert_equal :error, stanza.type, "Expected error for PIN: #{invalid_pin}" + error = stanza.find_first("error") + assert_equal "modify", error["type"] + assert_equal "bad-request", xmpp_error_name(error) + assert_equal expected_error, xmpp_error_text(error), + "Wrong error message for PIN: #{invalid_pin}" + + SGXbwmsgsv2.instance_variable_set(:@written, []) + end + end + em :test_port_out_pin_validation end