diff --git a/.gitignore b/.gitignore index 81e89806..ef2c03b9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ tmp/ spec/integration/tmp/ tarballs/ vendor/gems/ +vendor/bundle/ .idea .byebug_history .local/ diff --git a/spec/unit/lib/hooks/app/endpoints/health_spec.rb b/spec/unit/lib/hooks/app/endpoints/health_spec.rb new file mode 100644 index 00000000..1d5d8269 --- /dev/null +++ b/spec/unit/lib/hooks/app/endpoints/health_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rack/test" + +describe Hooks::App::HealthEndpoint do + include Rack::Test::Methods + + def app + described_class + end + + before do + # Mock API start_time for consistent uptime calculation + allow(Hooks::App::API).to receive(:start_time).and_return(Time.parse("2024-12-31T23:59:00Z")) + end + + describe "GET /" do + it "returns health status as JSON" do + get "/" + + expect(last_response.status).to eq(200) + expect(last_response.headers["Content-Type"]).to include("application/json") + end + + it "includes health status in response" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["status"]).to eq("healthy") + end + + it "includes timestamp in ISO8601 format" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["timestamp"]).to eq(TIME_MOCK) + end + + it "includes version information" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["version"]).to eq(Hooks::VERSION) + end + + it "includes uptime in seconds" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["uptime_seconds"]).to be_a(Integer) + expect(response_data["uptime_seconds"]).to eq(60) # 1 minute difference + end + + it "returns valid JSON structure" do + get "/" + + expect { JSON.parse(last_response.body) }.not_to raise_error + + response_data = JSON.parse(last_response.body) + expect(response_data).to have_key("status") + expect(response_data).to have_key("timestamp") + expect(response_data).to have_key("version") + expect(response_data).to have_key("uptime_seconds") + end + + it "calculates uptime correctly" do + # Test with different start time + different_start = Time.parse("2024-12-31T23:58:30Z") + allow(Hooks::App::API).to receive(:start_time).and_return(different_start) + + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["uptime_seconds"]).to eq(90) # 1.5 minutes difference + end + end +end diff --git a/spec/unit/lib/hooks/app/endpoints/version_spec.rb b/spec/unit/lib/hooks/app/endpoints/version_spec.rb new file mode 100644 index 00000000..3d5e18d3 --- /dev/null +++ b/spec/unit/lib/hooks/app/endpoints/version_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rack/test" + +describe Hooks::App::VersionEndpoint do + include Rack::Test::Methods + + def app + described_class + end + + describe "GET /" do + it "returns version information as JSON" do + get "/" + + expect(last_response.status).to eq(200) + expect(last_response.headers["Content-Type"]).to include("application/json") + end + + it "includes version number in response" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["version"]).to eq(Hooks::VERSION) + end + + it "includes timestamp in ISO8601 format" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["timestamp"]).to eq(TIME_MOCK) + end + + it "returns valid JSON structure" do + get "/" + + expect { JSON.parse(last_response.body) }.not_to raise_error + + response_data = JSON.parse(last_response.body) + expect(response_data).to have_key("version") + expect(response_data).to have_key("timestamp") + end + + it "version matches expected format" do + get "/" + + response_data = JSON.parse(last_response.body) + expect(response_data["version"]).to match(/^\d+\.\d+\.\d+$/) + end + end +end diff --git a/spec/unit/lib/hooks/app/helpers_spec.rb b/spec/unit/lib/hooks/app/helpers_spec.rb new file mode 100644 index 00000000..329f3a6a --- /dev/null +++ b/spec/unit/lib/hooks/app/helpers_spec.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +require "tempfile" + +describe Hooks::App::Helpers do + let(:test_class) do + Class.new do + include Hooks::App::Helpers + + attr_accessor :headers, :env, :request_obj + + def headers + @headers ||= {} + end + + def env + @env ||= {} + end + + def request + @request_obj + end + + def error!(message, code) + raise StandardError, "#{code}: #{message}" + end + end + end + + let(:helper) { test_class.new } + + describe "#uuid" do + it "generates a valid UUID" do + result = helper.uuid + + expect(result).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + + it "generates unique UUIDs on each call" do + uuid1 = helper.uuid + uuid2 = helper.uuid + + expect(uuid1).not_to eq(uuid2) + end + end + + describe "#enforce_request_limits" do + let(:config) { { request_limit: 1000 } } + + context "with content-length in headers" do + it "passes when content length is within limit" do + helper.headers["Content-Length"] = "500" + + expect { helper.enforce_request_limits(config) }.not_to raise_error + end + + it "raises error when content length exceeds limit" do + helper.headers["Content-Length"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + end + + context "with different header formats" do + it "handles uppercase CONTENT_LENGTH" do + helper.headers["CONTENT_LENGTH"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + + it "handles lowercase content-length" do + helper.headers["content-length"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + + it "handles HTTP_CONTENT_LENGTH" do + helper.headers["HTTP_CONTENT_LENGTH"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + end + + context "with content-length in env" do + it "uses env CONTENT_LENGTH when headers are empty" do + helper.env["CONTENT_LENGTH"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + + it "uses env HTTP_CONTENT_LENGTH when headers are empty" do + helper.env["HTTP_CONTENT_LENGTH"] = "1500" + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + end + + context "with request object" do + it "uses request.content_length when available" do + request_mock = double("request") + allow(request_mock).to receive(:content_length).and_return(1500) + helper.request_obj = request_mock + + expect { helper.enforce_request_limits(config) }.to raise_error(StandardError, /413.*too large/) + end + end + + context "without content length information" do + it "passes when no content length is available" do + expect { helper.enforce_request_limits(config) }.not_to raise_error + end + end + end + + describe "#parse_payload" do + context "with JSON content" do + it "parses valid JSON with application/json content type" do + headers = { "Content-Type" => "application/json" } + body = '{"key": "value"}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ key: "value" }) + end + + it "parses JSON that looks like JSON without content type" do + headers = {} + body = '{"key": "value"}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ key: "value" }) + end + + it "parses JSON arrays" do + headers = {} + body = '[{"key": "value"}]' + + result = helper.parse_payload(body, headers) + + expect(result).to eq([{ "key" => "value" }]) + end + + it "symbolizes keys by default" do + headers = { "Content-Type" => "application/json" } + body = '{"string_key": "value", "nested": {"inner_key": "inner_value"}}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ + string_key: "value", + nested: { "inner_key" => "inner_value" } # Only top level is symbolized + }) + end + + it "does not symbolize keys when symbolize is false" do + headers = { "Content-Type" => "application/json" } + body = '{"string_key": "value"}' + + result = helper.parse_payload(body, headers, symbolize: false) + + expect(result).to eq({ "string_key" => "value" }) + end + end + + context "with different content type headers" do + it "handles uppercase CONTENT_TYPE" do + headers = { "CONTENT_TYPE" => "application/json" } + body = '{"key": "value"}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ key: "value" }) + end + + it "handles lowercase content-type" do + headers = { "content-type" => "application/json" } + body = '{"key": "value"}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ key: "value" }) + end + + it "handles HTTP_CONTENT_TYPE" do + headers = { "HTTP_CONTENT_TYPE" => "application/json" } + body = '{"key": "value"}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq({ key: "value" }) + end + end + + context "with invalid JSON" do + it "returns raw body when JSON parsing fails" do + headers = { "Content-Type" => "application/json" } + body = '{"invalid": json}' + + result = helper.parse_payload(body, headers) + + expect(result).to eq(body) + end + end + + context "with non-JSON content" do + it "returns raw body for plain text" do + headers = { "Content-Type" => "text/plain" } + body = "plain text content" + + result = helper.parse_payload(body, headers) + + expect(result).to eq(body) + end + + it "returns raw body for XML" do + headers = { "Content-Type" => "application/xml" } + body = "content" + + result = helper.parse_payload(body, headers) + + expect(result).to eq(body) + end + end + end + + describe "#valid_handler_class_name?" do + it "returns true for valid handler class names" do + valid_names = ["MyHandler", "GitHubHandler", "Team1Handler", "APIHandler"] + + valid_names.each do |name| + expect(helper.send(:valid_handler_class_name?, name)).to be true + end + end + + it "returns false for non-string input" do + expect(helper.send(:valid_handler_class_name?, nil)).to be false + expect(helper.send(:valid_handler_class_name?, 123)).to be false + expect(helper.send(:valid_handler_class_name?, [])).to be false + end + + it "returns false for empty or whitespace-only strings" do + expect(helper.send(:valid_handler_class_name?, "")).to be false + expect(helper.send(:valid_handler_class_name?, " ")).to be false + expect(helper.send(:valid_handler_class_name?, "\t")).to be false + end + + it "returns false for class names not starting with uppercase" do + expect(helper.send(:valid_handler_class_name?, "myHandler")).to be false + expect(helper.send(:valid_handler_class_name?, "handler")).to be false + expect(helper.send(:valid_handler_class_name?, "123Handler")).to be false + end + + it "returns false for class names with invalid characters" do + expect(helper.send(:valid_handler_class_name?, "My-Handler")).to be false + expect(helper.send(:valid_handler_class_name?, "My.Handler")).to be false + expect(helper.send(:valid_handler_class_name?, "My Handler")).to be false + expect(helper.send(:valid_handler_class_name?, "My/Handler")).to be false + end + + it "returns false for dangerous class names" do + Hooks::Security::DANGEROUS_CLASSES.each do |dangerous_class| + expect(helper.send(:valid_handler_class_name?, dangerous_class)).to be false + end + end + end + + describe "#determine_error_code" do + it "returns 400 for ArgumentError" do + error = ArgumentError.new("bad argument") + + expect(helper.send(:determine_error_code, error)).to eq(400) + end + + it "returns 501 for NotImplementedError" do + error = NotImplementedError.new("not implemented") + + expect(helper.send(:determine_error_code, error)).to eq(501) + end + + it "returns 500 for other errors" do + error = StandardError.new("generic error") + + expect(helper.send(:determine_error_code, error)).to eq(500) + end + + it "returns 500 for RuntimeError" do + error = RuntimeError.new("runtime error") + + expect(helper.send(:determine_error_code, error)).to eq(500) + end + end + + describe "#load_handler" do + let(:temp_dir) { Dir.mktmpdir } + let(:handler_class_name) { "TestHandler" } + + after do + FileUtils.rm_rf(temp_dir) + end + + context "with valid handler" do + it "loads and instantiates a valid handler" do + # Create a test handler file + handler_content = <<~RUBY + class TestHandler < Hooks::Handlers::Base + def call(payload:, headers:, config:) + { status: "ok" } + end + end + RUBY + + File.write(File.join(temp_dir, "test_handler.rb"), handler_content) + + result = helper.load_handler(handler_class_name, temp_dir) + + expect(result).to be_an_instance_of(TestHandler) + expect(result).to respond_to(:call) + end + end + + context "with invalid handler class name" do + it "raises error for invalid class name" do + expect { helper.load_handler("invalid-name", temp_dir) }.to raise_error(StandardError, /400.*invalid handler class name/) + end + + it "raises error for dangerous class name" do + expect { helper.load_handler("File", temp_dir) }.to raise_error(StandardError, /400.*invalid handler class name/) + end + end + + context "with path traversal attempts" do + it "raises error for path traversal" do + expect { helper.load_handler("../../../EvilHandler", temp_dir) }.to raise_error(StandardError, /400.*invalid handler class name/) + end + end + + context "with missing handler file" do + it "raises LoadError when handler file does not exist" do + expect { helper.load_handler("MissingHandler", temp_dir) }.to raise_error(LoadError, /Handler MissingHandler not found/) + end + end + + context "with handler that doesn't inherit from Base" do + it "raises error when handler doesn't inherit from Base" do + # Create a handler that doesn't inherit from Base + handler_content = <<~RUBY + class BadHandler + def call(payload:, headers:, config:) + { status: "ok" } + end + end + RUBY + + File.write(File.join(temp_dir, "bad_handler.rb"), handler_content) + + expect { helper.load_handler("BadHandler", temp_dir) }.to raise_error(StandardError, /400.*must inherit from Hooks::Handlers::Base/) + end + end + + context "with handler file that has syntax errors" do + it "raises SyntaxError when handler file has syntax errors" do + # Create a handler with syntax errors + handler_content = "class SyntaxErrorHandler < Hooks::Handlers::Base\n def call\n {invalid syntax\n end\nend" + + File.write(File.join(temp_dir, "syntax_error_handler.rb"), handler_content) + + expect { helper.load_handler("SyntaxErrorHandler", temp_dir) }.to raise_error(SyntaxError) + end + end + end +end diff --git a/spec/unit/lib/hooks/core/log_spec.rb b/spec/unit/lib/hooks/core/log_spec.rb new file mode 100644 index 00000000..3dc38044 --- /dev/null +++ b/spec/unit/lib/hooks/core/log_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +describe Hooks::Log do + describe ".instance" do + it "can be set and retrieved" do + logger = instance_double(Logger) + described_class.instance = logger + + expect(described_class.instance).to eq(logger) + end + + it "can be set to nil" do + described_class.instance = nil + + expect(described_class.instance).to be_nil + end + + it "maintains the same instance when set" do + logger = instance_double(Logger) + described_class.instance = logger + + expect(described_class.instance).to be(logger) + end + + it "can be overridden" do + first_logger = instance_double(Logger) + second_logger = instance_double(Logger) + + described_class.instance = first_logger + expect(described_class.instance).to eq(first_logger) + + described_class.instance = second_logger + expect(described_class.instance).to eq(second_logger) + end + end +end diff --git a/spec/unit/lib/hooks/handlers/default_spec.rb b/spec/unit/lib/hooks/handlers/default_spec.rb new file mode 100644 index 00000000..f7066e74 --- /dev/null +++ b/spec/unit/lib/hooks/handlers/default_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +describe DefaultHandler do + let(:log) { instance_double(Logger).as_null_object } + let(:payload) { { "action" => "opened", "number" => 1 } } + let(:headers) { { "X-GitHub-Event" => "pull_request" } } + let(:config) { { environment: "test" } } + let(:handler) { described_class.new } + + before do + allow(handler).to receive(:log).and_return(log) + end + + describe "#call" do + context "with valid parameters" do + let(:result) { handler.call(payload:, headers:, config:) } + + it "logs that the default handler was invoked" do + result + + expect(log).to have_received(:info).with("🔔 Default handler invoked for webhook 🔔") + end + + it "logs payload debug information when payload is present" do + result + + expect(log).to have_received(:debug).with("received payload: #{payload.inspect}") + end + + it "returns a success response hash" do + expect(result).to be_a(Hash) + expect(result).to include( + message: "webhook processed successfully", + handler: "DefaultHandler", + timestamp: TIME_MOCK + ) + end + + it "includes timestamp in ISO8601 format" do + expect(result[:timestamp]).to eq(TIME_MOCK) + end + end + + context "with nil payload" do + let(:payload) { nil } + let(:result) { handler.call(payload:, headers:, config:) } + + it "does not log payload debug information" do + result + + expect(log).not_to have_received(:debug) + end + + it "still returns a success response" do + expect(result).to include( + message: "webhook processed successfully", + handler: "DefaultHandler" + ) + end + end + + context "with empty payload" do + let(:payload) { {} } + let(:result) { handler.call(payload:, headers:, config:) } + + it "logs the empty payload" do + result + + expect(log).to have_received(:debug).with("received payload: #{payload.inspect}") + end + end + + context "with complex payload" do + let(:payload) do + { + "action" => "opened", + "pull_request" => { + "id" => 123, + "title" => "Test PR", + "body" => "This is a test" + } + } + end + let(:result) { handler.call(payload:, headers:, config:) } + + it "logs the complex payload structure" do + result + + expect(log).to have_received(:debug).with("received payload: #{payload.inspect}") + end + end + end + + describe "inheritance" do + it "inherits from Hooks::Handlers::Base" do + expect(described_class.superclass).to eq(Hooks::Handlers::Base) + end + end +end diff --git a/spec/unit/lib/hooks/security_spec.rb b/spec/unit/lib/hooks/security_spec.rb new file mode 100644 index 00000000..1065c014 --- /dev/null +++ b/spec/unit/lib/hooks/security_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +describe Hooks::Security do + describe "DANGEROUS_CLASSES" do + it "is frozen to prevent modification" do + expect(described_class::DANGEROUS_CLASSES).to be_frozen + end + + it "contains system-access classes" do + expected_classes = %w[ + File Dir Kernel Object Class Module Proc Method + IO Socket TCPSocket UDPSocket BasicSocket + Process Thread Fiber Mutex ConditionVariable + Marshal YAML JSON Pathname + ] + + expect(described_class::DANGEROUS_CLASSES).to match_array(expected_classes) + end + + it "contains file system classes" do + expect(described_class::DANGEROUS_CLASSES).to include("File", "Dir", "Pathname") + end + + it "contains network classes" do + expect(described_class::DANGEROUS_CLASSES).to include("Socket", "TCPSocket", "UDPSocket", "BasicSocket") + end + + it "contains process control classes" do + expect(described_class::DANGEROUS_CLASSES).to include("Process", "Thread", "Fiber") + end + + it "contains serialization classes" do + expect(described_class::DANGEROUS_CLASSES).to include("Marshal", "YAML", "JSON") + end + + it "contains core Ruby classes that provide system access" do + expect(described_class::DANGEROUS_CLASSES).to include("Kernel", "Object", "Class", "Module") + end + + it "prevents empty string attacks" do + expect(described_class::DANGEROUS_CLASSES).not_to include("") + end + + it "prevents nil attacks" do + expect(described_class::DANGEROUS_CLASSES).not_to include(nil) + end + end +end