Skip to content

Add comprehensive test coverage for failure scenarios in plugin system #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/hooks/core/plugin_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def load_custom_auth_plugins(auth_plugin_dir)
Dir.glob(File.join(auth_plugin_dir, "*.rb")).sort.each do |file_path|
begin
load_custom_auth_plugin(file_path, auth_plugin_dir)
rescue => e
rescue StandardError, SyntaxError => e
raise StandardError, "Failed to load auth plugin from #{file_path}: #{e.message}"
Comment on lines 124 to 127
Copy link
Preview

Copilot AI Jun 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The same rescue/raise pattern is duplicated across multiple plugin-loading methods. Consider extracting this into a helper (e.g. handle_load_error(file_path, &block)) to reduce duplication and make future changes easier.

Copilot uses AI. Check for mistakes.

end
end
Expand All @@ -139,7 +139,7 @@ def load_custom_handler_plugins(handler_plugin_dir)
Dir.glob(File.join(handler_plugin_dir, "*.rb")).sort.each do |file_path|
begin
load_custom_handler_plugin(file_path, handler_plugin_dir)
rescue => e
rescue StandardError, SyntaxError => e
raise StandardError, "Failed to load handler plugin from #{file_path}: #{e.message}"
end
end
Expand All @@ -155,7 +155,7 @@ def load_custom_lifecycle_plugins(lifecycle_plugin_dir)
Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path|
begin
load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
rescue => e
rescue StandardError, SyntaxError => e
raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}"
end
end
Expand All @@ -171,7 +171,7 @@ def load_custom_instrument_plugins(instruments_plugin_dir)
Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path|
begin
load_custom_instrument_plugin(file_path, instruments_plugin_dir)
rescue => e
rescue StandardError, SyntaxError => e
raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}"
end
end
Expand Down
13 changes: 13 additions & 0 deletions spec/unit/lib/hooks/app/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,19 @@ def error!(message, code)
end
end

describe "#safe_json_parse" do
it "raises ArgumentError when JSON payload exceeds size limit" do
# Test the actual size limit by temporarily setting a small limit
stub_const("ENV", ENV.to_h.merge("JSON_MAX_SIZE" => "10"))
Copy link
Preview

Copilot AI Jun 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Stubbing the entire ENV constant can have unintended side effects. Consider using allow(ENV).to receive(:[]).with("JSON_MAX_SIZE").and_return("10") or a dedicated environment‐variable helper (e.g. the climate_control gem) to isolate this change.

Suggested change
stub_const("ENV", ENV.to_h.merge("JSON_MAX_SIZE" => "10"))
allow(ENV).to receive(:[]).with("JSON_MAX_SIZE").and_return("10")

Copilot uses AI. Check for mistakes.


large_json = '{"data": "' + "x" * 20 + '"}'

expect {
helper.send(:safe_json_parse, large_json)
}.to raise_error(ArgumentError, "JSON payload too large for parsing")
end
end

describe "#determine_error_code" do
it "returns 400 for ArgumentError" do
error = ArgumentError.new("bad argument")
Expand Down
16 changes: 16 additions & 0 deletions spec/unit/lib/hooks/core/builder_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative "../../../spec_helper"

describe Hooks::Core::Builder do
let(:log) { instance_double(Logger).as_null_object }
let(:temp_dir) { "/tmp/hooks_builder_test" }
Expand Down Expand Up @@ -272,6 +274,20 @@
}.to raise_error(Hooks::Core::ConfigurationError,
"Endpoint validation failed: Invalid endpoint")
end

it "raises ConfigurationError when plugin loading fails" do
allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ endpoints_dir: "/test" })
allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ endpoints_dir: "/test" })
allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([])
allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([])
allow(Hooks::Core::PluginLoader).to receive(:load_all_plugins)
.and_raise(StandardError, "Plugin loading error")

expect {
builder.build
}.to raise_error(Hooks::Core::ConfigurationError,
"Plugin loading failed: Plugin loading error")
end
end
end

Expand Down
248 changes: 248 additions & 0 deletions spec/unit/lib/hooks/core/plugin_loader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,252 @@ def call(payload:, headers:, config:)
)
end
end

describe "failure scenarios" do
describe "auth plugin loading failures" do
it "raises error when auth plugin file fails to load" do
temp_auth_dir = File.join(temp_dir, "auth_failures")
FileUtils.mkdir_p(temp_auth_dir)

# Create a malformed Ruby file
malformed_file = File.join(temp_auth_dir, "broken_auth.rb")
File.write(malformed_file, "class BrokenAuth\n # Missing end statement")

expect {
described_class.load_all_plugins({ auth_plugin_dir: temp_auth_dir })
}.to raise_error(StandardError, /Failed to load auth plugin from.*broken_auth\.rb/)
end

it "raises error for auth plugin path traversal attempt" do
temp_auth_dir = File.join(temp_dir, "auth_secure")
FileUtils.mkdir_p(temp_auth_dir)

# Create a plugin file outside the auth directory
outside_file = File.join(temp_dir, "outside_auth.rb")
File.write(outside_file, "# Outside file")

expect {
described_class.send(:load_custom_auth_plugin, outside_file, temp_auth_dir)
}.to raise_error(SecurityError, /Auth plugin path outside of auth plugin directory/)
end

it "raises error for invalid auth plugin class name" do
temp_auth_dir = File.join(temp_dir, "auth_invalid")
FileUtils.mkdir_p(temp_auth_dir)

# Create plugin with invalid class name
invalid_file = File.join(temp_auth_dir, "file.rb")
File.write(invalid_file, "# File with dangerous class name")

expect {
described_class.send(:load_custom_auth_plugin, invalid_file, temp_auth_dir)
}.to raise_error(StandardError, /Invalid auth plugin class name: File/)
end

it "raises error when auth plugin doesn't inherit from correct base class" do
temp_auth_dir = File.join(temp_dir, "auth_inheritance")
FileUtils.mkdir_p(temp_auth_dir)

# Create plugin with wrong inheritance
wrong_file = File.join(temp_auth_dir, "wrong_auth.rb")
File.write(wrong_file, <<~RUBY)
module Hooks
module Plugins
module Auth
class WrongAuth
def self.valid?(payload:, headers:, config:)
true
end
end
end
end
end
RUBY

expect {
described_class.send(:load_custom_auth_plugin, wrong_file, temp_auth_dir)
}.to raise_error(StandardError, /Auth plugin class must inherit from Hooks::Plugins::Auth::Base/)
end
end

describe "handler plugin loading failures" do
it "raises error when handler plugin file fails to load" do
temp_handler_dir = File.join(temp_dir, "handler_failures")
FileUtils.mkdir_p(temp_handler_dir)

# Create a malformed Ruby file
malformed_file = File.join(temp_handler_dir, "broken_handler.rb")
File.write(malformed_file, "class BrokenHandler\n # Missing end statement")

expect {
described_class.load_all_plugins({ handler_plugin_dir: temp_handler_dir })
}.to raise_error(StandardError, /Failed to load handler plugin from.*broken_handler\.rb/)
end

it "raises error for handler plugin path traversal attempt" do
temp_handler_dir = File.join(temp_dir, "handler_secure")
FileUtils.mkdir_p(temp_handler_dir)

# Create a plugin file outside the handler directory
outside_file = File.join(temp_dir, "outside_handler.rb")
File.write(outside_file, "# Outside file")

expect {
described_class.send(:load_custom_handler_plugin, outside_file, temp_handler_dir)
}.to raise_error(SecurityError, /Handler plugin path outside of handler plugin directory/)
end

it "raises error for invalid handler plugin class name" do
temp_handler_dir = File.join(temp_dir, "handler_invalid")
FileUtils.mkdir_p(temp_handler_dir)

# Create plugin with invalid class name
invalid_file = File.join(temp_handler_dir, "file.rb")
File.write(invalid_file, "# File with dangerous class name")

expect {
described_class.send(:load_custom_handler_plugin, invalid_file, temp_handler_dir)
}.to raise_error(StandardError, /Invalid handler class name: File/)
end

it "raises error when handler plugin doesn't inherit from correct base class" do
temp_handler_dir = File.join(temp_dir, "handler_inheritance")
FileUtils.mkdir_p(temp_handler_dir)

# Create plugin with wrong inheritance
wrong_file = File.join(temp_handler_dir, "wrong_handler.rb")
File.write(wrong_file, <<~RUBY)
class WrongHandler
def call(payload:, headers:, config:)
{ message: "wrong handler" }
end
end
RUBY

expect {
described_class.send(:load_custom_handler_plugin, wrong_file, temp_handler_dir)
}.to raise_error(StandardError, /Handler class must inherit from Hooks::Plugins::Handlers::Base/)
end
end

describe "lifecycle plugin loading failures" do
it "raises error when lifecycle plugin file fails to load" do
temp_lifecycle_dir = File.join(temp_dir, "lifecycle_failures")
FileUtils.mkdir_p(temp_lifecycle_dir)

# Create a malformed Ruby file
malformed_file = File.join(temp_lifecycle_dir, "broken_lifecycle.rb")
File.write(malformed_file, "class BrokenLifecycle\n # Missing end statement")

expect {
described_class.load_all_plugins({ lifecycle_plugin_dir: temp_lifecycle_dir })
}.to raise_error(StandardError, /Failed to load lifecycle plugin from.*broken_lifecycle\.rb/)
end

it "raises error for lifecycle plugin path traversal attempt" do
temp_lifecycle_dir = File.join(temp_dir, "lifecycle_secure")
FileUtils.mkdir_p(temp_lifecycle_dir)

# Create a plugin file outside the lifecycle directory
outside_file = File.join(temp_dir, "outside_lifecycle.rb")
File.write(outside_file, "# Outside file")

expect {
described_class.send(:load_custom_lifecycle_plugin, outside_file, temp_lifecycle_dir)
}.to raise_error(SecurityError, /Lifecycle plugin path outside of lifecycle plugin directory/)
end

it "raises error for invalid lifecycle plugin class name" do
temp_lifecycle_dir = File.join(temp_dir, "lifecycle_invalid")
FileUtils.mkdir_p(temp_lifecycle_dir)

# Create plugin with invalid class name
invalid_file = File.join(temp_lifecycle_dir, "file.rb")
File.write(invalid_file, "# File with dangerous class name")

expect {
described_class.send(:load_custom_lifecycle_plugin, invalid_file, temp_lifecycle_dir)
}.to raise_error(StandardError, /Invalid lifecycle plugin class name: File/)
end

it "raises error when lifecycle plugin doesn't inherit from correct base class" do
temp_lifecycle_dir = File.join(temp_dir, "lifecycle_inheritance")
FileUtils.mkdir_p(temp_lifecycle_dir)

# Create plugin with wrong inheritance
wrong_file = File.join(temp_lifecycle_dir, "wrong_lifecycle.rb")
File.write(wrong_file, <<~RUBY)
class WrongLifecycle
def on_request(env)
# Wrong base class
end
end
RUBY

expect {
described_class.send(:load_custom_lifecycle_plugin, wrong_file, temp_lifecycle_dir)
}.to raise_error(StandardError, /Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle/)
end
end

describe "instrument plugin loading failures" do
it "raises error when instrument plugin file fails to load" do
temp_instrument_dir = File.join(temp_dir, "instrument_failures")
FileUtils.mkdir_p(temp_instrument_dir)

# Create a malformed Ruby file
malformed_file = File.join(temp_instrument_dir, "broken_instrument.rb")
File.write(malformed_file, "class BrokenInstrument\n # Missing end statement")

expect {
described_class.load_all_plugins({ instruments_plugin_dir: temp_instrument_dir })
}.to raise_error(StandardError, /Failed to load instrument plugin from.*broken_instrument\.rb/)
end

it "raises error for instrument plugin path traversal attempt" do
temp_instrument_dir = File.join(temp_dir, "instrument_secure")
FileUtils.mkdir_p(temp_instrument_dir)

# Create a plugin file outside the instrument directory
outside_file = File.join(temp_dir, "outside_instrument.rb")
File.write(outside_file, "# Outside file")

expect {
described_class.send(:load_custom_instrument_plugin, outside_file, temp_instrument_dir)
}.to raise_error(SecurityError, /Instrument plugin path outside of instruments plugin directory/)
end

it "raises error for invalid instrument plugin class name" do
temp_instrument_dir = File.join(temp_dir, "instrument_invalid")
FileUtils.mkdir_p(temp_instrument_dir)

# Create plugin with invalid class name
invalid_file = File.join(temp_instrument_dir, "file.rb")
File.write(invalid_file, "# File with dangerous class name")

expect {
described_class.send(:load_custom_instrument_plugin, invalid_file, temp_instrument_dir)
}.to raise_error(StandardError, /Invalid instrument plugin class name: File/)
end

it "raises error when instrument plugin doesn't inherit from correct base class" do
temp_instrument_dir = File.join(temp_dir, "instrument_inheritance")
FileUtils.mkdir_p(temp_instrument_dir)

# Create plugin with wrong inheritance
wrong_file = File.join(temp_instrument_dir, "wrong_instrument.rb")
File.write(wrong_file, <<~RUBY)
class WrongInstrument
def record(metric_name, value, tags = {})
# Wrong base class
end
end
RUBY

expect {
described_class.send(:load_custom_instrument_plugin, wrong_file, temp_instrument_dir)
}.to raise_error(StandardError, /Instrument plugin class must inherit from StatsBase or FailbotBase/)
end
end
end
end
2 changes: 2 additions & 0 deletions spec/unit/lib/hooks/plugins/lifecycle_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative "../../../spec_helper"

describe Hooks::Plugins::Lifecycle do
let(:plugin) { described_class.new }
let(:env) { { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/webhook" } }
Expand Down