Finish onboarding by creating a Snikket instance

Stephen Paul Weber created

Custom domain not supported by the API yet, so stub for now.

Change summary

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(+)

Detailed changes

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"

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 :

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" },

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" }
+	]
+)

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

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

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" }

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)
+				<OrderResponse>
+					<Order>
+						<id>test_order</id>
+					</Order>
+				</OrderResponse>
+			RESPONSE
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders/test_order"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<OrderResponse>
+					<OrderStatus>COMPLETE</OrderStatus>
+					<CompletedNumbers>
+						<TelephoneNumber>
+							<FullNumber>5555550000</FullNumber>
+						</TelephoneNumber>
+					</CompletedNumbers>
+				</OrderResponse>
+			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