|
| 1 | +require 'test/unit' |
| 2 | +require 'net/http' |
| 3 | +require 'openssl' |
| 4 | +require 'json' |
| 5 | +require 'uri' |
| 6 | + |
| 7 | +# Base class for acceptance tests that make real HTTPS requests to a |
| 8 | +# smart-proxy instance running in a Docker container. |
| 9 | +class AcceptanceTestCase < Test::Unit::TestCase |
| 10 | + PROXY_HOST = ENV.fetch('PROXY_HOST', 'localhost') |
| 11 | + PROXY_PORT = ENV.fetch('PROXY_PORT', '8443').to_i |
| 12 | + SSL_DIR = ENV.fetch('SSL_DIR', File.expand_path('docker/ssl-export', __dir__)) |
| 13 | + |
| 14 | + def setup |
| 15 | + skip_unless_proxy |
| 16 | + end |
| 17 | + |
| 18 | + # --- HTTP client --- |
| 19 | + |
| 20 | + def http_client |
| 21 | + @http_client ||= build_http_client |
| 22 | + end |
| 23 | + |
| 24 | + def api_get(path) |
| 25 | + response = http_client.get("/openbolt#{path}") |
| 26 | + [response, parse_body(response)] |
| 27 | + end |
| 28 | + |
| 29 | + def api_post(path, body) |
| 30 | + request = Net::HTTP::Post.new("/openbolt#{path}") |
| 31 | + request['Content-Type'] = 'application/json' |
| 32 | + request.body = body.to_json |
| 33 | + response = http_client.request(request) |
| 34 | + [response, parse_body(response)] |
| 35 | + end |
| 36 | + |
| 37 | + def api_delete(path) |
| 38 | + response = http_client.delete("/openbolt#{path}") |
| 39 | + [response, parse_body(response)] |
| 40 | + end |
| 41 | + |
| 42 | + # --- Job lifecycle helpers --- |
| 43 | + |
| 44 | + def poll_job_status(job_id) |
| 45 | + response, parsed = api_get("/job/#{job_id}/status") |
| 46 | + assert response.is_a?(Net::HTTPSuccess), |
| 47 | + "GET /job/#{job_id}/status returned #{response.code}: #{response.body}" |
| 48 | + assert parsed.key?('status'), |
| 49 | + "Expected 'status' key in response, got: #{parsed}" |
| 50 | + parsed['status'] |
| 51 | + end |
| 52 | + |
| 53 | + def wait_for_job(job_id, timeout: 60) |
| 54 | + deadline = Time.now + timeout |
| 55 | + loop do |
| 56 | + status = poll_job_status(job_id) |
| 57 | + return status unless %w[pending running].include?(status) |
| 58 | + raise "Job #{job_id} did not complete within #{timeout}s" if Time.now > deadline |
| 59 | + sleep 0.5 |
| 60 | + end |
| 61 | + end |
| 62 | + |
| 63 | + def wait_for_jobs(job_ids, timeout: 60) |
| 64 | + deadline = Time.now + timeout |
| 65 | + results = {} |
| 66 | + remaining = job_ids.dup |
| 67 | + until remaining.empty? |
| 68 | + raise "Jobs #{remaining} did not complete within #{timeout}s" if Time.now > deadline |
| 69 | + remaining.each do |job_id| |
| 70 | + next if results.key?(job_id) |
| 71 | + status = poll_job_status(job_id) |
| 72 | + results[job_id] = status unless %w[pending running].include?(status) |
| 73 | + end |
| 74 | + remaining -= results.keys |
| 75 | + sleep 0.5 unless remaining.empty? |
| 76 | + end |
| 77 | + job_ids.map { |job_id| results[job_id] } |
| 78 | + end |
| 79 | + |
| 80 | + def launch_task(name:, targets:, parameters: {}, options: {}) |
| 81 | + payload = { |
| 82 | + 'name' => name, |
| 83 | + 'parameters' => parameters, |
| 84 | + 'targets' => targets, |
| 85 | + 'options' => options, |
| 86 | + } |
| 87 | + response, parsed = api_post('/launch/task', payload) |
| 88 | + assert response.is_a?(Net::HTTPSuccess), |
| 89 | + "launch_task failed (#{response.code}): #{response.body}" |
| 90 | + assert parsed.key?('id'), "Expected 'id' in launch response, got: #{parsed}" |
| 91 | + parsed['id'] |
| 92 | + end |
| 93 | + |
| 94 | + # --- Transport configuration --- |
| 95 | + # Bolt runs inside the proxy container and connects to targets by Docker |
| 96 | + # service name on port 22 (the container's internal port). |
| 97 | + |
| 98 | + def self.transport |
| 99 | + ENV['ACCEPTANCE_TRANSPORT'] |
| 100 | + end |
| 101 | + |
| 102 | + def transport_targets |
| 103 | + case self.class.transport |
| 104 | + when 'ssh' then ssh_targets |
| 105 | + else raise "Unknown transport '#{self.class.transport}'. Supported: ssh" |
| 106 | + end |
| 107 | + end |
| 108 | + |
| 109 | + def transport_options |
| 110 | + case self.class.transport |
| 111 | + when 'ssh' then ssh_options |
| 112 | + else raise "Unknown transport '#{self.class.transport}'. Supported: ssh" |
| 113 | + end |
| 114 | + end |
| 115 | + |
| 116 | + def ssh_targets |
| 117 | + %w[target1:22 target2:22] |
| 118 | + end |
| 119 | + |
| 120 | + def ssh_options |
| 121 | + { |
| 122 | + 'transport' => 'ssh', |
| 123 | + 'user' => 'openbolt', |
| 124 | + 'host-key-check' => false, |
| 125 | + 'private-key' => '/opt/foreman-proxy/.ssh/id_rsa', |
| 126 | + } |
| 127 | + end |
| 128 | + |
| 129 | + # --- Skip logic --- |
| 130 | + |
| 131 | + def self.proxy_available? |
| 132 | + return @proxy_available if instance_variable_defined?(:@proxy_available) |
| 133 | + require 'socket' |
| 134 | + TCPSocket.new(PROXY_HOST, PROXY_PORT).close |
| 135 | + @proxy_available = true |
| 136 | + rescue SystemCallError, SocketError => error |
| 137 | + @proxy_check_error = error |
| 138 | + @proxy_available = false |
| 139 | + end |
| 140 | + |
| 141 | + def skip_unless_proxy |
| 142 | + unless self.class.proxy_available? |
| 143 | + error = self.class.instance_variable_get(:@proxy_check_error) |
| 144 | + detail = " (#{error.class}: #{error.message})" if error |
| 145 | + omit("Proxy not reachable at #{PROXY_HOST}:#{PROXY_PORT}#{detail}") |
| 146 | + end |
| 147 | + omit('ACCEPTANCE_TRANSPORT not set') unless self.class.transport |
| 148 | + end |
| 149 | + |
| 150 | + private |
| 151 | + |
| 152 | + def build_http_client |
| 153 | + client = Net::HTTP.new(PROXY_HOST, PROXY_PORT) |
| 154 | + client.use_ssl = true |
| 155 | + client.open_timeout = 5 |
| 156 | + client.read_timeout = 60 |
| 157 | + |
| 158 | + # WEBrick is configured with SSLVerifyClient => VERIFY_PEER, which requests |
| 159 | + # a client certificate. Provide one to match production behavior. |
| 160 | + client_cert = File.join(SSL_DIR, 'client.pem') |
| 161 | + client_key = File.join(SSL_DIR, 'client-key.pem') |
| 162 | + ca_cert = File.join(SSL_DIR, 'ca.pem') |
| 163 | + raise "Client certs not found in #{SSL_DIR}. Are the containers running?" \ |
| 164 | + unless File.exist?(client_cert) && File.exist?(client_key) |
| 165 | + |
| 166 | + client.cert = OpenSSL::X509::Certificate.new(File.read(client_cert)) |
| 167 | + client.key = OpenSSL::PKey.read(File.read(client_key)) |
| 168 | + |
| 169 | + # Verify the proxy's certificate against the test CA when available, |
| 170 | + # otherwise fall back to no verification for self-signed certs. |
| 171 | + if File.exist?(ca_cert) |
| 172 | + client.verify_mode = OpenSSL::SSL::VERIFY_PEER |
| 173 | + client.ca_file = ca_cert |
| 174 | + else |
| 175 | + client.verify_mode = OpenSSL::SSL::VERIFY_NONE |
| 176 | + end |
| 177 | + |
| 178 | + client |
| 179 | + end |
| 180 | + |
| 181 | + def parse_body(response) |
| 182 | + JSON.parse(response.body) |
| 183 | + rescue JSON::ParserError => error |
| 184 | + flunk "Expected JSON response but got non-JSON body " \ |
| 185 | + "(HTTP #{response.code} #{response.message}): " \ |
| 186 | + "#{response.body.to_s.slice(0, 500)}\n" \ |
| 187 | + "Parse error: #{error.message}" |
| 188 | + end |
| 189 | +end |
0 commit comments