Snikket custom domain flow and more prev support

Stephen Paul Weber created

Change summary

forms/registration/snikket_custom.rb    |  22 ++++
forms/registration/snikket_needs_dns.rb |  16 +++
lib/registration.rb                     | 139 +++++++++++++++++++-------
lib/snikket.rb                          |  94 ++++++++++++++++++
test/test_registration.rb               | 120 +++++++++++++++++++++++
5 files changed, 352 insertions(+), 39 deletions(-)

Detailed changes

forms/registration/snikket_custom.rb 🔗

@@ -0,0 +1,22 @@
+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: "Domain (or subdomain) name",
+	var: "domain",
+	description: @error ||
+		"Your Jabber ID will be of the form username@domain"
+)

forms/registration/snikket_needs_dns.rb 🔗

@@ -0,0 +1,16 @@
+form!
+title "Setup Snikket Instance"
+
+instructions(
+	"To complete setup of your instance, please set these DNS records, " \
+	"then press next to check if they have propagated."
+)
+
+table(
+	@records,
+	name: "Name",
+	type: "Type",
+	status: "Status",
+	expected: "Expected",
+	found: "Found"
+)

lib/registration.rb 🔗

@@ -612,28 +612,16 @@ class Registration
 				end
 			end
 
-			def initialize(customer, tel, error: nil, db:)
+			def initialize(customer, tel, error: nil, old: nil, db:)
 				@customer = customer
 				@tel = tel
 				@error = error
 				@db = db
+				@old = old
 			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",
@@ -642,6 +630,25 @@ class Registration
 				)
 			end
 
+			def write
+				Command.reply { |reply|
+					reply.allowed_actions = [:next]
+					reply.command << form
+				}.then(&method(:next_step))
+			end
+
+			def next_step(iq)
+				subdomain = iq.form.field("subdomain")&.value
+				domain = "#{subdomain}.snikket.chat"
+				if iq.form.field(ACTION_VAR)&.value == "custom_domain"
+					CustomDomain.new(@tel, old: @old).write
+				elsif @old && (!subdomain || domain == @old.domain)
+					GetInvite.for(@old, @tel).then(&:write)
+				else
+					launch(domain)
+				end
+			end
+
 			def launch(domain)
 				IQ_MANAGER.write(::Snikket::Launch.new(
 					nil, CONFIG[:snikket_hosting_api], domain: domain
@@ -650,40 +657,88 @@ class Registration
 				}.catch { |e|
 					next EMPromise.reject(e) unless e.respond_to?(:text)
 
-					Snikket.new(@customer, @tel, error: e.text, db: @db).write
+					Snikket.new(@customer, @tel, old: @old, error: e.text, db: @db).write
 				}
 			end
 
 			def save_instance_and_wait(domain, launched)
 				instance = ::Snikket::CustomerInstance.for(@customer, domain, launched)
-				::Snikket::Repo.new(db: @db).put(instance).then do
-					GetInvite.for(instance).then(&:write)
+				repo = ::Snikket::Repo.new(db: @db)
+				repo.del(@old).then { repo.put(instance) }.then do
+					if launched.status == :needs_dns
+						NeedsDNS.new(@customer, instance, @tel, launched.records).write
+					else
+						GetInvite.for(@customer, instance, @tel, db: @db).then(&:write)
+					end
+				end
+			end
+
+			class NeedsDNS < Snikket
+				def initialize(customer, instance, tel, records, db: DB)
+					@customer = customer
+					@instance = instance
+					@tel = tel
+					@records = records
+					@db = db
+				end
+
+				def form
+					FormTemplate.render(
+						"registration/snikket_needs_dns",
+						records: @records
+					)
+				end
+
+				def write
+					Command.reply { |reply|
+						reply.allowed_actions = [:prev, :next]
+						reply.command << form
+					}.then do |iq|
+						if iq.prev?
+							CustomDomain.new(@tel, old: @instance).write
+						else
+							launch(@instance.domain)
+						end
+					end
 				end
 			end
 
 			class GetInvite
-				def self.for(instance)
+				def self.for(customer, instance, tel, db: DB)
 					instance.fetch_invite.then do |xmpp_uri|
 						if xmpp_uri
 							GoToInvite.new(xmpp_uri)
 						else
-							new(instance)
+							new(customer, instance, tel, db: db)
 						end
 					end
 				end
 
-				def initialize(instance)
+				def initialize(customer, instance, tel, db: DB)
+					@customer = customer
 					@instance = instance
+					@tel = tel
+					@db = db
+				end
+
+				def form
+					FormTemplate.render(
+						"registration/snikket_wait",
+						domain: @instance.domain
+					)
 				end
 
 				def write
 					Command.reply { |reply|
-						reply.allowed_actions = [:next]
-						reply.command << FormTemplate.render(
-							"registration/snikket_wait",
-							domain: @instance.domain
-						)
-					}.then { GetInvite.for(@instance).then(&:write) }
+						reply.allowed_actions = [:prev, :next]
+						reply.command << form
+					}.then do |iq|
+						if iq.prev?
+							Snikket.new(@customer, @tel, old: @instance, db: @db).write
+						else
+							GetInvite.for(@instance).then(&:write)
+						end
+					end
 				end
 			end
 
@@ -701,25 +756,33 @@ class Registration
 			end
 		end
 
-		class CustomDomain
-			def initialize(tel)
+		class CustomDomain < Snikket
+			def initialize(customer, tel, old: nil, error: nil, db: DB)
+				@customer = customer
 				@tel = tel
+				@error = error
+				@old = old
+				@db = db
 			end
 
-			CONTACT_SUPPORT =
-				"Please contact JMP support to set up " \
-				"an instance on an existing domain."
+			def form
+				FormTemplate.render(
+					"registration/snikket_custom",
+					tel: @tel,
+					error: @error
+				)
+			end
 
 			def write
 				Command.reply { |reply|
-					reply.allowed_actions = [:prev]
-					reply.status = :canceled
-					reply.note_type = :info
-					reply.note_text = CONTACT_SUPPORT
+					reply.allowed_actions = [:prev, :next]
+					reply.command << form
 				}.then do |iq|
-					raise "Action not allowed" unless iq.prev?
-
-					Snikket.new(@tel).write
+					if iq.prev?
+						Snikket.new(@customer, @tel, db: @db, old: @old).write
+					else
+						launch(iq.form.field("domain")&.value)
+					end
 				end
 			end
 		end

lib/snikket.rb 🔗

@@ -47,11 +47,61 @@ module Snikket
 				&.content
 		end
 
+		def status
+			query
+				.at_xpath("./ns:status", ns: self.class.registered_ns)
+				&.content&.to_sym
+		end
+
+		def records
+			query
+				.xpath("./ns:records/ns:record", ns: self.class.registered_ns)
+				&.map(&Record.method(:new))
+		end
+
 		def query
 			at_xpath("./ns:launched", ns: self.class.registered_ns)
 		end
 	end
 
+	class Record
+		NS = "xmpp:snikket.org/hosting/v1"
+
+		def initialize(element)
+			@element = element
+		end
+
+		def name
+			@element
+				.at_xpath("./ns:name", ns: NS)
+				&.content
+		end
+
+		def type
+			@element
+				.at_xpath("./ns:type", ns: NS)
+				&.content
+		end
+
+		def status
+			@element
+				.at_xpath("./ns:status", ns: NS)
+				&.content&.to_sym
+		end
+
+		def expected
+			@element
+				.xpath("./ns:expected/ns:value", ns: NS)
+				&.map(&:content)
+		end
+
+		def found
+			@element
+				.xpath("./ns:found/ns:value", ns: NS)
+				&.map(&:content)
+		end
+	end
+
 	class Instance < Blather::Stanza::Iq
 		register :snikket_instance, "instance", "xmpp:snikket.org/hosting/v1"
 
@@ -102,11 +152,36 @@ module Snikket
 		end
 	end
 
+	class Delete < Blather::Stanza::Iq
+		register nil, "delete", "xmpp:snikket.org/hosting/v1"
+
+		def self.new(type=nil, to=nil, id=nil, instance_id: nil)
+			stanza = super(type || :set, to, id)
+			node = Nokogiri::XML::Node.new("delete", stanza.document)
+			node.default_namespace = registered_ns
+			stanza << node
+			stanza.instance_id = instance_id if instance_id
+			stanza
+		end
+
+		def instance_id=(instance_id)
+			query.at_xpath("./ns:instance-id", ns: self.class.registered_ns)&.remove
+			node = Nokogiri::XML::Node.new("intance-id", document)
+			node.default_namespace = self.class.registered_ns
+			node.content = instance_id
+			query << node
+		end
+
+		def query
+			at_xpath("./ns:delete", ns: self.class.registered_ns)
+		end
+	end
+
 	class CustomerInstance
 		def self.for(customer, domain, launched)
 			new(
 				instance_id: launched.instance_id,
-				bootstrap_token: launched.bootstrap_token,
+				bootstrap_token: launched.bootstrap_token || "",
 				customer_id: customer.customer_id,
 				domain: domain
 			)
@@ -153,6 +228,21 @@ module Snikket
 			end
 		end
 
+		def del(instance)
+			return EMPromise.resolve(nil) unless instance
+
+			params = [instance.instance_id, instance.customer_id, instance.domain]
+
+			IQ_MANAGER.write(Delete.new(
+				nil, CONFIG[:snikket_hosting_api], instance_id: instance.instance_id
+			)).then do
+				@db.exec_defer(<<~SQL, params)
+					DELETE FROM snikket_instances
+					WHERE instance_id=$1 AND customer_id=$2 AND domain=$3
+				SQL
+			end
+		end
+
 		def put(instance)
 			params = [
 				instance.instance_id, instance.bootstrap_token,
@@ -163,6 +253,8 @@ module Snikket
 					(instance_id, bootstrap_token, customer_id, domain)
 				VALUES
 					($1, $2, $3, $4)
+				ON CONFLICT (instance_id)
+				DO UPDATE SET bootstrap_token=$2
 			SQL
 		end
 	end

test/test_registration.rb 🔗

@@ -1443,4 +1443,124 @@ class RegistrationTest < Minitest::Test
 		end
 		em :test_write_already_taken
 	end
+
+	class SnikketCustomDomainTest < Minitest::Test
+		def setup
+			@snikket = Registration::FinishOnboarding::CustomDomain.new(
+				customer,
+				"+15555550000",
+				db: FakeDB.new
+			)
+		end
+
+		def test_write_needs_dns
+			domain_form = Blather::Stanza::Iq::Command.new
+			domain_form.form.fields = [
+				{ var: "domain", value: "snikket.example.com" }
+			]
+
+			launched = Snikket::Launched.new
+			launched << Niceogiri::XML::Node.new(
+				:launched, launched.document, "xmpp:snikket.org/hosting/v1"
+			).tap { |inner|
+				inner << Niceogiri::XML::Node.new(
+					:'instance-id', launched.document, "xmpp:snikket.org/hosting/v1"
+				).tap { |id|
+					id.content = "si-1234"
+				}
+				inner << Niceogiri::XML::Node.new(
+					:status, launched.document, "xmpp:snikket.org/hosting/v1"
+				).tap { |id|
+					id.content = "needs_dns"
+				}
+				inner << Niceogiri::XML::Node.new(
+					:records, launched.document, "xmpp:snikket.org/hosting/v1"
+				).tap { |records|
+					records << Niceogiri::XML::Node.new(
+						:record, launched.document, "xmpp:snikket.org/hosting/v1"
+					).tap { |record|
+						record << Niceogiri::XML::Node.new(
+							:name, launched.document, "xmpp:snikket.org/hosting/v1"
+						).tap { |name| name.content = "snikket.example.com" }
+						record << Niceogiri::XML::Node.new(
+							:type, launched.document, "xmpp:snikket.org/hosting/v1"
+						).tap { |type| type.content = "AAAA" }
+						record << Niceogiri::XML::Node.new(
+							:status, launched.document, "xmpp:snikket.org/hosting/v1"
+						).tap { |type| type.content = "incorrect" }
+						record << Niceogiri::XML::Node.new(
+							:expected, launched.document, "xmpp:snikket.org/hosting/v1"
+						).tap { |expected|
+							expected << Niceogiri::XML::Node.new(
+								:value, launched.document, "xmpp:snikket.org/hosting/v1"
+							).tap { |value| value.content = "1::2" }
+						}
+						record << Niceogiri::XML::Node.new(
+							:found, launched.document, "xmpp:snikket.org/hosting/v1"
+						).tap { |found|
+							found << Niceogiri::XML::Node.new(
+								:value, launched.document, "xmpp:snikket.org/hosting/v1"
+							).tap { |value| value.content = "0::0" }
+						}
+					}
+				}
+			}
+
+			result = execute_command do
+				Command::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(domain_form),
+					[Matching.new do |iq|
+						assert_equal :form, iq.form.type
+						assert iq.form.field("domain")
+					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(
+							"snikket.example.com",
+							iq.xpath(
+								"./ns:launch/ns:domain",
+								ns: "xmpp:snikket.org/hosting/v1"
+							).text
+						)
+					end]
+				)
+
+				Command::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.reject(:test_result),
+					[Matching.new do |iq|
+						assert_equal :form, iq.form.type
+						assert_equal(
+							["snikket.example.com"],
+							iq.form.xpath(
+								"./ns:item/ns:field[@var='name']/ns:value",
+								ns: "jabber:x:data"
+							).map(&:content)
+						)
+						assert_equal(
+							["1::2"],
+							iq.form.xpath(
+								"./ns:item/ns:field[@var='expected']/ns:value",
+								ns: "jabber:x:data"
+							).map(&:content)
+						)
+					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_needs_dns
+	end
 end