diff --git a/script/load_test b/script/load_test new file mode 100755 index 0000000..b05b614 --- /dev/null +++ b/script/load_test @@ -0,0 +1,105 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +# Configuration +TARGET_URL = "http://0.0.0.0:8080/webhooks/hello" +REQUEST_COUNT = 10_000 # Total number of requests to send +EMPTY_JSON_BODY = "{}" + +# Parse the target URL +uri = URI.parse(TARGET_URL) + +# Initialize statistics tracking +response_times = [] +success_count = 0 +error_count = 0 + +puts "Starting load test..." +puts "Target: #{TARGET_URL}" +puts "Requests: #{REQUEST_COUNT}" +puts "Payload: #{EMPTY_JSON_BODY}" +puts "" + +# Perform the load test +REQUEST_COUNT.times do |i| + start_time = Time.now + + begin + # Create HTTP connection + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = false if uri.scheme == "http" + + # Create POST request + request = Net::HTTP::Post.new(uri.path) + request["Content-Type"] = "application/json" + request.body = EMPTY_JSON_BODY + + # Send request and measure time + response = http.request(request) + end_time = Time.now + + response_time_ms = ((end_time - start_time) * 1000).round(2) + response_times << response_time_ms + + if response.code.to_i >= 200 && response.code.to_i < 300 + success_count += 1 + else + error_count += 1 + end + + # Progress indicator + if (i + 1) % 100 == 0 + puts "Completed #{i + 1}/#{REQUEST_COUNT} requests" + end + + rescue => e + end_time = Time.now + response_time_ms = ((end_time - start_time) * 1000).round(2) + response_times << response_time_ms + error_count += 1 + puts "Error on request #{i + 1}: #{e.message}" + end +end + +puts "" +puts "Load test completed!" +puts "" + +# Calculate statistics +if response_times.any? + sorted_times = response_times.sort + average_time = (response_times.sum / response_times.length).round(2) + min_time = sorted_times.first + max_time = sorted_times.last + median_time = if sorted_times.length.odd? + sorted_times[sorted_times.length / 2] + else + ((sorted_times[sorted_times.length / 2 - 1] + sorted_times[sorted_times.length / 2]) / 2.0).round(2) + end + + # Calculate percentiles + p95_index = (sorted_times.length * 0.95).ceil - 1 + p99_index = (sorted_times.length * 0.99).ceil - 1 + p95_time = sorted_times[p95_index] + p99_time = sorted_times[p99_index] + + puts "=== RESULTS SUMMARY ===" + puts "Total requests: #{REQUEST_COUNT}" + puts "Successful requests: #{success_count}" + puts "Failed requests: #{error_count}" + puts "Success rate: #{((success_count.to_f / REQUEST_COUNT) * 100).round(2)}%" + puts "" + puts "=== RESPONSE TIME STATISTICS (ms) ===" + puts "Average: #{average_time} ms" + puts "Minimum: #{min_time} ms" + puts "Maximum: #{max_time} ms" + puts "Median: #{median_time} ms" + puts "95th percentile: #{p95_time} ms" + puts "99th percentile: #{p99_time} ms" +else + puts "No response times recorded!" +end diff --git a/spec/acceptance/config/endpoints/hello.yml b/spec/acceptance/config/endpoints/hello.yml new file mode 100644 index 0000000..13c4a32 --- /dev/null +++ b/spec/acceptance/config/endpoints/hello.yml @@ -0,0 +1,2 @@ +path: /hello +handler: Hello diff --git a/spec/acceptance/config/puma.rb b/spec/acceptance/config/puma.rb index c5cb415..6f645ac 100644 --- a/spec/acceptance/config/puma.rb +++ b/spec/acceptance/config/puma.rb @@ -4,9 +4,12 @@ require "json" bind "tcp://0.0.0.0:8080" + # single mode: https://github.com/puma/puma/blob/master/docs/deployment.md#single-vs-cluster-mode workers 0 +threads 0, 16 # the default + log_formatter do |msg| timestamp = Time.now.strftime("%Y-%m-%dT%H:%M:%S.%L%z") { diff --git a/spec/acceptance/plugins/handlers/hello.rb b/spec/acceptance/plugins/handlers/hello.rb new file mode 100644 index 0000000..9ea840c --- /dev/null +++ b/spec/acceptance/plugins/handlers/hello.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Hello < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + { + status: "success", + handler: self.class.name, + timestamp: Time.now.iso8601 + } + end +end diff --git a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb index 9e8b01c..fa12174 100644 --- a/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/hmac_spec.rb @@ -70,7 +70,8 @@ def valid_with(args = {}) auth: { header: header, algorithm: "sha256", - format: "signature_only" + format: "signature_only", + secret_env_key: "HMAC_TEST_SECRET" } } end @@ -78,7 +79,7 @@ def valid_with(args = {}) let(:headers) { { header => signature } } it "returns true for a valid hash-only signature" do - # TODO + expect(valid_with(headers:, config:)).to be true end it "returns false for an invalid hash-only signature" do @@ -104,13 +105,14 @@ def valid_with(args = {}) format: "version=signature", version_prefix: "v0", payload_template: payload_template, - timestamp_tolerance: 300 + timestamp_tolerance: 300, + secret_env_key: "HMAC_TEST_SECRET" } } end it "returns true for a valid versioned signature with valid timestamp" do - # TODO + expect(valid_with(headers:, config:)).to be true end it "returns false for an expired timestamp" do @@ -153,10 +155,10 @@ def valid_with(args = {}) context "with missing config values" do let(:headers) { { "X-Signature" => "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, payload) } } - let(:config) { {} } + let(:config) { { auth: { secret_env_key: "HMAC_TEST_SECRET" } } } it "uses defaults and validates correctly" do - # TODO + expect(valid_with(headers:, config:)).to be true end end @@ -404,7 +406,8 @@ def valid_with(args = {}) format: "version=signature", version_prefix: "v0", payload_template: "v0:{timestamp}:{body}", - timestamp_tolerance: 300 + timestamp_tolerance: 300, + secret_env_key: "HMAC_TEST_SECRET" } } end @@ -464,7 +467,14 @@ def valid_with(args = {}) end it "returns true when timestamp header name case differs due to normalization" do - # TODO + timestamp = Time.now.to_i.to_s + signing_payload = "v0:#{timestamp}:#{payload}" + signature = "v0=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + + # Use uppercase timestamp header name in the request headers + headers = { header => signature, timestamp_header.upcase => timestamp } + + expect(valid_with(headers:, config: base_config)).to be true end end