From 965ed567b564a606a8ba190794016cac60757563 Mon Sep 17 00:00:00 2001 From: Oleh Fedorenko Date: Wed, 30 Sep 2026 11:39:09 +0000 Subject: [PATCH 1/2] Fix undefined method 'rewind' for an instance of Rackup::Handler::WEBrick::Input --- lib/smart_proxy_shellhooks/shellhooks_api.rb | 5 ++ test/shellhooks_api_test.rb | 92 ++++++++++++++++++++ test/test_helper.rb | 7 ++ 3 files changed, 104 insertions(+) create mode 100644 test/shellhooks_api_test.rb create mode 100644 test/test_helper.rb diff --git a/lib/smart_proxy_shellhooks/shellhooks_api.rb b/lib/smart_proxy_shellhooks/shellhooks_api.rb index 31ccf1c..27bc816 100644 --- a/lib/smart_proxy_shellhooks/shellhooks_api.rb +++ b/lib/smart_proxy_shellhooks/shellhooks_api.rb @@ -1,4 +1,5 @@ require 'sinatra' +require 'rack/rewindable_input' require 'smart_proxy_shellhooks/shellhooks' module Proxy::ShellHooks @@ -8,6 +9,9 @@ class Api < ::Sinatra::Base include ::Proxy::Log helpers ::Proxy::Helpers + # Rack 3 inputs need buffering so parameter parsing does not consume the payload. + use Rack::RewindableInput::Middleware if defined?(Rack::RewindableInput::Middleware) + authorize_with_ssl_client authorize_with_trusted_hosts @@ -39,6 +43,7 @@ class Api < ::Sinatra::Base break end end + # Sinatra may have already read the body while parsing form parameters. request.body.rewind Proxy::Util::CommandTask.new(cmd, request.body.read).start end diff --git a/test/shellhooks_api_test.rb b/test/shellhooks_api_test.rb new file mode 100644 index 0000000..8112a86 --- /dev/null +++ b/test/shellhooks_api_test.rb @@ -0,0 +1,92 @@ +require_relative 'test_helper' +require 'stringio' +require 'tmpdir' +require 'fileutils' +require 'rack/mock' +require 'rackup/handler/webrick' if Rack.release.start_with?('3.') + +class ShellhooksApiTest < Test::Unit::TestCase + # Implements the Rack 3 input interface without rewind. + class ForwardOnlyInput + def initialize(body) + @io = StringIO.new(body) + end + + def read(*args) + @io.read(*args) + end + + def gets(*args) + @io.gets(*args) + end + + def each(&block) + @io.each(&block) + end + end + + def setup + @directory = Dir.mktmpdir('shellhooks') + @script = File.join(@directory, 'print_body') + File.write(@script, "#!/bin/sh\ncat\n") + File.chmod(0700, @script) + Proxy::ShellHooks::Plugin.load_test_settings(:directory => @directory) + Proxy::ShellHooks::Api.any_instance.stubs(:logger).returns(stub(:debug => nil)) + end + + def teardown + FileUtils.remove_entry(@directory) if @directory + end + + def test_plain_text_payload + assert_payload("hello\nworld\n", 'text/plain') + end + + def test_json_payload + assert_payload('{"message":"hello"}', 'application/json') + end + + def test_form_payload_after_parameter_parsing + assert_payload('message=hello+world&name=another_hook', 'application/x-www-form-urlencoded') + end + + def test_empty_payload + assert_payload('', 'text/plain') + end + + def test_rewindable_input + assert_payload('message=hello', 'application/x-www-form-urlencoded', StringIO.new('message=hello')) + end + + if Rack.release.start_with?('3.') + def test_webrick_input + payload = 'message=hello+world' + request = stub('WEBrick request') + request.stubs(:body).yields(payload) + input = Rackup::Handler::WEBrick::Input.new(request) + assert_payload(payload, 'application/x-www-form-urlencoded', input) + end + end + + private + + def assert_payload(payload, content_type, input = nil) + # Rack 2 requires rewindable input; Rack 3 permits forward-only streams. + input ||= Rack.release.start_with?('2.') ? StringIO.new(payload) : ForwardOnlyInput.new(payload) + task = mock('command task') + task.expects(:start).returns('') + Proxy::Util::CommandTask.expects(:new).with([@script, 'an argument'], payload).returns(task) + + env = Rack::MockRequest.env_for('/print_body', + :method => 'POST', + 'CONTENT_TYPE' => content_type, + 'CONTENT_LENGTH' => payload.bytesize.to_s, + 'HTTP_X_SHELLHOOK_ARG_1' => 'an argument') + env['rack.input'] = input + status, _, body = Proxy::ShellHooks::Api.call(env) + assert_equal 200, status + ensure + body.close if body.respond_to?(:close) + env['rack.input'].close if env && env['rack.input'].respond_to?(:close) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..d41baf3 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,7 @@ +ENV['RACK_ENV'] = 'test' + +require 'test/unit' +require 'mocha/test_unit' +require 'smart_proxy_for_testing' +require 'smart_proxy_shellhooks' +require 'smart_proxy_shellhooks/shellhooks_api' From c837d2bb60ddc2e33f6c9203a3ca876d8151be48 Mon Sep 17 00:00:00 2001 From: Oleh Fedorenko Date: Wed, 30 Sep 2026 11:54:10 +0000 Subject: [PATCH 2/2] Add CI and local test support --- .github/workflows/test.yml | 16 ++++++++++++++++ .gitignore | 5 +++++ Gemfile | 11 +++++++++++ README.md | 11 +++++++++++ Rakefile | 15 +++++++++++++++ smart_proxy_shellhooks.gemspec | 1 + test/test_helper.rb | 4 ++++ 7 files changed, 63 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 Gemfile create mode 100644 Rakefile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c157071 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +concurrency: + group: ${{ github.ref_name }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + test: + name: Tests + uses: theforeman/actions/.github/workflows/smart_proxy_plugin.yml@v1 diff --git a/.gitignore b/.gitignore index 7b0e7d5..fdc3fff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ smart_proxy_shellhooks-*.gem /.idea +/Gemfile.lock +/.bundle +/.vendor +/logs +.ruby-version* \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..8d663fd --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' +gemspec + +group :test do + gem 'mocha' + gem 'rake' + gem 'smart_proxy', github: 'theforeman/smart-proxy', branch: ENV.fetch('SMART_PROXY_BRANCH', 'develop') + gem 'test-unit' +end diff --git a/README.md b/README.md index e375acd..fa3d442 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Provides an endpoint for foreman_webhooks plugin executing binaries (shell scripts, python scripts, anything). +## Running tests + +Install the test dependencies and run the suite: + + bundle install + bundle exec rake test + +`bundle exec rake` also runs the tests. The test bundle uses Smart Proxy's +`develop` branch by default; set `SMART_PROXY_BRANCH` to test against another +branch. CI uses Foreman's shared Smart Proxy plugin workflow. + ## Installation Install the plugin using the foreman-installer. Never enable this service via HTTP endpoint, only HTTPS with authentication using client certificate and with trusted hosts should be used. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..3a81109 --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rake' +require 'rake/testtask' + +desc 'Default: run unit tests.' +task default: :test + +desc 'Test the Smart Proxy Shell Hooks plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << '.' + t.libs << 'lib' + t.libs << 'test' + t.test_files = FileList['test/**/*_test.rb'] +end diff --git a/smart_proxy_shellhooks.gemspec b/smart_proxy_shellhooks.gemspec index 72bb3ee..906c613 100644 --- a/smart_proxy_shellhooks.gemspec +++ b/smart_proxy_shellhooks.gemspec @@ -13,4 +13,5 @@ Gem::Specification.new do |s| s.files = Dir['{lib,settings.d,bundler.d,examples}/**/*'] + s.extra_rdoc_files s.homepage = 'http://github.com/theforeman/smart_proxy_shellhooks' s.license = 'GPL-3.0-or-later' + s.required_ruby_version = '>= 3.0' end diff --git a/test/test_helper.rb b/test/test_helper.rb index d41baf3..82dc991 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,5 +3,9 @@ require 'test/unit' require 'mocha/test_unit' require 'smart_proxy_for_testing' + +# Keep test logs in the plugin directory, as in other Smart Proxy plugins. +FileUtils.mkdir_p File.dirname(Proxy::SETTINGS.log_file) + require 'smart_proxy_shellhooks' require 'smart_proxy_shellhooks/shellhooks_api'