Skip to content

Commit e570056

Browse files
committed
Add acceptance tests
Containerized test setup using Docker Compose with a proxy container and two SSH targets. Tests make real HTTPS requests with certs.
1 parent 30660f5 commit e570056

29 files changed

+1504
-31
lines changed

.github/workflows/ci.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
name: CI
3+
4+
on:
5+
pull_request: {}
6+
push:
7+
branches:
8+
- main
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: ${{ github.ref_name }}-${{ github.workflow }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
test:
19+
name: Ruby
20+
uses: theforeman/actions/.github/workflows/smart_proxy_plugin.yml@v0
21+
22+
acceptance-ssh:
23+
name: Acceptance (SSH)
24+
runs-on: ubuntu-24.04
25+
steps:
26+
- uses: actions/checkout@v6
27+
- uses: ruby/setup-ruby@v1
28+
with:
29+
ruby-version: '3.2'
30+
bundler-cache: true
31+
- name: Start proxy and SSH targets
32+
run: bundle exec rake test:acceptance:ssh:up
33+
- name: Run SSH acceptance tests
34+
run: bundle exec rake test:acceptance:ssh
35+
- name: Stop containers
36+
if: always()
37+
run: bundle exec rake test:acceptance:ssh:down
38+
39+
tests:
40+
needs:
41+
- test
42+
- acceptance-ssh
43+
runs-on: ubuntu-latest
44+
name: Test suite
45+
steps:
46+
- run: echo Test suite completed

.github/workflows/main.yml

Lines changed: 0 additions & 28 deletions
This file was deleted.

.gitignore

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
**/.DS_Store
22
*.gem
3-
# Local testing files
4-
*.rpm
5-
*.sh
3+
# Local testing files (root-level only)
4+
/*.rpm
5+
/*.sh
66
/vendor
77
/.vendor
88
/Gemfile.lock
99
/.bundle
1010
/logs
11+
# Generated at runtime by acceptance tests
12+
/test/acceptance/fixtures/keys/
13+
/test/acceptance/docker/ssl-export/

Rakefile

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'ci/reporter/rake/test_unit'
4+
require 'fileutils'
45
require 'rake'
56
require 'rake/testtask'
67
require 'rubocop/rake_task'
@@ -20,6 +21,64 @@ Rake::TestTask.new(:test) do |t|
2021
t.verbose = true
2122
end
2223

24+
ACCEPTANCE_TEST_FILES = FileList['test/acceptance/tests/*_test.rb']
25+
DOCKER_COMPOSE = 'test/acceptance/docker/docker-compose.yml'
26+
SSH_KEY_PATH = 'test/acceptance/fixtures/keys/id_rsa'
27+
SSL_EXPORT_DIR = 'test/acceptance/docker/ssl-export'
28+
29+
Rake::TestTask.new('test:acceptance') do |task|
30+
task.libs << 'test'
31+
task.test_files = ACCEPTANCE_TEST_FILES
32+
task.options = '--verbose'
33+
task.verbose = true
34+
end
35+
36+
desc 'Run acceptance tests against SSH targets.'
37+
task 'test:acceptance:ssh' do
38+
ENV['ACCEPTANCE_TRANSPORT'] = 'ssh'
39+
Rake::Task['test:acceptance'].invoke
40+
end
41+
42+
namespace :test do
43+
namespace :acceptance do
44+
namespace :ssh do
45+
desc 'Start proxy and SSH target containers for acceptance tests.'
46+
task :up do
47+
unless File.exist?(SSH_KEY_PATH)
48+
FileUtils.mkdir_p(File.dirname(SSH_KEY_PATH))
49+
sh 'ssh-keygen', '-t', 'rsa', '-b', '2048', '-f', SSH_KEY_PATH, '-N', '', '-q'
50+
File.chmod(0600, SSH_KEY_PATH)
51+
end
52+
53+
FileUtils.mkdir_p(SSL_EXPORT_DIR)
54+
sh "docker compose -f #{DOCKER_COMPOSE} up -d --build"
55+
56+
# Wait for the proxy to be healthy (healthcheck polls /openbolt/tasks)
57+
puts 'Waiting for proxy to become healthy...'
58+
ready = false
59+
60.times do
60+
output = `docker compose -f #{DOCKER_COMPOSE} ps proxy --format json 2>&1`
61+
abort "docker compose failed: #{output.strip}" unless $?.success?
62+
if output.include?('"healthy"')
63+
ready = true
64+
break
65+
end
66+
sleep 2
67+
end
68+
abort "Proxy did not become healthy within 120s. Check: docker compose -f #{DOCKER_COMPOSE} logs proxy" unless ready
69+
puts 'Proxy is healthy and ready for acceptance tests.'
70+
end
71+
72+
desc 'Stop containers and clean up generated keys and certs.'
73+
task :down do
74+
system("docker compose -f #{DOCKER_COMPOSE} down")
75+
rm_rf File.dirname(SSH_KEY_PATH)
76+
rm_rf SSL_EXPORT_DIR
77+
end
78+
end
79+
end
80+
end
81+
2382
begin
2483
require 'rubygems'
2584
require 'github_changelog_generator/task'
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)