.rubocop.yml 🔗
@@ -13,6 +13,7 @@ Metrics/MethodLength:
- test/*
CountAsOne:
- array
+ - hash
Metrics/BlockLength:
ExcludedMethods:
Amolith created
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(-)
@@ -13,6 +13,7 @@ Metrics/MethodLength:
- test/*
CountAsOne:
- array
+ - hash
Metrics/BlockLength:
ExcludedMethods:
@@ -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"
+)
@@ -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
@@ -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
@@ -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,
@@ -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",
@@ -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