From 0adc5c5eccc946e0139076ab3a997ab53288d560 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Mar 2023 13:09:06 -0500 Subject: [PATCH 1/2] Finish onboarding by creating a Snikket instance Custom domain not supported by the API yet, so stub for now. --- Gemfile | 1 + config-schema.dhall | 1 + config.dhall.sample | 1 + forms/registration/snikket.rb | 33 ++++ lib/registration.rb | 129 ++++++++++++++ lib/snikket.rb | 17 ++ test/test_helper.rb | 2 + test/test_registration.rb | 306 ++++++++++++++++++++++++++++++++++ 8 files changed, 490 insertions(+) create mode 100644 forms/registration/snikket.rb diff --git a/Gemfile b/Gemfile index 1434dcd91915aa18af9fb265380c573ce9fe7ea1..c49fa1ab1781b561ddee05379edf86cde4f6fcb8 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem "em_promise.rb", "~> 0.0.4" gem "em-synchrony" gem "eventmachine" gem "faraday-em_http", git: "https://github.com/singpolyma/faraday-em_http", branch: "fix-gzip" +gem "link-header-parser" gem "money-open-exchange-rates" gem "multibases" gem "multihashes" diff --git a/config-schema.dhall b/config-schema.dhall index 82fe73c61c5561ceea3c95453ea80b212858edee..1b3aa07a4f6dcb5f95d733e9894c59d28f6ce9f1 100644 --- a/config-schema.dhall +++ b/config-schema.dhall @@ -27,6 +27,7 @@ , notify_from : Text , ogm_path : Text , ogm_web_root : Text +, onboarding_domain : Text , oxr_app_id : Text , payable : Text , plans : diff --git a/config.dhall.sample b/config.dhall.sample index 52686a7fbe41dbc8c173715de26d35fba85621e2..839f07a5f25110ed86cf1de8e9444fbd6f54cd69 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -82,6 +82,7 @@ in keep_area_codes = ["555"], keep_area_codes_in = { account = "", site_id = "", sip_peer_id = "" }, snikket_hosting_api = "", + onboarding_domain = "", rev_ai_token = "", upstream_domain = "example.net", approved_domains = toMap { `example.com` = Some "customer_id" }, diff --git a/forms/registration/snikket.rb b/forms/registration/snikket.rb new file mode 100644 index 0000000000000000000000000000000000000000..912b1abaca9002a93fdb3bd0eb997eb35d0cf692 --- /dev/null +++ b/forms/registration/snikket.rb @@ -0,0 +1,33 @@ +form! +title "Setup Snikket Instance" + +instructions( + "Phone number #{@tel} has been activated. " \ + "Now you will need a Jabber ID to associate with your new JMP account. " \ + "JMP has partnered with Snikket CIC to provide you with your very own " \ + "Snikket instance that will provide Jabber service to you and anyone you " \ + "invite.\n\n" \ + "A Jabber ID is kind of like an email address, and looks similar too. " \ + "People who contact you using your Jabber ID instead of your phone number " \ + "will get additional features, such as video calling and end-to-end " \ + "encryption.\n\n" \ + "You will be prompted to choose a username after your instance is ready." +) + +field( + label: "Choose a unique name for your Snikket instance", + var: "subdomain", + suffix: ".snikket.chat", + description: @error || + "Your Jabber ID will be of the form username@instance-name.snikket.chat" +) + +field( + label: "Action", + type: "list-single", + var: "http://jabber.org/protocol/commands#actions", + options: [ + { label: "Next", value: "next" }, + { label: "Use Your Own Domain Name", value: "custom_domain" } + ] +) diff --git a/lib/registration.rb b/lib/registration.rb index c50b806255d938128849854a76999c237159f156..26f482a984aa232ab4881212bdacf3abaee419a3 100644 --- a/lib/registration.rb +++ b/lib/registration.rb @@ -503,6 +503,135 @@ class Registration put_default_fwd ]) }.then do + FinishOnboarding.for(@customer, @tel).write + end + end + end + + module FinishOnboarding + def self.for(customer, tel) + jid = ProxiedJID.new(customer.jid).unproxied + if jid.domain == CONFIG[:onboarding_domain] + Snikket.new(tel) + else + NotOnboarding.new(customer, tel) + end + end + + class Snikket + def initialize(tel, error: nil) + @tel = tel + @error = error + end + + ACTION_VAR = "http://jabber.org/protocol/commands#actions" + + def write + Command.reply { |reply| + reply.allowed_actions = [:next] + reply.command << form + }.then do |iq| + if iq.form.field(ACTION_VAR)&.value == "custom_domain" + CustomDomain.new(@tel).write + else + launch("#{iq.form.field('subdomain')&.value}.snikket.chat") + end + end + end + + def form + FormTemplate.render( + "registration/snikket", + tel: @tel, + error: @error + ) + end + + def launch(domain) + IQ_MANAGER.write(::Snikket::Launch.new( + nil, CONFIG[:snikket_hosting_api], + domain: domain + )).then { |launched| + GetInvite.for(domain, launched).then(&:write) + }.catch { |e| + next EMPromise.reject(e) unless e.respond_to?(:text) + + Snikket.new(@tel, error: e.text).write + } + end + + class GetInvite + def self.for(domain, launched) + launched.fetch_invite(domain).then do |xmpp_uri| + if xmpp_uri + GoToInvite.new(xmpp_uri) + else + new(domain, launched) + end + end + end + + def initialize(domain, launched) + @domain = domain + @launched = launched + end + + def write + Command.reply { |reply| + reply.allowed_actions = [:next] + reply.note_type = :info + reply.note_text = + "Your instance #{@domain} is starting up. " \ + "This may take several minutes. " \ + "Press next to check if it is ready." + }.then { GetInvite.for(@domain, @launched).then(&:write) } + end + end + + class GoToInvite + def initialize(xmpp_uri) + @xmpp_uri = xmpp_uri + end + + def write + Command.finish do |reply| + oob = OOB.find_or_create(reply.command) + oob.url = @xmpp_uri + end + end + end + end + + class CustomDomain + def initialize(tel) + @tel = tel + end + + CONTACT_SUPPORT = + "Please contact JMP support to set up " \ + "an instance on an existing domain." + + def write + Command.reply { |reply| + reply.allowed_actions = [:prev] + reply.status = :canceled + reply.note_type = :info + reply.note_text = CONTACT_SUPPORT + }.then do |iq| + raise "Action not allowed" unless iq.prev? + + Snikket.new(@tel).write + end + end + end + + class NotOnboarding + def initialize(customer, tel) + @customer = customer + @tel = tel + end + + def write WelcomeMessage.new(@customer, @tel).welcome Command.finish("Your JMP account has been activated as #{@tel}") end diff --git a/lib/snikket.rb b/lib/snikket.rb index 3403d1c6335b5b2075da209cc5cef09036bee366..aa69e8e8530a2b13f96b7f2155b6fdda09090bd4 100644 --- a/lib/snikket.rb +++ b/lib/snikket.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require "blather" +require "em-http" +require "em_promise" +require "em-synchrony/em-http" # For apost vs post +require "link-header-parser" module Snikket class Launch < Blather::Stanza::Iq @@ -47,6 +51,19 @@ module Snikket "https://#{instance_domain}/invites_bootstrap?token=#{bootstrap_token}" end + def fetch_invite(instance_domain) + url = bootstrap_uri(instance_domain) + EM::HttpRequest.new( + url, tls: { verify_peer: true } + ).ahead(redirects: 5).then { |res| + LinkHeaderParser.parse( + Array(res.response_header["LINK"]), base: url + ).group_by_relation_type[:alternate]&.find do |header| + URI.parse(header.target_uri).scheme == "xmpp" + end&.target_uri + }.catch { nil } + end + def query at_xpath("./ns:launched", ns: self.class.registered_ns) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6d479f2815f6b7dbf911e65dff121939db02aa5c..be130085d6b42f80758333b9ce6ad6575cc382a1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -113,6 +113,8 @@ CONFIG = { bandwidth_site: "test_site", bandwidth_peer: "test_peer", keepgo: { api_key: "keepgokey", access_token: "keepgotoken" }, + snikket_hosting_api: "snikket.example.com", + onboarding_domain: "onboarding.example.com", adr: "A Mailing Address", interac: "interac@example.com", support_link: ->(*) { "https://support.com" } diff --git a/test/test_registration.rb b/test/test_registration.rb index e04ce53b6847d8c98cbddfebfa7fdd17e6ad3443..d9880565d896acad9ea1da5f44d55eb9012afa11 100644 --- a/test/test_registration.rb +++ b/test/test_registration.rb @@ -816,6 +816,89 @@ class RegistrationTest < Minitest::Test end em :test_write + def test_write_onboarding + create_order = stub_request( + :post, + "https://dashboard.bandwidth.com/v1.0/accounts//orders" + ).to_return(status: 201, body: <<~RESPONSE) + + + test_order + + + RESPONSE + stub_request( + :get, + "https://dashboard.bandwidth.com/v1.0/accounts//orders/test_order" + ).to_return(status: 201, body: <<~RESPONSE) + + COMPLETE + + + 5555550000 + + + + RESPONSE + stub_request( + :post, + "https://dashboard.bandwidth.com/v1.0/accounts//sites//sippeers//movetns" + ) + Registration::Finish::REDIS.expect( + :del, + nil, + ["pending_tel_for-test\\40onboarding.example.com@proxy"] + ) + Bwmsgsv2Repo::REDIS.expect( + :set, + nil, + [ + "catapult_fwd-+15555550000", + "xmpp:test\\40onboarding.example.com@proxy" + ] + ) + Bwmsgsv2Repo::REDIS.expect( + :set, + nil, + ["catapult_fwd_timeout-customer_test@component", 25] + ) + result = execute_command do + @sgx.expect( + :register!, + EMPromise.resolve(@sgx.with( + registered?: Blather::Stanza::Iq::IBR.new.tap do |ibr| + ibr.phone = "+15555550000" + end + )), + ["+15555550000"] + ) + + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert iq.form.field("subdomain") + end] + ) + + Registration::Finish.new( + customer( + sgx: @sgx, + jid: Blather::JID.new("test\\40onboarding.example.com@proxy") + ), + "+15555550000" + ).write.catch { |e| e } + end + assert_equal :test_result, result + assert_requested create_order + assert_mock @sgx + assert_mock Registration::Finish::REDIS + assert_mock Bwmsgsv2Repo::REDIS + assert_mock Command::COMMAND_MANAGER + end + em :test_write_onboarding + def test_write_tn_fail create_order = stub_request( :post, @@ -862,4 +945,227 @@ class RegistrationTest < Minitest::Test end em :test_write_tn_fail end + + class SnikketTest < Minitest::Test + Command::COMMAND_MANAGER = Minitest::Mock.new + Registration::FinishOnboarding::Snikket::IQ_MANAGER = Minitest::Mock.new + + def setup + @sgx = Minitest::Mock.new(TrivialBackendSgxRepo.new.get("test")) + @snikket = Registration::FinishOnboarding::Snikket.new("+15555550000") + end + + def test_write + xmpp_uri = "xmpp:test.snikket.chat?register;preauth=NEWTOKEN" + + subdomain_form = Blather::Stanza::Iq::Command.new + subdomain_form.form.fields = [ + { var: "subdomain", value: "test" } + ] + + launched = Snikket::Launched.new + launched << Niceogiri::XML::Node.new( + :launched, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |inner| + inner << Niceogiri::XML::Node.new( + :bootstrap, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |bootstrap| + bootstrap << Niceogiri::XML::Node.new( + :token, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |token| + token.content = "TOKEN" + } + } + } + + blather = Minitest::Mock.new + blather.expect( + :<<, + nil, + [Matching.new do |reply| + assert_equal :completed, reply.status + assert_equal( + xmpp_uri, + OOB.find_or_create(reply.command).url + ) + end] + ) + + execute_command(blather: blather) do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(subdomain_form), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert iq.form.field("subdomain") + end] + ) + + Registration::FinishOnboarding::Snikket::IQ_MANAGER.expect( + :write, + EMPromise.resolve(launched), + [Matching.new do |iq| + assert_equal :set, iq.type + assert_equal CONFIG[:snikket_hosting_api], iq.to.to_s + assert_equal( + "test.snikket.chat", + iq.xpath( + "./ns:launch/ns:domain", + ns: "xmpp:snikket.org/hosting/v1" + ).text + ) + end] + ) + + # Webmock doesn't support redirects properly, but they work live + stub_request( + :head, "https://test.snikket.chat/invites_bootstrap?token=TOKEN" + ).to_return( + status: 200, + headers: { + "link" => "<#{xmpp_uri}>; rel=\"alternate\"" + } + ) + + @snikket.write + end + + assert_mock Command::COMMAND_MANAGER + assert_mock Registration::FinishOnboarding::Snikket::IQ_MANAGER + assert_mock blather + end + em :test_write + + def test_write_not_yet + subdomain_form = Blather::Stanza::Iq::Command.new + subdomain_form.form.fields = [ + { var: "subdomain", value: "test" } + ] + + launched = Snikket::Launched.new + launched << Niceogiri::XML::Node.new( + :launched, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |inner| + inner << Niceogiri::XML::Node.new( + :bootstrap, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |bootstrap| + bootstrap << Niceogiri::XML::Node.new( + :token, launched.document, "xmpp:snikket.org/hosting/v1" + ).tap { |token| + token.content = "TOKEN" + } + } + } + + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(subdomain_form), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert iq.form.field("subdomain") + end] + ) + + Registration::FinishOnboarding::Snikket::IQ_MANAGER.expect( + :write, + EMPromise.resolve(launched), + [Matching.new do |iq| + assert_equal :set, iq.type + assert_equal CONFIG[:snikket_hosting_api], iq.to.to_s + assert_equal( + "test.snikket.chat", + iq.xpath( + "./ns:launch/ns:domain", + ns: "xmpp:snikket.org/hosting/v1" + ).text + ) + end] + ) + + stub_request( + :head, "https://test.snikket.chat/invites_bootstrap?token=TOKEN" + ).to_timeout + + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |iq| + assert_equal :info, iq.note_type + assert iq.note.content =~ / test\.snikket\.chat / + end] + ) + + @snikket.write.catch { |e| e } + end + + assert_equal :test_result, result + assert_mock Command::COMMAND_MANAGER + assert_mock Registration::FinishOnboarding::Snikket::IQ_MANAGER + end + em :test_write_not_yet + + def test_write_already_taken + subdomain_form = Blather::Stanza::Iq::Command.new + subdomain_form.form.fields = [ + { var: "subdomain", value: "test" } + ] + + launched = Snikket::Launched.new.as_error( + "internal-server-error", + :wait, + "This is an error" + ) + + result = execute_command do + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.resolve(subdomain_form), + [Matching.new do |iq| + assert_equal :form, iq.form.type + assert iq.form.field("subdomain") + end] + ) + + Registration::FinishOnboarding::Snikket::IQ_MANAGER.expect( + :write, + EMPromise.reject(launched), + [Matching.new do |iq| + assert_equal :set, iq.type + assert_equal CONFIG[:snikket_hosting_api], iq.to.to_s + assert_equal( + "test.snikket.chat", + iq.xpath( + "./ns:launch/ns:domain", + ns: "xmpp:snikket.org/hosting/v1" + ).text + ) + end] + ) + + stub_request( + :head, "https://test.snikket.chat/invites_bootstrap?token=TOKEN" + ).to_timeout + + Command::COMMAND_MANAGER.expect( + :write, + EMPromise.reject(:test_result), + [Matching.new do |iq| + assert iq.executing? + assert_equal( + "This is an error", + iq.form.field("subdomain").desc + ) + end] + ) + + @snikket.write.catch { |e| e } + end + + assert_equal :test_result, result + assert_mock Command::COMMAND_MANAGER + assert_mock Registration::FinishOnboarding::Snikket::IQ_MANAGER + end + em :test_write_already_taken + end end From 13dc410ca691193b27607829b60e413f750489f8 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Tue, 21 Mar 2023 21:33:29 -0500 Subject: [PATCH 2/2] Welcome message after final jidswitch for onboarding --- sgx_jmp.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sgx_jmp.rb b/sgx_jmp.rb index e6455d724d2f7f20ebf933711ccf31ce5d1ae8f3..b449e30889cc9f1c1ccea2a3ffc530fe8e49c781 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -103,6 +103,7 @@ require_relative "lib/transaction" require_relative "lib/tel_selections" require_relative "lib/sim_repo" require_relative "lib/snikket" +require_relative "lib/welcome_message" require_relative "web" require_relative "lib/statsd" @@ -900,6 +901,10 @@ Command.new( } }.then { StatsD.increment("changejid.completed") + jid = ProxiedJID.new(customer.jid).unproxied + if jid.domain == CONFIG[:onboarding_domain] + WelcomeMessage.new(customer, customer.registered?.phone).welcome + end Command.finish { |reply| reply.note_type = :info reply.note_text = "Customer JID Changed"