Merge branch '380-set-port-out-pin' of https://git.secluded.site/sgx-bwmsgsv2

Stephen Paul Weber created

* '380-set-port-out-pin' of https://git.secluded.site/sgx-bwmsgsv2:
  chore: swap StandardError/RuntimeError, simplify
  feat: let users set their port-out PIN
  chore: update rubocop names
  test: rescue NameError
  chore: add missing webrick dependency

Change summary

.rubocop.yml                |   4 
Gemfile                     |   5 +
lib/bandwidth_tn_options.rb |  84 ++++++++++++++++++++++
sgx-bwmsgsv2.rb             | 147 ++++++++++++++++++++++++++++++++++++++
test/test_component.rb      | 109 ++++++++++++++++++++++++++++
test/test_helper.rb         |   2 
6 files changed, 347 insertions(+), 4 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -1,4 +1,4 @@
-Metrics/LineLength:
+Layout/LineLength:
   Max: 80
 
 Layout/IndentationStyle:
@@ -9,7 +9,7 @@ Layout/IndentationStyle:
 Layout/IndentationWidth:
   Width: 1 # one tab
 
-Lint/EndAlignment:
+Layout/EndAlignment:
   EnforcedStyleAlignWith: variable
 
 Lint/RescueException:

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'
@@ -16,6 +20,7 @@ gem 'multihashes'
 gem 'rack', '< 2'
 gem 'redis'
 gem "sentry-ruby", "<= 4.3.1"
+gem 'webrick'
 
 group(:development) do
 	gem "pry-reload"

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 "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(RuntimeError.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 "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 "Dashboard tnOptions warnings: #{descs}"
+			end
+
+			status = order[:order_status] || order[:processing_status]
+
+			case status&.to_s&.upcase
+			when 'COMPLETE', 'PARTIAL'
+				true
+			when 'FAILED'
+				raise "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 "Unexpected poll status: #{status.inspect}"
+			end
+		end
+	end
+end

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,

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

test/test_helper.rb 🔗

@@ -25,7 +25,7 @@ begin
 			end
 		end
 	end
-rescue LoadError
+rescue LoadError, NameError
 	# Just helpers for dev, no big deal if missing
 	nil
 end