Initial test framework and some tests

Stephen Paul Weber created

Change summary

Gemfile                |  17 ++
Rakefile               |  21 +++
sgx-bwmsgsv2.rb        |   7 
test/test_component.rb | 256 ++++++++++++++++++++++++++++++++++++++++++++
test/test_helper.rb    | 138 +++++++++++++++++++++++
5 files changed, 433 insertions(+), 6 deletions(-)

Detailed changes

Gemfile 🔗

@@ -3,7 +3,7 @@
 source 'https://rubygems.org'
 
 gem 'activesupport', '<5.0.0'
-gem 'blather'
+gem 'blather', git: "https://github.com/adhearsion/blather.git"
 gem 'em-hiredis'
 gem 'em-http-request'
 gem 'em_promise.rb'
@@ -15,6 +15,17 @@ gem 'rack', '< 2'
 gem 'redis'
 gem "sentry-ruby", "<= 4.3.1"
 
-group :development do
-	gem 'rubocop', require: false
+group(:development) do
+	gem "pry-reload"
+	gem "pry-rescue"
+	gem "pry-stack_explorer"
+end
+
+group(:test) do
+	gem 'minitest'
+	gem 'rack-test'
+	gem 'rake'
+	gem 'rubocop'
+	gem 'simplecov', require: false
+	gem 'webmock'
 end

Rakefile 🔗

@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "rake/testtask"
+require "rubocop/rake_task"
+
+Rake::TestTask.new(:test) do |t|
+	ENV["ENV"] = "test"
+
+	t.libs << "test"
+	t.libs << "lib"
+	t.test_files = FileList["test/**/test_*.rb"]
+	t.warning = false
+end
+
+RuboCop::RakeTask.new(:lint)
+
+task :entr do
+	sh "sh", "-c", "git ls-files | entr -s 'rake test && rubocop'"
+end
+
+task default: :test

sgx-bwmsgsv2.rb 🔗

@@ -76,6 +76,7 @@ protected
 
 	def wrap_handler(*args)
 		v = yield(*args)
+		v = v.sync if ENV['ENV'] == 'test' && v.is_a?(Promise)
 		v.catch(&method(:panic)) if v.is_a?(Promise)
 		true # Do not run other handlers unless throw :pass
 	rescue Exception => e
@@ -593,7 +594,7 @@ module SGXbwmsgsv2
 				creds_from_registration_query(qn)
 			end
 		}.then { |user_id, api_token, api_secret, phone_num|
-			if phone_num[0] == '+'
+			if phone_num && phone_num[0] == '+'
 				[user_id, api_token, api_secret, phone_num]
 			else
 				# TODO: add text re number not (yet) supported
@@ -724,7 +725,7 @@ module SGXbwmsgsv2
 			# Unknown IQ, ignore for now
 			EMPromise.reject(:done)
 		end.catch { |e|
-			if e.is_a?(Array) && e.length == 2
+			if e.is_a?(Array) && (e.length == 2 || e.length == 3)
 				write_to_stream error_msg(i.reply, qn, *e)
 			elsif e != :done
 				EMPromise.reject(e)
@@ -1051,4 +1052,4 @@ at_exit do
 			end
 		end
 	end
-end
+end unless ENV['ENV'] == 'test'

test/test_component.rb 🔗

@@ -0,0 +1,256 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require_relative "../sgx-bwmsgsv2"
+
+def panic(e)
+	$panic = e
+end
+
+class ComponentTest < Minitest::Test
+	def setup
+		SGXbwmsgsv2.instance_variable_set(:@written, [])
+
+		def SGXbwmsgsv2.write_to_stream(s)
+			@written ||= []
+			@written << s
+		end
+
+		REDIS.set("catapult_cred-test@example.com", [
+			'account', 'user', 'password', '+15550000000'
+		])
+	end
+
+	def written
+		SGXbwmsgsv2.instance_variable_get(:@written)
+	end
+
+	def xmpp_error_name(error)
+		error.find_first(
+			"child::*[name()!='text']",
+			Blather::StanzaError::STANZA_ERR_NS
+		).element_name
+	end
+
+	def process_stanza(s)
+		SGXbwmsgsv2.send(:client).receive_data(s)
+		raise $panic if $panic
+	end
+
+	def test_message_unregistered
+		m = Blather::Stanza::Message.new("+15551234567@component", "a"*4096)
+		m.from = "unknown@example.com"
+		process_stanza(m)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "auth", error["type"]
+		assert_equal "registration-required", xmpp_error_name(error)
+	end
+	em :test_message_unregistered
+
+	def test_message_too_long
+		req = stub_request(
+			:post,
+			"https://messaging.bandwidth.com/api/v2/users/account/messages"
+		).with(body: {
+			from: "+15550000000",
+			to: "+15551234567",
+			text: "a"*4096,
+			applicationId: nil,
+			tag: " "
+		}).to_return(status: 400)
+
+		m = Blather::Stanza::Message.new("+15551234567@component", "a"*4096)
+		m.from = "test@example.com"
+		process_stanza(m)
+
+		assert_requested req
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "internal-server-error", xmpp_error_name(error)
+	end
+	em :test_message_too_long
+
+	def test_message_to_component_not_group
+		m = Blather::Stanza::Message.new("component", "a"*4096)
+		m.from = "test@example.com"
+		process_stanza(m)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "item-not-found", xmpp_error_name(error)
+	end
+	em :test_message_to_component_not_group
+
+	def test_message_to_invalid_num
+		m = Blather::Stanza::Message.new("123@component", "a"*4096)
+		m.from = "test@example.com"
+		process_stanza(m)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "item-not-found", xmpp_error_name(error)
+	end
+	em :test_message_to_invalid_num
+
+	def test_message_to_anonymous
+		m = Blather::Stanza::Message.new(
+			"1;phone-context=anonymous.phone-context.soprani.ca@component",
+			"a"*4096
+		)
+		m.from = "test@example.com"
+		process_stanza(m)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "gone", xmpp_error_name(error)
+	end
+	em :test_message_to_anonymous
+
+	def test_blank_message
+		m = Blather::Stanza::Message.new("+15551234567@component", " ")
+		m.from = "test@example.com"
+		process_stanza(m)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "modify", error["type"]
+		assert_equal "policy-violation", xmpp_error_name(error)
+	end
+	em :test_blank_message
+
+	def test_ibr_bad_tel
+		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
+		iq.from = "newuser@example.com"
+		iq.phone = "5551234567"
+		process_stanza(iq)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "item-not-found", xmpp_error_name(error)
+	end
+	em :test_ibr_bad_tel
+
+	def test_ibr_bad_creds
+		stub_request(
+			:get,
+			"https://messaging.bandwidth.com/api/v2/users/acct/media"
+		).with(basic_auth: ["user", "pw"]).to_return(status: 401)
+
+		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
+		iq.from = "newuser@example.com"
+		iq.phone = "+15551234567"
+		iq.nick = "acct"
+		iq.username = "user"
+		iq.password = "pw"
+		process_stanza(iq)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "auth", error["type"]
+		assert_equal "not-authorized", xmpp_error_name(error)
+	end
+	em :test_ibr_bad_creds
+
+	def test_ibr_number_not_found
+		stub_request(
+			:get,
+			"https://messaging.bandwidth.com/api/v2/users/acct/media"
+		).with(basic_auth: ["user", "pw"]).to_return(status: 404)
+
+		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
+		iq.from = "newuser@example.com"
+		iq.phone = "+15551234567"
+		iq.nick = "acct"
+		iq.username = "user"
+		iq.password = "pw"
+		process_stanza(iq)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "item-not-found", xmpp_error_name(error)
+	end
+	em :test_ibr_number_not_found
+
+	def test_ibr_other_error
+		stub_request(
+			:get,
+			"https://messaging.bandwidth.com/api/v2/users/acct/media"
+		).with(basic_auth: ["user", "pw"]).to_return(status: 400)
+
+		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
+		iq.from = "newuser@example.com"
+		iq.phone = "+15551234567"
+		iq.nick = "acct"
+		iq.username = "user"
+		iq.password = "pw"
+		process_stanza(iq)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "modify", error["type"]
+		assert_equal "not-acceptable", xmpp_error_name(error)
+	end
+	em :test_ibr_other_error
+
+	def test_ibr_conflict
+		stub_request(
+			:get,
+			"https://messaging.bandwidth.com/api/v2/users/acct/media"
+		).with(basic_auth: ["user", "pw"]).to_return(status: 200, body: "[]")
+
+		iq = Blather::Stanza::Iq::IBR.new(:set, "component")
+		iq.from = "test@example.com"
+		iq.phone = "+15550000000"
+		iq.nick = "acct"
+		iq.username = "user"
+		iq.password = "pw"
+		process_stanza(iq)
+
+		assert_equal 1, written.length
+
+		stanza = Blather::XMPPNode.parse(written.first.to_xml)
+		assert stanza.error?
+		error = stanza.find_first("error")
+		assert_equal "cancel", error["type"]
+		assert_equal "conflict", xmpp_error_name(error)
+	end
+	em :test_ibr_conflict
+end

test/test_helper.rb 🔗

@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require "simplecov"
+SimpleCov.start do
+	add_filter "/test/"
+	enable_coverage :branch
+end
+
+require "minitest/autorun"
+require "webmock/minitest"
+
+begin
+	require "pry-rescue/minitest"
+	require "pry-reload"
+
+	module Minitest
+		class Test
+			alias old_capture_exceptions capture_exceptions
+			def capture_exceptions
+				old_capture_exceptions do
+					yield
+				rescue Minitest::Skip => e
+					failures << e
+				end
+			end
+		end
+	end
+rescue LoadError
+	# Just helpers for dev, no big deal if missing
+	nil
+end
+
+$VERBOSE = nil
+ARGV[0] = "component"
+
+class FakeRedis
+	def initialize(values={})
+		@values = values
+	end
+
+	def set(key, value, *)
+		@values[key] = value
+		EMPromise.resolve("OK")
+	end
+
+	def setex(key, _expiry, value)
+		set(key, value)
+	end
+
+	def mget(*keys)
+		EMPromise.all(keys.map(&method(:get)))
+	end
+
+	def get(key)
+		EMPromise.resolve(@values[key])
+	end
+
+	def getbit(key, bit)
+		get(key).then { |v| v.to_i.to_s(2)[bit].to_i }
+	end
+
+	def bitfield(key, *ops)
+		get(key).then do |v|
+			bits = v.to_i.to_s(2)
+			ops.each_slice(3).map do |(op, encoding, offset)|
+				raise "unsupported bitfield op" unless op == "GET"
+				raise "unsupported bitfield op" unless encoding == "u1"
+
+				bits[offset].to_i
+			end
+		end
+	end
+
+	def hget(key, field)
+		@values.dig(key, field)
+	end
+
+	def hincrby(key, field, incrby)
+		@values[key] ||= {}
+		@values[key][field] ||= 0
+		@values[key][field] += incrby
+	end
+
+	def sadd(key, member)
+		@values[key] ||= Set.new
+		@values[key] << member
+	end
+
+	def srem(key, member)
+		@values[key].delete(member)
+	end
+
+	def scard(key)
+		@values[key]&.size || 0
+	end
+
+	def expire(_, _); end
+
+	def exists(*keys)
+		EMPromise.resolve(
+			@values.select { |k, _| keys.include? k }.size.to_s
+		)
+	end
+
+	def lindex(key, index)
+		get(key).then { |v| v&.fetch(index) }
+	end
+
+	def lrange(key, sindex, eindex)
+		get(key).then { |v| v ? v[sindex..eindex] : [] }
+	end
+end
+
+REDIS = FakeRedis.new
+
+module Minitest
+	class Test
+		def self.em(m)
+			alias_method "raw_#{m}", m
+			define_method(m) do
+				$panic = nil
+				e = nil
+				EM.run do
+					Fiber.new {
+						begin
+							send("raw_#{m}")
+						rescue
+							e = $!
+						ensure
+							EM.stop
+						end
+					}.resume
+				end
+				raise e if e
+			end
+		end
+	end
+end