├── .ruby-version ├── .gitignore ├── lib ├── bane │ ├── version.rb │ ├── extensions.rb │ ├── behaviors │ │ ├── responders │ │ │ ├── exported.rb │ │ │ ├── close_immediately.rb │ │ │ ├── echo_response.rb │ │ │ ├── newline_response.rb │ │ │ ├── for_each_line.rb │ │ │ ├── random_response.rb │ │ │ ├── close_after_pause.rb │ │ │ ├── fixed_response.rb │ │ │ ├── deluge_response.rb │ │ │ ├── never_respond.rb │ │ │ ├── http_refuse_all_credentials.rb │ │ │ └── slow_response.rb │ │ └── servers │ │ │ ├── exported.rb │ │ │ ├── timeout_in_listen_queue.rb │ │ │ └── responder_server.rb │ ├── launcher.rb │ ├── behavior_maker.rb │ ├── naive_http_response.rb │ ├── command_line_configuration.rb │ └── arguments_parser.rb └── bane.rb ├── Gemfile ├── examples ├── readme_example.rb ├── single_behavior.rb └── multiple_behaviors.rb ├── bin └── bane ├── test ├── bane │ ├── behaviors │ │ ├── responders │ │ │ ├── close_immediately_test.rb │ │ │ ├── newline_response_test.rb │ │ │ ├── fixed_response_test.rb │ │ │ ├── random_response_test.rb │ │ │ ├── echo_response_test.rb │ │ │ ├── deluge_response_test.rb │ │ │ ├── slow_response_test.rb │ │ │ ├── http_refuse_all_credentials_test.rb │ │ │ ├── close_after_pause_test.rb │ │ │ ├── for_each_line_test.rb │ │ │ └── never_respond_test.rb │ │ └── servers │ │ │ ├── timeout_in_listen_queue_test.rb │ │ │ └── responder_server_test.rb │ ├── bane_test.rb │ ├── launchable_role_tests.rb │ ├── extensions_test.rb │ ├── fake_connection_test.rb │ ├── acceptance_test.rb │ ├── naive_http_response_test.rb │ ├── arguments_parser_test.rb │ └── command_line_configuration_test.rb └── test_helper.rb ├── Rakefile ├── Gemfile.lock ├── .github └── workflows │ └── ruby.yml ├── LICENSE ├── TODO ├── bane.gemspec ├── HISTORY.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.5 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | pkg 3 | .idea 4 | .DS_Store 5 | .bundle 6 | -------------------------------------------------------------------------------- /lib/bane/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | VERSION = '1.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :test do 8 | gem 'mocha', '>= 0.14.0' 9 | gem 'test-unit', '3.3.4' 10 | end 11 | -------------------------------------------------------------------------------- /lib/bane/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless Class.respond_to?(:unqualified_name) 4 | class Class 5 | def unqualified_name 6 | self.name.split("::").last 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/exported.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | EXPORTED = self.constants.map { |name| self.const_get(name) }.grep(Class) 8 | 9 | end 10 | end 11 | end -------------------------------------------------------------------------------- /examples/readme_example.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib' 2 | require 'bane' 3 | 4 | include Bane 5 | include Behaviors 6 | 7 | launcher = Launcher.new( 8 | [Servers::ResponderServer.new(3000, Responders::FixedResponse.new(message: "Shall we play a game?"))]) 9 | launcher.start 10 | launcher.join 11 | -------------------------------------------------------------------------------- /bin/bane: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bane' 4 | 5 | parser = Bane::CommandLineConfiguration.new(Bane.find_makeables) 6 | servers = parser.process(ARGV) do |error_message| 7 | puts error_message 8 | exit 1 9 | end 10 | 11 | launcher = Bane::Launcher.new(servers) 12 | launcher.start 13 | trap("SIGINT") { Thread.new { launcher.stop; exit } } 14 | launcher.join 15 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/close_immediately.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Closes the connection immediately after a connection is made. 8 | class CloseImmediately 9 | def serve(io) 10 | # do nothing 11 | end 12 | end 13 | 14 | end 15 | end 16 | end -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/echo_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | class EchoResponse 8 | def serve(io) 9 | while (input = io.gets) 10 | io.write(input) 11 | end 12 | io.close 13 | end 14 | end 15 | 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/close_immediately_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class CloseImmediatelyTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_no_response 11 | query_server(CloseImmediately.new) 12 | 13 | assert_empty_response 14 | end 15 | 16 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/newline_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class NewlineResponseTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_only_a_newline_character 11 | query_server(NewlineResponse.new) 12 | 13 | assert_equal "\n", response 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /test/bane/behaviors/responders/fixed_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class FixedResponseTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_the_specified_message 11 | query_server(FixedResponse.new(message: "Test Message")) 12 | 13 | assert_equal "Test Message", response 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /test/bane/behaviors/responders/random_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class RandomResponseTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_a_nonempty_response 11 | query_server(RandomResponse.new) 12 | 13 | assert (!response.empty?), "Should have served a nonempty response" 14 | end 15 | 16 | end 17 | 18 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/newline_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends a newline character as the only response 8 | class NewlineResponse 9 | def serve(io) 10 | io.write "\n" 11 | end 12 | end 13 | 14 | class NewlineResponseForEachLine < NewlineResponse 15 | include ForEachLine 16 | end 17 | 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/bane/behaviors/servers/exported.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Servers 6 | 7 | EXPORTED = [TimeoutInListenQueue] 8 | 9 | # Listen only on localhost 10 | LOCALHOST = '127.0.0.1' 11 | 12 | # Deprecated - use LOCALHOST - Listen only on localhost 13 | DEFAULT_HOST = LOCALHOST 14 | 15 | # Listen on all interfaces 16 | ALL_INTERFACES = '0.0.0.0' 17 | 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/bane/behaviors/responders/echo_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class EchoResponseTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_returns_received_characters 11 | fake_connection.will_send("Hello, echo!") 12 | 13 | query_server(EchoResponse.new) 14 | 15 | assert_equal "Hello, echo!", response 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /test/bane/bane_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class BaneTest < Test::Unit::TestCase 6 | 7 | def test_includes_responders_and_servers_in_all_makeables 8 | all_names = Bane.find_makeables.keys 9 | 10 | assert all_names.include?('NeverRespond'), "Expected 'NeverRespond' responder to be in #{all_names}" 11 | assert all_names.include?('TimeoutInListenQueue'), "Expected 'TimeoutInListenQueue' server to be in #{all_names}" 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /test/bane/launchable_role_tests.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LaunchableRoleTests 4 | 5 | # Verify the contract required for Launcher 6 | 7 | def test_responds_to_start 8 | assert_respond_to(@object, :start) 9 | end 10 | 11 | def test_responds_to_stop 12 | assert_respond_to(@object, :stop) 13 | end 14 | 15 | def test_responds_to_join 16 | assert_respond_to(@object, :join) 17 | end 18 | 19 | def test_responds_to_stdlog 20 | assert_respond_to(@object, :stdlog=) 21 | end 22 | end -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/for_each_line.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # This module can be used to wrap another behavior with 8 | # a "while(io.gets)" loop, which reads a line from the input and 9 | # then performs the given behavior. 10 | module ForEachLine 11 | def serve(io) 12 | while (io.gets) 13 | super(io) 14 | end 15 | end 16 | end 17 | 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/bane/launcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | 5 | class Launcher 6 | 7 | def initialize(servers, logger = $stderr) 8 | @servers = servers 9 | @servers.each { |server| server.stdlog = logger } 10 | end 11 | 12 | def start 13 | @servers.each { |server| server.start } 14 | end 15 | 16 | def join 17 | @servers.each { |server| server.join } 18 | end 19 | 20 | def stop 21 | @servers.each { |server| server.stop } 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /test/bane/extensions_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class ExtensionsTest < Test::Unit::TestCase 6 | def test_unqualified_name_removes_module_path 7 | assert_equal 'String', String.unqualified_name 8 | assert_equal 'NestedClass', TopModule::NestedClass.unqualified_name 9 | assert_equal 'DoublyNestedClass', TopModule::NestedModule::DoublyNestedClass.unqualified_name 10 | end 11 | end 12 | 13 | module TopModule 14 | class NestedClass; end 15 | 16 | module NestedModule 17 | class DoublyNestedClass; end 18 | end 19 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | require 'rdoc/task' 6 | require_relative 'lib/bane/version' 7 | 8 | Rake::TestTask.new(:test) do |test| 9 | test.libs << 'test' 10 | test.test_files = FileList['test/**/*_test.rb'] 11 | test.verbose = true 12 | end 13 | 14 | task default: :test 15 | 16 | Rake::RDocTask.new do |rdoc| 17 | version = Bane::VERSION 18 | 19 | rdoc.rdoc_dir = 'rdoc' 20 | rdoc.title = "list #{version}" 21 | rdoc.rdoc_files.include('README*') 22 | rdoc.rdoc_files.include('lib/**/*.rb') 23 | end 24 | -------------------------------------------------------------------------------- /test/bane/behaviors/responders/deluge_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class DelugeResponseTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_one_million_bytes_by_default 11 | query_server(DelugeResponse.new) 12 | 13 | assert_response_length 1_000_000 14 | end 15 | 16 | def test_sends_the_specified_number_of_bytes 17 | query_server(DelugeResponse.new(length: 1)) 18 | 19 | assert_response_length 1 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/random_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends a random response. 8 | class RandomResponse 9 | def serve(io) 10 | io.write random_string 11 | end 12 | 13 | private 14 | def random_string 15 | (1..rand(26)+1).map { |i| ('a'..'z').to_a[rand(26)] }.join 16 | end 17 | 18 | end 19 | 20 | class RandomResponseForEachLine < RandomResponse 21 | include ForEachLine 22 | end 23 | 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | bane (1.0.0) 5 | gserver (= 0.0.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | gserver (0.0.1) 11 | mocha (1.13.0) 12 | power_assert (2.0.1) 13 | psych (4.0.3) 14 | stringio 15 | rake (13.0.6) 16 | rdoc (6.4.0) 17 | psych (>= 4.0.0) 18 | stringio (3.0.1) 19 | test-unit (3.3.4) 20 | power_assert 21 | 22 | PLATFORMS 23 | ruby 24 | 25 | DEPENDENCIES 26 | bane! 27 | mocha (>= 0.14.0) 28 | rake (~> 13.0) 29 | rdoc (~> 6.3) 30 | test-unit (= 3.3.4) 31 | 32 | BUNDLED WITH 33 | 2.1.4 34 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/close_after_pause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Accepts a connection, pauses a fixed duration, then closes the connection. 8 | # 9 | # Options: 10 | # - duration: The number of seconds to wait before disconnect. Default: 30 11 | class CloseAfterPause 12 | def initialize(options = {}) 13 | @options = {duration: 30}.merge(options) 14 | end 15 | 16 | def serve(io) 17 | sleep(@options[:duration]) 18 | end 19 | end 20 | 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/slow_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | require 'mocha/test_unit' 5 | 6 | class SlowResponseTest < Test::Unit::TestCase 7 | 8 | include Bane::Behaviors::Responders 9 | include BehaviorTestHelpers 10 | 11 | def test_pauses_between_sending_each_character 12 | message = "Hi!" 13 | delay = 0.5 14 | 15 | server = SlowResponse.new(pause_duration: delay, message: message) 16 | server.expects(:sleep).with(delay).at_least(message.length) 17 | 18 | query_server(server) 19 | 20 | assert_equal message, response 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /examples/single_behavior.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib' 2 | require 'bane' 3 | 4 | include Bane 5 | include Behaviors 6 | 7 | # This example creates a single behavior listening on port 3000. 8 | # Note that the behavior, CloseAfterPause, specifies a default duration to pause - 60 seconds. 9 | 10 | behavior = Servers::ResponderServer.new(3000, Responders::CloseAfterPause.new(duration: 60)) 11 | launcher = Launcher.new([behavior]) 12 | launcher.start 13 | # To run until interrupt, use the following line: 14 | #launcher.join 15 | 16 | # For examples, we'll let these sleep for a few seconds and then shut down' 17 | sleep 10 18 | launcher.stop 19 | 20 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/fixed_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends a static response. 8 | # 9 | # Options: 10 | # - message: The response message to send. Default: "Hello, world!" 11 | class FixedResponse 12 | def initialize(options = {}) 13 | @options = {message: "Hello, world!"}.merge(options) 14 | end 15 | 16 | def serve(io) 17 | io.write @options[:message] 18 | end 19 | end 20 | 21 | class FixedResponseForEachLine < FixedResponse 22 | include ForEachLine 23 | end 24 | 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/http_refuse_all_credentials_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class HttpRefuseAllCredentialsTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | 10 | def test_sends_401_response_code 11 | fake_connection.will_send("GET /some/irrelevant/path HTTP/1.1") 12 | 13 | server = HttpRefuseAllCredentials.new 14 | query_server(server) 15 | 16 | assert fake_connection.read_all_queries?, "Should have read the HTTP query before sending response" 17 | assert_match(/HTTP\/1.1 401 Unauthorized/, response, 'Should have responded with the 401 response code') 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /test/bane/behaviors/servers/timeout_in_listen_queue_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | require 'socket' 5 | 6 | class TimeoutInListenQueueTest < Test::Unit::TestCase 7 | 8 | include LaunchableRoleTests 9 | include ServerTestHelpers 10 | 11 | def setup 12 | @object = Bane::Behaviors::Servers::TimeoutInListenQueue.make(IRRELEVANT_PORT, Bane::Behaviors::Servers::LOCALHOST) 13 | end 14 | 15 | def test_never_connects 16 | run_server(Bane::Behaviors::Servers::TimeoutInListenQueue.make(port, Bane::Behaviors::Servers::LOCALHOST)) do 17 | assert_raise(Errno::ECONNREFUSED) { TCPSocket.new('localhost', port) } 18 | end 19 | end 20 | 21 | def port 22 | 4001 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/deluge_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends a large response. Response consists of a repeated 'x' character. 8 | # 9 | # Options 10 | # - length: The size in bytes of the response to send. Default: 1,000,000 bytes 11 | class DelugeResponse 12 | def initialize(options = {}) 13 | @options = {length: 1_000_000}.merge(options) 14 | end 15 | 16 | def serve(io) 17 | length = @options[:length] 18 | 19 | length.times { io.write('x') } 20 | end 21 | end 22 | 23 | class DelugeResponseForEachLine < DelugeResponse 24 | include ForEachLine 25 | end 26 | 27 | end 28 | end 29 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/close_after_pause_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | require 'mocha/test_unit' 5 | 6 | class CloseAfterPauseTest < Test::Unit::TestCase 7 | 8 | include Bane::Behaviors::Responders 9 | include BehaviorTestHelpers 10 | 11 | def test_sleeps_30_seconds_by_default 12 | server = CloseAfterPause.new 13 | server.expects(:sleep).with(30) 14 | 15 | query_server(server) 16 | end 17 | 18 | def test_sleeps_specified_number_of_seconds 19 | server = CloseAfterPause.new(duration: 1) 20 | server.expects(:sleep).with(1) 21 | 22 | query_server(server) 23 | end 24 | 25 | def test_sends_nothing 26 | server = CloseAfterPause.new 27 | server.stubs(:sleep) 28 | 29 | query_server(server) 30 | end 31 | 32 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/for_each_line_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | require 'mocha/test_unit' 5 | 6 | class ForEachLineTest < Test::Unit::TestCase 7 | 8 | include Bane::Behaviors::Responders 9 | include BehaviorTestHelpers 10 | 11 | def test_reads_a_line_before_responding_with_parent_behavior 12 | server = SayHelloForEachLineBehavior.new 13 | 14 | fake_connection.will_send "irrelevant\n" 15 | 16 | query_server(server) 17 | assert_equal "Hello", response 18 | 19 | assert fake_connection.read_all_queries? 20 | end 21 | 22 | class SayHelloBehavior 23 | def serve(io) 24 | io.write('Hello') 25 | end 26 | end 27 | 28 | class SayHelloForEachLineBehavior < SayHelloBehavior 29 | include Bane::Behaviors::Responders::ForEachLine 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/never_respond.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Accepts a connection and never sends a byte of data. The connection is 8 | # left open indefinitely. 9 | class NeverRespond 10 | READ_TIMEOUT_IN_SECONDS = 2 11 | MAXIMUM_BYTES_TO_READ = 4096 12 | 13 | def serve(io) 14 | loop do 15 | begin 16 | io.read_nonblock(MAXIMUM_BYTES_TO_READ) 17 | rescue Errno::EAGAIN 18 | IO.select([io], nil, nil, READ_TIMEOUT_IN_SECONDS) 19 | retry # Ignore the result of IO select since we retry reads regardless of if there's data to be read or not 20 | rescue EOFError 21 | break 22 | end 23 | end 24 | end 25 | 26 | end 27 | end 28 | end 29 | end -------------------------------------------------------------------------------- /test/bane/behaviors/responders/never_respond_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | 5 | class NeverRespondTest < Test::Unit::TestCase 6 | 7 | include Bane::Behaviors::Responders 8 | include BehaviorTestHelpers 9 | include ServerTestHelpers 10 | 11 | LONG_MESSAGE = 'x'*(1024*5) 12 | 13 | def test_does_not_send_a_response 14 | server = NeverRespond.new 15 | 16 | query_server(server) 17 | assert_empty_response 18 | end 19 | 20 | def test_disconnects_after_client_closes_connection 21 | run_server(Bane::Behaviors::Servers::ResponderServer.new(0, NeverRespond.new)) do |server| 22 | client = TCPSocket.new('localhost', server.port) 23 | sleep 3 24 | client.write LONG_MESSAGE 25 | client.close 26 | 27 | sleep 0.1 28 | 29 | assert_equal 0, server.connections 30 | end 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/http_refuse_all_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends an HTTP 401 response (Unauthorized) for every request. This 8 | # attempts to mimic an HTTP server by reading a line (the request) 9 | # and then sending the response. This behavior responds to all 10 | # incoming request URLs on the running port. 11 | class HttpRefuseAllCredentials 12 | UNAUTHORIZED_RESPONSE_BODY = < 14 | 15 | 16 | Bane Server 17 | 18 | 19 |

Unauthorized

20 | 21 | 22 | EOF 23 | 24 | def serve(io) 25 | io.gets # Read the request before responding 26 | response = NaiveHttpResponse.new(401, "Unauthorized", "text/html", UNAUTHORIZED_RESPONSE_BODY) 27 | io.write(response.to_s) 28 | end 29 | end 30 | 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /examples/multiple_behaviors.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib' 2 | require 'bane' 3 | 4 | include Bane 5 | include Behaviors 6 | 7 | # This example creates several behavior listening on distinct ports. 8 | # Note the FixedResponse port specifies to listen to all hosts (0.0.0.0), all 9 | # other servers listen to localhost only by default (127.0.0.1). 10 | 11 | close_immediately = Responders::CloseImmediately.new 12 | never_respond = Responders::NeverRespond.new 13 | fixed_response = Responders::FixedResponse.new(message: "OK") 14 | 15 | launcher = Launcher.new([Servers::ResponderServer.new(3000, close_immediately), 16 | Servers::ResponderServer.new(8000, never_respond), 17 | Servers::ResponderServer.new(8080, fixed_response, Servers::ALL_INTERFACES)]) 18 | launcher.start 19 | # To run until interrupt, use the following line: 20 | #launcher.join 21 | 22 | # For examples, we'll let these sleep for a few seconds and then shut down' 23 | sleep 10 24 | launcher.stop 25 | 26 | -------------------------------------------------------------------------------- /test/bane/fake_connection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class FakeConnectionTest < Test::Unit::TestCase 6 | 7 | def setup 8 | @fake_connection = FakeConnection.new 9 | end 10 | 11 | def test_fake_connection_returns_nil_if_no_commands_to_read 12 | assert_nil @fake_connection.gets 13 | end 14 | 15 | def test_fake_connection_reports_when_one_command_set_and_read 16 | @fake_connection.will_send("Command #1") 17 | 18 | @fake_connection.gets 19 | 20 | assert @fake_connection.read_all_queries?, "Should have read all queries" 21 | end 22 | 23 | def test_fake_connection_reports_when_all_commands_read 24 | @fake_connection.will_send("Command #1") 25 | @fake_connection.will_send("Command #2") 26 | 27 | @fake_connection.gets 28 | 29 | assert !@fake_connection.read_all_queries?, "Did not read all queries yet" 30 | 31 | @fake_connection.gets 32 | 33 | assert @fake_connection.read_all_queries?, "Should have read all queries" 34 | end 35 | 36 | end -------------------------------------------------------------------------------- /lib/bane/behaviors/responders/slow_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | module Behaviors 5 | module Responders 6 | 7 | # Sends a fixed response character-by-character, pausing between each character. 8 | # 9 | # Options: 10 | # - message: The response to send. Default: "Hello, world!" 11 | # - pause_duration: The number of seconds to pause between each character. Default: 10 seconds 12 | class SlowResponse 13 | def initialize(options = {}) 14 | @options = {message: "Hello, world!", pause_duration: 10}.merge(options) 15 | end 16 | 17 | def serve(io) 18 | message = @options[:message] 19 | pause_duration = @options[:pause_duration] 20 | 21 | message.each_char do |char| 22 | io.write char 23 | sleep pause_duration 24 | end 25 | end 26 | end 27 | 28 | class SlowResponseForEachLine < SlowResponse 29 | include ForEachLine 30 | end 31 | 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /lib/bane.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bane/extensions' 4 | require 'bane/behavior_maker' 5 | require 'bane/behaviors/responders/for_each_line' 6 | require 'bane/behaviors/responders/close_after_pause' 7 | require 'bane/behaviors/responders/close_immediately' 8 | require 'bane/behaviors/responders/deluge_response' 9 | require 'bane/behaviors/responders/echo_response' 10 | require 'bane/behaviors/responders/fixed_response' 11 | require 'bane/behaviors/responders/http_refuse_all_credentials' 12 | require 'bane/behaviors/responders/never_respond' 13 | require 'bane/behaviors/responders/newline_response' 14 | require 'bane/behaviors/responders/random_response' 15 | require 'bane/behaviors/responders/slow_response' 16 | require 'bane/behaviors/responders/exported' 17 | require 'bane/behaviors/servers/responder_server' 18 | require 'bane/behaviors/servers/timeout_in_listen_queue' 19 | require 'bane/behaviors/servers/exported' 20 | require 'bane/launcher' 21 | require 'bane/arguments_parser' 22 | require 'bane/command_line_configuration' 23 | require 'bane/naive_http_response' 24 | -------------------------------------------------------------------------------- /lib/bane/behavior_maker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | 5 | class BehaviorMaker 6 | def initialize(makeables) 7 | @makeables = makeables 8 | end 9 | 10 | def create(behavior_names, starting_port, host) 11 | behavior_names 12 | .map { |behavior| makeables.fetch(behavior) { raise UnknownBehaviorError.new(behavior) } } 13 | .map.with_index { |maker, index| maker.make(starting_port + index, host) } 14 | end 15 | 16 | def create_all(starting_port, host) 17 | makeables.sort.map.with_index { |name_maker_pair, index| name_maker_pair.last.make(starting_port + index, host) } 18 | end 19 | 20 | private 21 | 22 | attr_reader :makeables 23 | end 24 | 25 | class UnknownBehaviorError < RuntimeError 26 | def initialize(name) 27 | super "Unknown behavior: #{name}" 28 | end 29 | end 30 | 31 | class ResponderMaker 32 | def initialize(responder) 33 | @responder = responder 34 | end 35 | 36 | def make(port, host) 37 | Behaviors::Servers::ResponderServer.new(port, @responder.new, host) 38 | end 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/bane/naive_http_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NaiveHttpResponse 4 | 5 | CRLF = "\r\n" 6 | 7 | class HttpHeader 8 | def initialize(headers) 9 | @headers = headers 10 | end 11 | 12 | def to_s 13 | @headers.map { |k, v| "#{k}: #{v}" }.join(CRLF) 14 | end 15 | 16 | end 17 | 18 | def initialize(response_code, response_description, content_type, body) 19 | @code = response_code 20 | @description = response_description 21 | @content_type = content_type 22 | @body = body 23 | end 24 | 25 | def to_s 26 | str = [] 27 | str << "HTTP/1.1 #{@code} #{@description}" 28 | str << http_header.to_s 29 | str << "" 30 | str << @body 31 | str.join(CRLF) 32 | end 33 | 34 | private 35 | 36 | def http_header() 37 | HttpHeader.new( 38 | {"Server" => "Bane HTTP Server", 39 | "Connection" => "close", 40 | "Date" => http_date(Time.now), 41 | "Content-Type" => @content_type, 42 | "Content-Length" => @body.length}) 43 | end 44 | 45 | def http_date(time) 46 | time.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ main ] 13 | pull_request: 14 | branches: [ main ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby-version: ['2.5', '2.6', '2.7', '3.0', '3.1'] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Ruby 27 | # Automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 28 | # using this version alias (see https://github.com/ruby/setup-ruby#versioning): 29 | uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby-version }} 32 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 33 | - name: Run tests 34 | run: bundle exec rake 35 | -------------------------------------------------------------------------------- /lib/bane/behaviors/servers/timeout_in_listen_queue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'socket' 4 | 5 | module Bane 6 | module Behaviors 7 | module Servers 8 | 9 | class TimeoutInListenQueue 10 | 11 | def initialize(port, host = Servers::LOCALHOST) 12 | @port = port 13 | @host = host 14 | self.stdlog= $stderr 15 | end 16 | 17 | def start 18 | @server = Socket.new(:INET, :STREAM) 19 | address = Socket.sockaddr_in(port, host) 20 | @server.bind(address) # Note that we never call listen 21 | 22 | log 'started' 23 | end 24 | 25 | def join 26 | sleep 27 | end 28 | 29 | def stop 30 | @server.close 31 | log 'stopped' 32 | end 33 | 34 | def stdlog=(logger) 35 | @logger = logger 36 | end 37 | 38 | def self.make(port, host) 39 | new(port, host) 40 | end 41 | 42 | private 43 | 44 | attr_reader :host, :port, :logger 45 | 46 | def log(message) 47 | logger.puts "[#{Time.new.ctime}] #{self.class.unqualified_name} #{host}:#{port} #{message}" 48 | end 49 | end 50 | 51 | end 52 | end 53 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Daniel J. Wellman 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | 11 | -------------------------------------------------------------------------------- /lib/bane/behaviors/servers/responder_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gserver' 4 | 5 | module Bane 6 | module Behaviors 7 | module Servers 8 | 9 | class ResponderServer < GServer 10 | 11 | def initialize(port, behavior, host = Servers::LOCALHOST) 12 | super(port, host) 13 | @behavior = behavior 14 | self.audit = true 15 | end 16 | 17 | def serve(io) 18 | @behavior.serve(io) 19 | end 20 | 21 | def to_s 22 | "" 23 | end 24 | 25 | protected 26 | 27 | alias_method :original_log, :log 28 | 29 | def log(message) 30 | original_log("#{@behavior.class.unqualified_name} #{@host}:#{@port} #{message}") 31 | end 32 | 33 | def connecting(client) 34 | addr = client.peeraddr 35 | log("client:#{addr[1]} #{addr[2]}<#{addr[3]}> connect") 36 | end 37 | 38 | def disconnecting(client_port) 39 | log("client:#{client_port} disconnect") 40 | end 41 | 42 | def starting 43 | log('start') 44 | end 45 | 46 | def stopping 47 | log('stop') 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/bane/command_line_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bane 4 | 5 | def self.find_makeables 6 | Hash[Bane::Behaviors::Responders::EXPORTED.map { |responder| [responder.unqualified_name, ResponderMaker.new(responder)] }] 7 | .merge(Hash[Bane::Behaviors::Servers::EXPORTED.map { |server| [server.unqualified_name, server] }]) 8 | end 9 | 10 | class CommandLineConfiguration 11 | 12 | def initialize(makeables) 13 | @behavior_maker = BehaviorMaker.new(makeables) 14 | @arguments_parser = ArgumentsParser.new(makeables.keys) 15 | end 16 | 17 | def process(args, &error_policy) 18 | arguments = @arguments_parser.parse(args) 19 | create(arguments.behaviors, arguments.port, arguments.host) 20 | rescue ConfigurationError => ce 21 | error_policy.call([ce.message, @arguments_parser.usage].join("\n")) 22 | end 23 | 24 | private 25 | 26 | def create(behaviors, port, host) 27 | if behaviors.any? 28 | @behavior_maker.create(behaviors, port, host) 29 | else 30 | @behavior_maker.create_all(port, host) 31 | end 32 | rescue UnknownBehaviorError => ube 33 | raise ConfigurationError, ube.message 34 | end 35 | 36 | end 37 | 38 | class ConfigurationError < RuntimeError; 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Features: 2 | 3 | Design questions / Ideas: 4 | - Figure out where the logger configuration logic belongs in the Launcher/BehaviorServer relationship 5 | - Should the default logger go to STDERR or STDOUT? 6 | - Log every request to the server/behavior, in addition to open, close. For this to work, it would have to be the 7 | behavior's responsibility, since GServer#serve gets called only once for the lifetime of the connection. 8 | - If we extract a commong logger, we might use that to test for proper disconnection in NeverRespondTest#test_disconnects_after_client_closes_connection 9 | 10 | Future Behaviors: 11 | - Create a more configurable version of the DelugeResponse which allows for a header, footer, content and times to repeat. 12 | - Write the remaining bad HTTP behaviors. In addition, we may want to replace the NaiveHttpResponse with something 13 | from the standard Ruby library, so that there's less code in this project, and so we know that we're 14 | following the HTTP protocol. 15 | - Have a tiny listen queue (queue size 0) and a connection never gets into the queue 16 | - Have a giant listen queue (queue size max?) and only one thread processing -- times out in the listen queue 17 | - Close the write part of the connection, but not the read 18 | - Close the read part of the connection, but not the write 19 | -------------------------------------------------------------------------------- /bane.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/bane/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'bane' 7 | spec.version = Bane::VERSION 8 | spec.authors = ['Daniel Wellman', 'Joe Leo'] 9 | spec.email = ['dan@danielwellman.com'] 10 | 11 | spec.summary = "A test harness for socket connections based upon ideas from Michael Nygard's 'Release It!'" 12 | spec.description = "Bane is a test harness used to test your application's interaction with\n other servers. It is based upon the material from Michael Nygard's \"Release\n It!\" book as described in the \"Test Harness\" chapter.\n" 13 | spec.homepage = 'https://github.com/danielwellman/bane' 14 | spec.licenses = ['BSD'] 15 | spec.required_rubygems_version = Gem::Requirement.new('>= 0') if spec.respond_to? :required_rubygems_version= 16 | spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') 17 | 18 | spec.metadata['source_code_uri'] = spec.homepage 19 | spec.metadata['changelog_uri'] = 'https://github.com/danielwellman/bane/blob/main/HISTORY.md' 20 | 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir['{bin,lib}/**/*', 'HISTORY.md', 'LICENSE', 'README.md'] 23 | spec.require_paths = ['lib'] 24 | spec.executables = ['bane'] 25 | 26 | spec.extra_rdoc_files = %w[LICENSE README.md TODO] 27 | 28 | spec.add_development_dependency 'rake', '~> 13.0' 29 | spec.add_development_dependency 'rdoc', '~> 6.3' 30 | 31 | spec.add_dependency 'gserver', '0.0.1' 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'test/unit' 5 | require 'stringio' 6 | require 'timeout' 7 | 8 | $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib') 9 | require 'bane' 10 | require_relative 'bane/launchable_role_tests' 11 | 12 | IRRELEVANT_PORT = 4001 13 | IRRELEVANT_BEHAVIOR = 'CloseImmediately' 14 | 15 | class FakeConnection < StringIO 16 | 17 | def will_send(query) 18 | commands.push query 19 | end 20 | 21 | def gets 22 | commands.pop 23 | end 24 | 25 | def read_all_queries? 26 | commands.empty? 27 | end 28 | 29 | private 30 | 31 | def commands 32 | @commands ||= [] 33 | end 34 | end 35 | 36 | module BehaviorTestHelpers 37 | 38 | def fake_connection 39 | @fake_connection ||= FakeConnection.new 40 | end 41 | 42 | def query_server(server) 43 | server.serve(fake_connection) 44 | end 45 | 46 | def response 47 | fake_connection.string 48 | end 49 | 50 | def assert_empty_response 51 | assert_equal 0, response.length, "Should have sent nothing" 52 | end 53 | 54 | def assert_response_length(expected_length) 55 | assert_equal expected_length, response.length, "Response was the wrong length" 56 | end 57 | 58 | end 59 | 60 | module ServerTestHelpers 61 | 62 | def run_server(server, &block) 63 | server.stdlog = StringIO.new 64 | launch_and_stop_safely(server, &block) 65 | end 66 | 67 | def launch_and_stop_safely(launcher, &block) 68 | launcher.start 69 | block.call(launcher) 70 | ensure 71 | launcher.stop if launcher 72 | end 73 | end -------------------------------------------------------------------------------- /lib/bane/arguments_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'optparse' 4 | 5 | module Bane 6 | class ArgumentsParser 7 | def initialize(makeable_names) 8 | @makeable_names = makeable_names 9 | @options = {host: default_host} 10 | @option_parser = init_option_parser 11 | end 12 | 13 | def parse(args) 14 | @option_parser.parse!(args) 15 | 16 | raise ConfigurationError, "Missing arguments" if args.empty? 17 | 18 | port = parse_port(args[0]) 19 | behaviors = args.drop(1) 20 | ParsedArguments.new(port, @options[:host], behaviors) 21 | rescue OptionParser::InvalidOption => io 22 | raise ConfigurationError, io.message 23 | end 24 | 25 | def usage 26 | @option_parser.help 27 | end 28 | 29 | private 30 | 31 | def init_option_parser 32 | OptionParser.new do |opts| 33 | opts.banner = 'Usage: bane [options] port [behaviors]' 34 | opts.separator '' 35 | opts.on('-l', '--listen-on-localhost', 36 | "Listen on localhost, (#{default_host}). [default]") do 37 | @options[:host] = default_host 38 | end 39 | opts.on('-a', '--listen-on-all-hosts', "Listen on all interfaces, (#{all_interfaces})") do 40 | @options[:host] = all_interfaces 41 | end 42 | opts.separator '' 43 | opts.separator 'All behaviors:' 44 | opts.separator @makeable_names.sort.map { |title| " - #{title}" }.join("\n") 45 | end 46 | end 47 | 48 | def parse_port(port) 49 | Integer(port) 50 | rescue ArgumentError 51 | raise ConfigurationError, "Invalid port number: #{port}" 52 | end 53 | 54 | def all_interfaces 55 | Behaviors::Servers::ALL_INTERFACES 56 | end 57 | 58 | def default_host 59 | Behaviors::Servers::LOCALHOST 60 | end 61 | end 62 | 63 | class ParsedArguments 64 | 65 | attr_reader :port, :host, :behaviors 66 | 67 | def initialize(port, host, behaviors) 68 | @host = host 69 | @port = port 70 | @behaviors = behaviors 71 | end 72 | 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /test/bane/acceptance_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | require 'mocha/test_unit' 5 | require 'net/http' 6 | 7 | class BaneAcceptanceTest < Test::Unit::TestCase 8 | 9 | include ServerTestHelpers 10 | 11 | TEST_PORT = 4000 12 | 13 | def test_uses_specified_port_and_server 14 | run_server_with(TEST_PORT, Bane::Behaviors::Responders::FixedResponse) do 15 | with_response_from TEST_PORT do |response| 16 | assert !response.empty?, "Should have had a non-empty response" 17 | end 18 | end 19 | end 20 | 21 | def test_serves_http_requests 22 | run_server_with(TEST_PORT, Bane::Behaviors::Responders::HttpRefuseAllCredentials) do 23 | assert_match(/401/, status_returned_from("http://localhost:#{TEST_PORT}/some/url")) 24 | end 25 | end 26 | 27 | def test_supports_command_line_interface 28 | run_server_with_cli_arguments(["--listen-on-localhost", TEST_PORT, "FixedResponse"]) do 29 | with_response_from TEST_PORT do |response| 30 | assert !response.empty?, "Should have had a non-empty response" 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | def run_server_with(port, behavior, &block) 38 | behavior = Bane::Behaviors::Servers::ResponderServer.new(port, behavior.new) 39 | launcher = Bane::Launcher.new([behavior], quiet_logger) 40 | launch_and_stop_safely(launcher, &block) 41 | sleep 0.1 # Until we can fix the GServer stopping race condition (Issue #7) 42 | end 43 | 44 | def run_server_with_cli_arguments(arguments, &block) 45 | config = Bane::CommandLineConfiguration.new(Bane.find_makeables) 46 | launcher = Bane::Launcher.new(config.process(arguments), quiet_logger) { |error| raise error } 47 | launch_and_stop_safely(launcher, &block) 48 | sleep 0.1 # Until we can fix the GServer stopping race condition (Issue #7) 49 | end 50 | 51 | def quiet_logger 52 | StringIO.new 53 | end 54 | 55 | def status_returned_from(uri) 56 | Net::HTTP.get_response(URI(uri)).code 57 | end 58 | 59 | def with_response_from(port) 60 | begin 61 | connection = TCPSocket.new "localhost", port 62 | yield connection.read 63 | ensure 64 | connection.close if connection 65 | end 66 | end 67 | 68 | end 69 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 1.0.0 4 | 5 | ### Changed 6 | * Support Ruby 2.5+. This meant adding a dependency on 'gserver', which was moved out of the standard library. 7 | 8 | ## 0.4.0 9 | 10 | ### Added 11 | * The EchoResponse behavior which replies with each line sent 12 | * The TimeoutInListenQueue server which binds to a port but never calls listen(2) 13 | * Trap SIGINT to gracefully stop servers without a messy exception stacktrace 14 | 15 | ### Removed 16 | * The fancy, flexible ConfigurationParser has been deleted. Command-line invocation now uses the CommandLineConfiguration parser. For programmatic invocation, see the examples. 17 | * Ruby 1.8.7 support 18 | 19 | ### Changed 20 | * Rearranged packages to create Bane::Behaviors::Servers and Bane::Beaviors::Responders. Servers may be started and stopped; Responders simply interact with an already connected socket. 21 | * Added Bane::Behaviors::Servers::LOCALHOST (127.0.0.1) and deprecated Bane::Behaviors::Servers::DEFAULT_HOST; please use LOCALHOST when specifying a host to listen on. 22 | 23 | ## 0.3.0 24 | 25 | ### Added 26 | * Servers can now listen on all hosts or localhost via the command-line options -a / --listen-on-all-hosts or -l / --listen-on-localhost. The default is to listen on localhost. 27 | 28 | 29 | ### Changed 30 | * Behaviors receive their parameters through their constructors instead of being passed via the serve method. That is, 31 | the serve(io, options) method has been changed to serve(io). Behaviors that need to accept user-specified parameters 32 | should accept them via constructor arguments, and should provide a default version since the command-line interface 33 | does not specify options. e.g. 34 | 35 | ``` 36 | class MyBehavior 37 | def initialize(options = {}) 38 | ... 39 | ``` 40 | 41 | * BehaviorServer no longer accepts options; instead these are created with the Behavior objects. 42 | * Configuration() and ConfigurationParser class are deprecated and will be removed in the next release. Instead of 43 | using these classes, please directly instantiate a BehaviorServer with the required arguments. This class is being 44 | deprecated and removed because the flexibility of the code creates a structure that is harder to read and maintain. 45 | I'm also not sure anyone is using this method -- if so, please open a GitHub Issue and let me know if you're using 46 | it -- and if so, how. 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /test/bane/behaviors/servers/responder_server_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../../test_helper' 4 | require 'mocha/test_unit' 5 | 6 | class ResponderServerTest < Test::Unit::TestCase 7 | include LaunchableRoleTests 8 | 9 | include Bane 10 | include Bane::Behaviors::Servers 11 | 12 | IRRELEVANT_IO_STREAM = nil 13 | IRRELEVANT_OPTIONS = {} 14 | IRRELEVANT_HOST = '1.1.1.1' 15 | 16 | def setup 17 | @object = ResponderServer.new(IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR) 18 | end 19 | 20 | def test_initializes_server_on_specified_port 21 | server = ResponderServer.new(6000, IRRELEVANT_BEHAVIOR) 22 | assert_equal 6000, server.port 23 | end 24 | 25 | def test_initializes_server_on_specified_hostname 26 | server = ResponderServer.new(IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR, 'hostname') 27 | assert_equal 'hostname', server.host 28 | end 29 | 30 | def test_delegates_serve_call_to_responder 31 | io = mock 32 | responder = mock 33 | server = ResponderServer.new(IRRELEVANT_PORT, responder) 34 | 35 | responder.expects(:serve).with(io) 36 | 37 | server.serve(io) 38 | end 39 | 40 | def test_connection_log_messages_use_short_behavior_name_to_shorten_log_messages 41 | [:connecting, :disconnecting].each do |method| 42 | assert_log_message_uses_short_behavior_name_for(method) do |server| 43 | server.send(method, stub_everything(peeraddr: [127, 0, 0, 1])) 44 | end 45 | end 46 | end 47 | 48 | def test_start_stop_log_messages_use_short_behavior_name_to_shorten_log_messages 49 | [:starting, :stopping].each do |method| 50 | assert_log_message_uses_short_behavior_name_for(method) do |server| 51 | server.send(method) 52 | end 53 | end 54 | end 55 | 56 | def assert_log_message_uses_short_behavior_name_for(method) 57 | logger = StringIO.new 58 | server = ResponderServer.new(IRRELEVANT_PORT, Bane::Behaviors::Responders::SampleForTesting.new) 59 | server.stdlog = logger 60 | 61 | yield server 62 | 63 | assert_match(/SampleForTesting/, logger.string, "Log for #{method} should contain class short name") 64 | assert_no_match(/Behaviors::Responders::SampleForTesting/, logger.string, "Log for #{method} should not contain expanded module name") 65 | end 66 | 67 | end 68 | 69 | module Bane 70 | module Behaviors 71 | module Responders 72 | class SampleForTesting 73 | def serve(io) 74 | io.puts('Hello') 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/bane/naive_http_response_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class NaiveHttpResponseTest < Test::Unit::TestCase 6 | 7 | IRRELEVANT_RESPONSE_CODE = "999" 8 | IRRELEVANT_RESPONSE_DESCRIPTION = "Irrelevant description" 9 | IRRELEVANT_CONTENT_TYPE = "irrelevant content type" 10 | IRRELEVANT_BODY = "irrelevant body" 11 | 12 | def test_should_send_http_format_string 13 | response = response_for("200", "OK", IRRELEVANT_CONTENT_TYPE, IRRELEVANT_BODY) 14 | assert_equal "HTTP/1.1 200 OK\r\n", response.lines.first, "First line should be HTTP status" 15 | end 16 | 17 | def test_should_include_date 18 | assert_match(/Date: .*/, any_response, 'Should have included a Date header') 19 | end 20 | 21 | def test_should_set_content_type 22 | response = response_for(IRRELEVANT_RESPONSE_CODE, 23 | IRRELEVANT_RESPONSE_DESCRIPTION, 24 | "text/xml", 25 | IRRELEVANT_BODY) 26 | assert_match(/Content-Type: text\/xml/, response, 'Should have included content type') 27 | end 28 | 29 | def test_should_set_content_length_as_length_of_body_in_bytes 30 | message = "Hello, there!" 31 | response = response_for(IRRELEVANT_RESPONSE_CODE, 32 | IRRELEVANT_RESPONSE_DESCRIPTION, 33 | IRRELEVANT_CONTENT_TYPE, 34 | message) 35 | 36 | assert_match(/Content-Length: #{message.length}/, response, 'Should have included content length') 37 | end 38 | 39 | def test_should_include_newline_between_headers_and_body 40 | message = "This is some body content." 41 | response = response_for(IRRELEVANT_RESPONSE_CODE, 42 | IRRELEVANT_RESPONSE_DESCRIPTION, 43 | IRRELEVANT_CONTENT_TYPE, 44 | message) 45 | 46 | response_lines = response.lines.to_a 47 | index_of_body_start = response_lines.index(message) 48 | line_before_body = response_lines[index_of_body_start - 1] 49 | assert line_before_body.strip.empty?, "Should have had blank line before the body" 50 | end 51 | 52 | def test_should_include_the_body_at_the_end_of_the_response 53 | message = "This is some body content." 54 | response = response_for(IRRELEVANT_RESPONSE_CODE, 55 | IRRELEVANT_RESPONSE_DESCRIPTION, 56 | IRRELEVANT_CONTENT_TYPE, 57 | message) 58 | assert_match(/#{message}$/, response, "Should have ended the response with the body content") 59 | end 60 | 61 | private 62 | 63 | def response_for(response_code, response_description, content_type, body) 64 | NaiveHttpResponse.new(response_code, response_description, content_type, body).to_s 65 | end 66 | 67 | def any_response 68 | response_for(IRRELEVANT_RESPONSE_CODE, IRRELEVANT_RESPONSE_DESCRIPTION, IRRELEVANT_CONTENT_TYPE, IRRELEVANT_BODY) 69 | end 70 | 71 | end -------------------------------------------------------------------------------- /test/bane/arguments_parser_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | 5 | class ArgumentsParserTest < Test::Unit::TestCase 6 | include Bane 7 | 8 | # Parsing arguments (uses the isolated ArgumentsParser object) 9 | 10 | def test_parses_the_port 11 | config = parse(["3000", IRRELEVANT_BEHAVIOR]) 12 | assert_equal 3000, config.port 13 | end 14 | 15 | def test_parses_the_behaviors 16 | config = parse([IRRELEVANT_PORT, 'NeverRespond', 'EchoResponse']) 17 | assert_equal ['NeverRespond', 'EchoResponse'], config.behaviors 18 | end 19 | 20 | def test_host_defaults_to_localhost_if_not_specified 21 | config = parse([IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR]) 22 | assert_equal '127.0.0.1', config.host 23 | end 24 | 25 | def test_dash_l_option_sets_listen_host_to_localhost 26 | assert_parses_host(Behaviors::Servers::LOCALHOST, ['-l', IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR]) 27 | end 28 | 29 | def test_listen_on_localhost_sets_listen_host_to_localhost 30 | assert_parses_host(Behaviors::Servers::LOCALHOST, ['--listen-on-localhost', IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR]) 31 | end 32 | 33 | def test_dash_a_option_sets_listen_host_to_all_interfaces 34 | assert_parses_host(Behaviors::Servers::ALL_INTERFACES, ['-a', IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR]) 35 | end 36 | 37 | def test_listen_on_all_hosts_option_sets_listen_host_to_all_interfaces 38 | assert_parses_host(Behaviors::Servers::ALL_INTERFACES, ['--listen-on-all-hosts', IRRELEVANT_PORT, IRRELEVANT_BEHAVIOR]) 39 | end 40 | 41 | def test_usage_message_includes_known_makeables_in_alphabetical_order 42 | usage = ArgumentsParser.new(['makeable2', 'makeable1']).usage 43 | assert_match(/makeable1/i, usage, 'Should have included all known makeables') 44 | assert_match(/makeable2/i, usage, 'Should have included all known makeables') 45 | assert_match(/makeable1\W+makeable2/i, usage, 'Should have been in alphabetical order') 46 | end 47 | 48 | def test_no_arguments_fail_with 49 | assert_invalid_arguments_fail_matching_message([], /missing arguments/i) 50 | end 51 | 52 | def test_non_integer_port_fails_with_error_message 53 | assert_invalid_arguments_fail_matching_message(['text_instead_of_an_integer'], /Invalid Port Number/i) 54 | end 55 | 56 | def test_invalid_option_fails_with_error_message 57 | assert_invalid_arguments_fail_matching_message(['--unknown-option', IRRELEVANT_PORT], /Invalid Option/i) 58 | end 59 | 60 | def parse(arguments) 61 | ArgumentsParser.new(["makeable1", "makeable2"]).parse(arguments) 62 | end 63 | 64 | def assert_parses_host(expected_host, arguments) 65 | config = parse(arguments) 66 | assert_equal expected_host, config.host 67 | end 68 | 69 | def assert_invalid_arguments_fail_matching_message(arguments, message_matcher) 70 | begin 71 | parse(arguments) 72 | flunk "Should have thrown an error" 73 | rescue ConfigurationError => ce 74 | assert_match message_matcher, ce.message 75 | end 76 | end 77 | 78 | end -------------------------------------------------------------------------------- /test/bane/command_line_configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../test_helper' 4 | require 'mocha/test_unit' 5 | 6 | class CommandLineConfigurationTest < Test::Unit::TestCase 7 | include Bane 8 | 9 | # Creation tests (uses a cluster of objects starting at the top-level CommandLineConfiguration) 10 | 11 | def test_creates_specified_makeable_on_given_port 12 | behaviors = process arguments: [3000, 'ThingA'], 13 | configuration: { 'ThingA' => SimpleMaker.new('ThingA'), 14 | 'ThingB' => SimpleMaker.new('ThingB') } 15 | assert_equal 1, behaviors.size, "Wrong number of behaviors, got #{behaviors}" 16 | assert_makeable_created(behaviors.first, port: 3000, name: 'ThingA') 17 | end 18 | 19 | def test_creates_multiple_makeables_on_increasing_ports 20 | behaviors = process arguments: [4000, 'ThingA', 'ThingB'], 21 | configuration: {'ThingA' => SimpleMaker.new('ThingA'), 22 | 'ThingB' => SimpleMaker.new('ThingB') } 23 | 24 | assert_equal 2, behaviors.size, "Wrong number of behaviors, got #{behaviors}" 25 | assert_makeable_created(behaviors.first, port: 4000, name: 'ThingA') 26 | assert_makeable_created(behaviors.last, port: 4000 + 1, name: 'ThingB') 27 | end 28 | 29 | def test_creates_all_known_makeables_in_alphabetical_order_if_only_port_specified 30 | behaviors = process arguments: [4000], 31 | configuration: { 'ThingB' => SimpleMaker.new('ThingB'), 32 | 'ThingC' => SimpleMaker.new('ThingC'), 33 | 'ThingA' => SimpleMaker.new('ThingA') } 34 | 35 | assert_equal 3, behaviors.size, "Wrong number of behaviors created, got #{behaviors}" 36 | assert_equal 'ThingA', behaviors[0].name 37 | assert_equal 'ThingB', behaviors[1].name 38 | assert_equal 'ThingC', behaviors[2].name 39 | end 40 | 41 | def process(options) 42 | arguments = options.fetch(:arguments) 43 | makeables = options.fetch(:configuration) 44 | CommandLineConfiguration.new(makeables).process(arguments) { |errors| raise errors } 45 | end 46 | 47 | def assert_makeable_created(behaviors, parameters) 48 | assert_equal parameters.fetch(:port), behaviors.port 49 | assert_equal parameters.fetch(:name), behaviors.name 50 | end 51 | 52 | class SimpleMaker 53 | attr_reader :name, :port, :host 54 | def initialize(name) 55 | @name = name 56 | end 57 | 58 | def make(port, host) 59 | @port = port 60 | @host = host 61 | self 62 | end 63 | end 64 | 65 | # Failure tests (uses a cluster of objects starting at the top-level CommandLineConfiguration) 66 | 67 | def test_unknown_behavior_fails_with_message 68 | assert_invalid_arguments_fail_matching_message([IRRELEVANT_PORT, 'AnUnknownBehavior'], /Unknown Behavior/i) 69 | end 70 | 71 | def test_invalid_option_fails_with_error_message 72 | assert_invalid_arguments_fail_matching_message(['--unknown-option', IRRELEVANT_PORT], /Invalid Option/i) 73 | end 74 | 75 | def assert_invalid_arguments_fail_matching_message(arguments, message_matcher) 76 | block_called = false 77 | CommandLineConfiguration.new({}).process(arguments) do |error_message| 78 | block_called = true 79 | assert_match message_matcher, error_message 80 | end 81 | assert block_called, "Expected invalid arguments to invoke the failure block" 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bane 2 | 3 | Bane is a test harness used to test your application's interaction with other servers. It is based upon the material from Michael Nygard's ["Release It!"](http://www.pragprog.com/titles/mnee/release-it) book as described in the "Test Harness" chapter. 4 | 5 | ## Why Use Bane? 6 | 7 | If you are building an application, you may depend on third-party servers or web services for your data. Most of the time these services are reliable, but at some point they will behave in an unusual manner - such as connecting but never respond, sending data very slowly, or sending an unexpected response. To ensure your application survives these scenarios, you should test your application against these bad behaviors. Bane helps you recreate these scenarios by standing in for your third-party servers and responding in several nefarious ways. 8 | 9 | ## Setup 10 | 11 | Bane is available as a Ruby gem. Install it with 12 | 13 | `gem install bane` 14 | 15 | Note that Bane installs an executable, `bane`. Simply invoke `bane` with no arguments to get a usage description. 16 | 17 | Bane requires Ruby 2.5 or later. 18 | 19 | ## Usage 20 | 21 | Bane is designed with a few usage scenarios in mind: 22 | 23 | 1. Quick start with a specific behavior from the command line. If your application talks to another server on a given port, you can start Bane from the command line by specifying the desired port and a name of server behavior. For example, if your server talks to a third-party server on port 8080, you could start the "Never Respond" behavior on port 8080 to observe how your application behaves. 24 | 25 | Example: `$ bane 8080 NeverRespond` 26 | 27 | 2. Quick start with multiple specific behaviors from the command line. This will start each behavior on consecutive ports. 28 | 29 | Example: `$ bane 8080 NeverRespond CloseImmediately` 30 | 31 | 3. Quick start with all known behaviors from the command line. This also starts each behavior on a consecutive port, starting from the supplied starting port. If you just want a general purpose test harness to run, and you can easily control the ports that your application talks to, you can start Bane up with a base port number and it will start all its known behaviors. This way you can leave Bane running and tweak your application to talk to each of the various behaviors. 32 | 33 | Example: `$ bane 3000` 34 | 35 | 4. Advanced Configuration using Ruby. If you want to modify some of the defaults used in the included behaviors, you can create a Ruby script to invoke Bane. For example, you might want to specify a response for the FixedResponse behavior: 36 | 37 | Example: 38 | 39 | ``` 40 | require 'bane' 41 | 42 | include Bane 43 | 44 | launcher = Launcher.new( 45 | [BehaviorServer.new(3000, Behaviors::Responders::FixedResponse.new(message: "Shall we play a game?"))]) 46 | launcher.start 47 | launcher.join 48 | ``` 49 | 50 | See the `examples` directory for more examples. For a list of options supported by the 51 | included behaviors, see the source for the behaviors in `lib/bane/behaviors`; note that you will find both 52 | servers (that bind to sockets directly) and responders (that assume a running TCP server and communicate with a socket connection). 53 | 54 | ## Listening on all hosts 55 | 56 | By default, Bane will listen only to connections on localhost (127.0.0.1). 57 | 58 | To listen on all hosts (0.0.0.0), start Bane from the command line with the `-a` or `--listen-on-all-hosts` option. For more command line help, run `bane -h` or `bane --help`. 59 | 60 | ## Keeping the Connection Open 61 | 62 | By default, the socket behaviors that send any data will close the connection immediately after sending the response. There are variations of these behaviors available that end with `ForEachLine` which will wait for a line of input (using IO's `gets`), respond, then return to the waiting for input state. 63 | 64 | For example, if you want to send a static response and then close the connection immediately, use `FixedResponse`. If you want to keep the connection open and respond to every line of input with the same data, use `FixedResponseForEachLine`. Note that these behaviors will never close the connection; they will happily respond to every line of input until you stop Bane. 65 | 66 | If you are implementing a new behavior, you should consider whether or not you would like to provide another variation which keeps a connection open and responds after every line of input. If so, create the basic behavior which responds and closes the connection immediately, then create another behavior which includes the `ForEachLine` module. See the source in `lib/bane/behaviors/responders/fixed_response.rb` for some examples. 67 | 68 | ## Background 69 | 70 | See the "Test Harness" chapter from "Release It!" to read about the inspiration of Bane. 71 | 72 | The following behaviors are currently supported in Bane, with the name of the behavior after the description in parenthesis. 73 | Note that these are simple protocol-independent socket behaviors: 74 | 75 | * The connection can be established, but the remote end never sends a byte of data (NeverRespond) 76 | * The service can send one byte of the response every thirty seconds (SlowResponse) 77 | * The server establishes a connection but sends a random reply (RandomResponse) 78 | * The server accepts a connection and then drops it immediately (CloseImmediately) 79 | * The service can send megabytes when kilobytes are expected. (rough approximation with the DelugeReponse) 80 | * The service can refuse all authentication credentials. (HttpRefuseAllCredentials) 81 | * The request can sit in a listen queue until the caller times out. (TimeoutInListenQueue) 82 | 83 | The following behaviors are not yet supported; they require the configuration of an HTTP server. 84 | See the implementation of HttpRefuseAllCredentials for a simple example of an HTTP behavior. 85 | 86 | * The service can accept a request, send response headers (supposing HTTP), and never send the response body. 87 | * The service can send a response of HTML instead of the expected XML. 88 | 89 | The following behaviors are not yet supported. These require the ability to manipulate 90 | TCP packets at a low level, which may require a C or C++ extension or raw sockets. 91 | 92 | * The connection can be refused. 93 | * The remote end can reply with a SYN/ACK and then never send any data. 94 | * The remote end can send nothing but RESET packets. 95 | * The remote end can report a full receive window and never drain the data. 96 | * The connection can be established, but packets could be lost causing retransmit delays 97 | * The connection can be established, but the remote end never acknowledges receiving a packet, causing endless retransmits 98 | --------------------------------------------------------------------------------