From f66d5aafcb9b97be98cd47be2923ed2cce34ca63 Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 12 Jun 2025 11:05:45 -0600 Subject: [PATCH] feat: add command to set port-out PIN Currently, port-out PINs require manual intervention from support. This command allows customers to self-manage their port-out PINs. Actual functionality is delegated to the user's backend. For now, that's just sgx-bwmsgsv2. References: https://todo.sr.ht/~singpolyma/soprani.ca/380 --- .rubocop.yml | 1 + forms/set_port_out_pin.rb | 22 +++++++ lib/backend_sgx.rb | 65 +++++++++++++++++++++ lib/command_list.rb | 16 ++++-- lib/customer.rb | 2 +- sgx_jmp.rb | 27 +++++++++ test/test_backend_sgx.rb | 118 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 forms/set_port_out_pin.rb diff --git a/.rubocop.yml b/.rubocop.yml index f78db8ea03c76de8ec7684a2a4a8caac22de8198..4d3de8ab729b90b1786d2f763e3ec031fee53cc2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,7 @@ Metrics/MethodLength: - test/* CountAsOne: - array + - hash Metrics/BlockLength: ExcludedMethods: diff --git a/forms/set_port_out_pin.rb b/forms/set_port_out_pin.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7ed6d872355de17f983f9c42e7c9af8853bad32 --- /dev/null +++ b/forms/set_port_out_pin.rb @@ -0,0 +1,22 @@ +form! +title "Set port-out PIN" +instructions( + "Your port-out PIN must be between 4 and 10 alphanumeric characters. " \ + "It'll be required when porting your number to another carrier." \ +) + +field( + var: "pin", + type: "text-private", + label: "Port-out PIN", + required: true, + description: "Enter 4-10 alphanumeric characters" +) + +field( + var: "confirm_pin", + type: "text-private", + label: "Confirm PIN", + required: true, + description: "Re-enter the same PIN to confirm" +) diff --git a/lib/backend_sgx.rb b/lib/backend_sgx.rb index 5badd5fa02e51496f677cc79b8d75405692b66a3..d976dc396ce6744d48053f112e9fb631ead7deca 100644 --- a/lib/backend_sgx.rb +++ b/lib/backend_sgx.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true require "blather" +require "blather/stanza/iq/command" require "value_semantics/monkey_patched" require_relative "customer_fwd" require_relative "not_loaded" +require_relative "blather_notify" +require_relative "form_to_h" class BackendSgx + using FormToH + value_semantics do jid Blather::JID creds HashOf(Symbol => String) @@ -46,6 +51,21 @@ class BackendSgx REDIS.set("catapult_ogm_url-#{from_jid}", url) end + def set_port_out_pin(pin) + cmd = build_port_out_command(:execute) + + IQ_MANAGER.write(cmd).then { |reply| + session_id = reply.command[:sessionid] + submit_cmd = build_submit_form(pin, session_id) + + IQ_MANAGER.write(submit_cmd).then { |submit_reply| + validate_submit_reply!(submit_reply) + }.catch { |e| + handle_pin_submission_error(e) + } + } + end + protected def ibr @@ -56,4 +76,49 @@ protected s.password = creds[:password] s end + + def build_submit_form(pin, session_id) + build_port_out_command(:complete, session_id: session_id).tap { |iq| + iq.form.type = :submit + iq.form.fields = [ + { var: "pin", value: pin, type: "text-private" }, + { var: "confirm_pin", value: pin, type: "text-private" } + ] + } + end + + def build_port_out_command(action, session_id: nil) + Blather::Stanza::Iq::Command.new.tap { |iq| + iq.to = jid + iq.from = from_jid + iq.node = "set-port-out-pin" + iq.action = action + iq.sessionid = session_id if session_id + } + end + + def validate_submit_reply!(submit_reply) + sub_text = submit_reply.note&.text + case submit_reply.status + when :completed + raise sub_text if submit_reply.note&.[]("type") == "error" + when :canceled + raise CanceledError, reply.note&.text + else + raise sub_text + end + end + + def handle_pin_submission_error(e) + if e.is_a?(Blather::StanzaError) || e.is_a?(RuntimeError) + EMPromise.reject(e) + else + Sentry.capture_exception(e) + EMPromise.reject( + RuntimeError.new( + "Unable to communicate with service. Please try again later." + ) + ) + end + end end diff --git a/lib/command_list.rb b/lib/command_list.rb index 44965d0594040ea7c1cae2e406e0f08289ede58e..88847d15fb63cfd9caaa02ef75718b32fee11da9 100644 --- a/lib/command_list.rb +++ b/lib/command_list.rb @@ -15,16 +15,24 @@ class CommandList end def self.args_for(customer, from_jid) + registered = customer&.registered args = { from_jid: from_jid, customer: customer, - tel: customer&.registered? ? customer&.registered?&.phone : nil, + tel: registered&.phone, fwd: customer&.fwd, feature_flags: customer&.feature_flags || [], - payment_methods: [] + payment_methods: [], + tn_portable: false } return EMPromise.resolve(args) unless customer&.plan_name - customer.payment_methods.then do |payment_methods| - args.merge(payment_methods: payment_methods) + EMPromise.all([ + customer.payment_methods, + args[:tel] && customer.active? && customer.tn_portable? + ]).then do |payment_methods, eligible| + args.merge( + payment_methods: payment_methods, + tn_portable: eligible + ) end end diff --git a/lib/customer.rb b/lib/customer.rb index 0b16d7fcd5b4a8e966f6f3f524bc111dea43b16e..d11f800bbfdbab31d3fe9afeb1249c2527b98998 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -27,7 +27,7 @@ class Customer :expires_at, :monthly_price, :save_plan!, :auto_top_up_amount, :extend_plan, :status def_delegators :@sgx, :deregister!, :register!, :registered?, :set_ogm_url, - :fwd, :transcription_enabled + :fwd, :transcription_enabled, :set_port_out_pin, :tn_portable? def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage, :calling_charges_this_month def_delegators :@financials, :payment_methods, :declines, :mark_decline, diff --git a/sgx_jmp.rb b/sgx_jmp.rb index fc2cc27f97c7e57f0d33cdfe0fcd3c8d8d2d4ec6..ca024c70c1e1acd59cac4f1711303d3bf8c0ef1d 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -877,6 +877,33 @@ Command.new( end }.register(self).then(&CommandList.method(:register)) +Command.new( + "set-port-out-pin", + "🔐 Set Port-out PIN", + list_for: lambda do |customer:, tn_portable:, **| + customer&.active? && tn_portable + end +) { + Command.customer.then do |customer| + Command.reply { |reply| + reply.command << FormTemplate.render("set_port_out_pin") + }.then { |iq| + pin = iq.form.field("pin")&.value.to_s + confirm_pin = iq.form.field("confirm_pin")&.value.to_s + + unless pin.match?(/\A[\w\d]{4,10}\Z/) + raise "PIN must be between 4 and 10 alphanumeric characters." + end + + raise "PIN and confirm PIN must match." unless pin == confirm_pin + + customer.set_port_out_pin(pin) + }.then do + Command.finish("Your port-out PIN has been set.") + end + end +}.register(self).then(&CommandList.method(:register)) + Command.new( "terminate account", "❌ Cancel your account and terminate your phone number", diff --git a/test/test_backend_sgx.rb b/test/test_backend_sgx.rb index e1ee50d64e71a775825f1d2e8db48bb015e7ca29..9fe4fedef0334560be2642f4341040860e7a9be7 100644 --- a/test/test_backend_sgx.rb +++ b/test/test_backend_sgx.rb @@ -4,6 +4,7 @@ require "test_helper" require "bwmsgsv2_repo" require "backend_sgx" require "trivial_backend_sgx_repo" +require "customer" BackendSgx::IQ_MANAGER = Minitest::Mock.new IBRRepo::IQ_MANAGER = Minitest::Mock.new @@ -58,4 +59,121 @@ class BackendSgxTest < Minitest::Test BackendSgx::IQ_MANAGER.verify end em :test_register! + + def test_set_port_out_pin_happy_path + sgx = TrivialBackendSgxRepo.new(redis: FakeRedis.new).get("test").sync + cust = customer("test", sgx: sgx) + + port_out_pin = "74hwsn" + session_id = "session_yay_awesome" + + BackendSgx::IQ_MANAGER.expect( + :write, + EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq| + iq.command[:sessionid] = session_id + }), + [Matching.new do |iq| + assert_equal CONFIG[:sgx], iq.to.to_s + assert_equal "customer_test@component", iq.from.to_s + assert_equal "set-port-out-pin", iq.node + assert_equal :execute, iq.action + end] + ) + + BackendSgx::IQ_MANAGER.expect( + :write, + EMPromise.resolve(OpenStruct.new( + status: :completed, + note_type: :info + )), + [Matching.new do |iq| + assert_equal :complete, iq.action + assert_equal :submit, iq.form.type + assert_equal CONFIG[:sgx], iq.to.to_s + assert_equal "customer_test@component", iq.from.to_s + assert_equal "set-port-out-pin", iq.node + assert_equal session_id, iq.sessionid + + pin_field = iq.form.fields.find { |f| f.var == "pin" } + assert_equal port_out_pin, pin_field.value + assert_equal "text-private", pin_field.type + + confirm_field = iq.form.fields.find { |f| f.var == "confirm_pin" } + assert_equal port_out_pin, confirm_field.value + assert_equal "text-private", confirm_field.type + end] + ) + + result = sgx.set_port_out_pin(cust, port_out_pin).sync + assert_nil result + assert_mock BackendSgx::IQ_MANAGER + end + em :test_set_port_out_pin_happy_path + + def test_set_port_out_pin_validation + sgx = TrivialBackendSgxRepo.new(redis: FakeRedis.new).get("test").sync + cust = customer("test", sgx: sgx) + + [ + ["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"], + ["", "PIN must be 4-10 alphanumeric characters"] + ].each do |invalid_pin, expected_error| + session_id = "session_validation_#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}" + + BackendSgx::IQ_MANAGER.expect( + :write, + EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq| + iq.command[:sessionid] = session_id + }), + [Matching.new do |iq| + assert_equal CONFIG[:sgx], iq.to.to_s + assert_equal "customer_test@component", iq.from.to_s + assert_equal "set-port-out-pin", iq.node + assert_equal :execute, iq.action + end] + ) + + note = Struct.new(:text) do + def [](key) + "error" if key == "type" + end + end.new(expected_error) + + BackendSgx::IQ_MANAGER.expect( + :write, + EMPromise.resolve(OpenStruct.new( + status: :completed, + note: note + )), + [Matching.new do |iq| + assert_equal :complete, iq.action + assert_equal :submit, iq.form.type + assert_equal CONFIG[:sgx], iq.to.to_s + assert_equal "customer_test@component", iq.from.to_s + assert_equal "set-port-out-pin", iq.node + assert_equal session_id, iq.sessionid + + pin_field = iq.form.fields.find { |f| f.var == "pin" } + assert_equal invalid_pin, pin_field.value + assert_equal "text-private", pin_field.type + + confirm_field = iq.form.fields.find { |f| f.var == "confirm_pin" } + assert_equal invalid_pin, confirm_field.value + assert_equal "text-private", confirm_field.type + end] + ) + + error = assert_raises(RuntimeError) { + sgx.set_port_out_pin(cust, invalid_pin).sync + } + + assert_equal expected_error, error.message + end + + assert_mock BackendSgx::IQ_MANAGER + end + em :test_set_port_out_pin_validation end