foreman_openbolt 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/Rakefile +32 -0
  4. data/app/controllers/api/v2/openbolt_jobs_controller.rb +77 -0
  5. data/app/controllers/api/v2/openbolt_tasks_controller.rb +83 -0
  6. data/app/controllers/concerns/foreman_openbolt/common.rb +121 -0
  7. data/app/controllers/concerns/foreman_openbolt/jobs.rb +84 -0
  8. data/app/controllers/concerns/foreman_openbolt/tasks.rb +140 -0
  9. data/app/controllers/foreman_openbolt/task_controller.rb +12 -291
  10. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +4 -2
  11. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +48 -29
  12. data/app/models/foreman_openbolt/task_job.rb +9 -2
  13. data/config/routes.rb +39 -4
  14. data/db/migrate/20260824000000_remove_view_smart_proxies_openbolt_permission.rb +18 -0
  15. data/lib/foreman_openbolt/engine.rb +15 -7
  16. data/lib/foreman_openbolt/version.rb +1 -1
  17. data/lib/proxy_api/openbolt.rb +64 -13
  18. data/package.json +1 -1
  19. data/test/acceptance/acceptance_helper.rb +13 -0
  20. data/test/acceptance/api_acceptance_helper.rb +109 -0
  21. data/test/acceptance/tests/api/authn_authz_test.rb +82 -0
  22. data/test/acceptance/tests/api/jobs_test.rb +77 -0
  23. data/test/acceptance/tests/api/launch_test.rb +79 -0
  24. data/test/acceptance/tests/api/proxy_tasks_test.rb +51 -0
  25. data/test/unit/controllers/api/v2/openbolt_jobs_controller_test.rb +251 -0
  26. data/test/unit/controllers/api/v2/openbolt_tasks_controller_test.rb +438 -0
  27. data/test/unit/controllers/task_controller_test.rb +197 -39
  28. data/test/unit/docker/Dockerfile +1 -0
  29. data/test/unit/lib/actions/poll_task_status_test.rb +112 -34
  30. data/test/unit/lib/proxy_api/openbolt_test.rb +121 -6
  31. data/test/unit/models/task_job_test.rb +11 -0
  32. data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +44 -1
  33. data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +3 -0
  34. data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +5 -4
  35. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +1 -1
  36. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +4 -1
  37. data/webpack/src/Components/LaunchTask/index.js +21 -6
  38. data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +1 -1
  39. data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +8 -8
  40. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +3 -3
  41. data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +6 -6
  42. data/webpack/src/Components/TaskHistory/index.js +3 -3
  43. metadata +17 -4
@@ -4,13 +4,19 @@ require 'json'
4
4
 
5
5
  module ProxyAPI
6
6
  class Openbolt < Resource
7
+ # A 200 response carrying the proxy's {"error": {...}} result. This is a final,
8
+ # permanent error, unlike a transport-level ProxyException, so callers should not retry.
9
+ class ProxyReportedError < ProxyException; end
10
+
7
11
  def initialize(args)
8
12
  @url = args[:url]
9
13
  super
10
14
  end
11
15
 
12
16
  def fetch_tasks
13
- @tasks = parse_response(get('/openbolt/tasks'), 'fetch_tasks')
17
+ @tasks = with_proxy_error_handling('fetch_tasks') do
18
+ parse_response(get('/openbolt/tasks'), 'fetch_tasks')
19
+ end
14
20
  end
15
21
 
16
22
  def tasks
@@ -18,7 +24,9 @@ module ProxyAPI
18
24
  end
19
25
 
20
26
  def reload_tasks
21
- @tasks = parse_response(get('/openbolt/tasks/reload'), 'reload_tasks')
27
+ @tasks = with_proxy_error_handling('reload_tasks') do
28
+ parse_response(get('/openbolt/tasks/reload'), 'reload_tasks')
29
+ end
22
30
  end
23
31
 
24
32
  def task_names
@@ -26,29 +34,41 @@ module ProxyAPI
26
34
  end
27
35
 
28
36
  def openbolt_options
29
- @openbolt_options ||= parse_response(get('/openbolt/tasks/options'), 'openbolt_options')
37
+ @openbolt_options ||= with_proxy_error_handling('openbolt_options') do
38
+ parse_response(get('/openbolt/tasks/options'), 'openbolt_options')
39
+ end
30
40
  end
31
41
 
42
+ # Passes the proxy's error result through so a rejected launch renders
43
+ # as a 400 at the caller, not a 502.
32
44
  def launch_task(name:, targets:, parameters: {}, options: {})
33
- response = post({
34
- name: name,
35
- targets: targets,
36
- parameters: parameters,
37
- options: options,
38
- }.to_json, '/openbolt/launch/task')
39
- parse_response(response, 'launch_task')
45
+ with_transport_errors_wrapped('launch_task') do
46
+ response = post({
47
+ name: name,
48
+ targets: targets,
49
+ parameters: parameters,
50
+ options: options,
51
+ }.to_json, '/openbolt/launch/task')
52
+ parse_response(response, 'launch_task')
53
+ end
40
54
  end
41
55
 
42
56
  def job_status(job_id:)
43
- parse_response(get("/openbolt/job/#{job_id}/status"), 'job_status')
57
+ with_proxy_error_handling('job_status') do
58
+ parse_response(get("/openbolt/job/#{job_id}/status"), 'job_status')
59
+ end
44
60
  end
45
61
 
46
62
  def job_result(job_id:)
47
- parse_response(get("/openbolt/job/#{job_id}/result"), 'job_result')
63
+ with_proxy_error_handling('job_result') do
64
+ parse_response(get("/openbolt/job/#{job_id}/result"), 'job_result')
65
+ end
48
66
  end
49
67
 
50
68
  def delete_job_artifacts(job_id:)
51
- parse_response(delete("/openbolt/job/#{job_id}/artifacts"), 'delete_job_artifacts')
69
+ with_proxy_error_handling('delete_job_artifacts') do
70
+ parse_response(delete("/openbolt/job/#{job_id}/artifacts"), 'delete_job_artifacts')
71
+ end
52
72
  end
53
73
 
54
74
  def parse_response(response, operation)
@@ -75,5 +95,36 @@ module ProxyAPI
75
95
  "Response body (first 500 chars): #{body.to_s[0..500]}"
76
96
  )
77
97
  end
98
+
99
+ private
100
+
101
+ # Rewraps transport-layer failures as ProxyException so callers never
102
+ # see raw RestClient::Exception / Errno::* and there's only one type
103
+ # of exception to handle for this kind of error.
104
+ def with_transport_errors_wrapped(operation)
105
+ yield
106
+ rescue RestClient::Exception, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
107
+ raise ProxyException.new(
108
+ @url, e,
109
+ "Transport error during #{operation}: #{e.message}"
110
+ )
111
+ end
112
+
113
+ # Raises ProxyReportedError when a 200 body carries the proxy's error
114
+ # result, which is always {"error": {"message": ...}}. Requiring that
115
+ # exact shape avoids false positives on real data, like a task named
116
+ # 'error' in the fetch_tasks response.
117
+ def with_proxy_error_handling(operation, &block)
118
+ result = with_transport_errors_wrapped(operation, &block)
119
+ error = result.is_a?(Hash) ? result['error'] : nil
120
+ if error.is_a?(Hash) && error.key?('message')
121
+ detail = error['message']
122
+ raise ProxyReportedError.new(
123
+ @url, RuntimeError.new(detail.to_s),
124
+ "Smart Proxy reported error during #{operation}: #{detail}"
125
+ )
126
+ end
127
+ result
128
+ end
78
129
  end
79
130
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foreman_openbolt",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "OpenBolt integration into Foreman",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,5 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Suppress a noisy deprecation warning emitted by Capybara 3.40.0 on Ruby 3.4+:
4
+ # URI::RFC3986_PARSER.make_regexp is obsolete. Use URI::RFC2396_PARSER.make_regexp explicitly.
5
+ # Fixed upstream in teamcapybara/capybara#2781 but not yet released. Remove this
6
+ # block once Capybara ships a release with that PR.
7
+ module SuppressCapybaraURIDeprecation
8
+ TARGET = 'URI::RFC3986_PARSER.make_regexp is obsolete'
9
+ def warn(message, **)
10
+ return if message.to_s.include?(TARGET)
11
+ super
12
+ end
13
+ end
14
+ Warning.singleton_class.prepend(SuppressCapybaraURIDeprecation)
15
+
3
16
  require 'capybara'
4
17
  require 'capybara/dsl'
5
18
  require 'selenium-webdriver'
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'test/unit'
6
+
7
+ # Base class for API-driven acceptance tests. Unlike AcceptanceTestCase's
8
+ # Capybara suite, these hit the Foreman API from the host via Faraday.
9
+ # Defaults to https://localhost with the Host header set to the Foreman FQDN.
10
+ class ApiAcceptanceTestCase < Test::Unit::TestCase
11
+ FOREMAN_API_URL = ENV.fetch('FOREMAN_API_URL', 'https://localhost')
12
+ FOREMAN_FQDN = 'foreman.example.com'
13
+ ADMIN_USER = ENV.fetch('FOREMAN_USER', 'admin')
14
+ ADMIN_PASS = ENV.fetch('FOREMAN_PASS', 'changeme')
15
+
16
+ TERMINAL_STATUSES = %w[success failure exception invalid].freeze
17
+
18
+ def setup
19
+ @api = build_client(ADMIN_USER, ADMIN_PASS)
20
+ @created_user_ids = []
21
+ end
22
+
23
+ def teardown
24
+ @created_user_ids.each do |user_id|
25
+ @api.delete("/api/v2/users/#{user_id}")
26
+ rescue StandardError
27
+ # Best effort, not a big deal if something fails
28
+ end
29
+ end
30
+
31
+ def build_client(user, pass)
32
+ Faraday.new(url: FOREMAN_API_URL,
33
+ ssl: { verify: false },
34
+ request: { timeout: 30 },
35
+ headers: {
36
+ 'Host' => FOREMAN_FQDN,
37
+ 'Content-Type' => 'application/json',
38
+ }) do |conn|
39
+ conn.request :authorization, :basic, user, pass
40
+ conn.request :json
41
+ conn.response :json, content_type: /\bjson$/
42
+ end
43
+ end
44
+
45
+ # Foreman 404s are either a nested {error: {message: ...}} body or a
46
+ # flat {message: ...} body.
47
+ def error_message(body)
48
+ return nil unless body.is_a?(Hash)
49
+ body.dig('error', 'message') || body['message']
50
+ end
51
+
52
+ # The proxy id is not stable across rebuilds, so resolve by name.
53
+ def smart_proxy_id
54
+ @smart_proxy_id ||= begin
55
+ resp = @api.get('/api/v2/smart_proxies', search: "name=#{FOREMAN_FQDN}")
56
+ id = resp.body.dig('results', 0, 'id')
57
+ flunk "Could not find smart proxy '#{FOREMAN_FQDN}' (status #{resp.status}): #{resp.body.inspect}" unless id
58
+ id
59
+ end
60
+ end
61
+
62
+ # Launches the task and polls until the status is terminal. Returns the job_id.
63
+ def launch_and_wait_for(task:, params: {}, targets: 'target1.example.com', timeout: 120)
64
+ launch_resp = @api.post('/api/v2/openbolt/launch/task',
65
+ smart_proxy_id: smart_proxy_id,
66
+ task_name: task,
67
+ targets: targets,
68
+ parameters: params,
69
+ options: { 'host-key-check' => false, 'user' => 'openbolt', 'private-key' => '/opt/foreman-proxy/.ssh/id_rsa' })
70
+ assert_equal 201, launch_resp.status,
71
+ "launch_task failed (#{launch_resp.status}): #{launch_resp.body.inspect}"
72
+ job_id = launch_resp.body['job_id']
73
+ flunk "launch_task returned no job_id: #{launch_resp.body.inspect}" unless job_id
74
+
75
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
76
+ loop do
77
+ status_resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/status")
78
+ assert_equal 200, status_resp.status,
79
+ "status poll failed (#{status_resp.status}): #{status_resp.body.inspect}"
80
+ status = status_resp.body['status']
81
+ return job_id if TERMINAL_STATUSES.include?(status)
82
+ flunk "timed out after #{timeout}s waiting for job #{job_id} (last status: #{status})" if
83
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
84
+ sleep 2
85
+ end
86
+ end
87
+
88
+ # Yields a client for a fresh role-less user, which is deleted in teardown.
89
+ def with_unprivileged_user
90
+ login = "acc_unpriv_#{Process.pid}_#{rand(1_000_000)}"
91
+ password = 'TempPass1!'
92
+ resp = @api.post('/api/v2/users',
93
+ user: {
94
+ login: login,
95
+ password: password,
96
+ firstname: 'Acc',
97
+ lastname: 'Unpriv',
98
+ mail: "#{login}@example.com",
99
+ auth_source_id: 1,
100
+ admin: false,
101
+ roles: [],
102
+ })
103
+ assert_equal 201, resp.status,
104
+ "user creation failed (#{resp.status}): #{resp.body.inspect}"
105
+ user_id = resp.body['id']
106
+ @created_user_ids << user_id
107
+ yield build_client(login, password)
108
+ end
109
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../api_acceptance_helper'
4
+
5
+ class ApiAuthnAuthzTest < ApiAcceptanceTestCase
6
+ def test_no_credentials_returns401
7
+ anonymous = Faraday.new(url: FOREMAN_API_URL,
8
+ ssl: { verify: false },
9
+ request: { timeout: 30 },
10
+ headers: { 'Host' => FOREMAN_FQDN }) do |conn|
11
+ conn.request :json
12
+ conn.response :json, content_type: /\bjson$/
13
+ end
14
+ resp = anonymous.get('/api/v2/openbolt/jobs')
15
+ assert_equal 401, resp.status, resp.body.inspect
16
+ end
17
+
18
+ def test_wrong_credentials_returns401
19
+ wrong = build_client(ADMIN_USER, 'definitely-not-the-password')
20
+ resp = wrong.get('/api/v2/openbolt/jobs')
21
+ assert_equal 401, resp.status, resp.body.inspect
22
+ end
23
+
24
+ def test_unprivileged_user_is_forbidden_from_jobs
25
+ with_unprivileged_user do |client|
26
+ resp = client.get('/api/v2/openbolt/jobs')
27
+ assert_equal 403, resp.status, resp.body.inspect
28
+ end
29
+ end
30
+
31
+ def test_unprivileged_user_is_forbidden_from_tasks
32
+ with_unprivileged_user do |client|
33
+ resp = client.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks")
34
+ assert_equal 403, resp.status, resp.body.inspect
35
+ end
36
+ end
37
+
38
+ def test_unprivileged_user_is_forbidden_from_reload_tasks
39
+ with_unprivileged_user do |client|
40
+ resp = client.post("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/reload")
41
+ assert_equal 403, resp.status, resp.body.inspect
42
+ end
43
+ end
44
+
45
+ def test_unprivileged_user_is_forbidden_from_task_options
46
+ with_unprivileged_user do |client|
47
+ resp = client.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/options")
48
+ assert_equal 403, resp.status, resp.body.inspect
49
+ end
50
+ end
51
+
52
+ def test_unprivileged_user_is_forbidden_from_launch_task
53
+ with_unprivileged_user do |client|
54
+ resp = client.post('/api/v2/openbolt/launch/task',
55
+ smart_proxy_id: smart_proxy_id,
56
+ task_name: 'acceptance::noop_task',
57
+ targets: 'target1.example.com',
58
+ parameters: {})
59
+ assert_equal 403, resp.status, resp.body.inspect
60
+ end
61
+ end
62
+
63
+ def test_unprivileged_user_is_forbidden_from_job_status
64
+ # Seed a real job so the route resolves. The 403 should fire before
65
+ # the action body looks up the TaskJob.
66
+ job_id = launch_and_wait_for(task: 'acceptance::noop_task')
67
+
68
+ with_unprivileged_user do |client|
69
+ resp = client.get("/api/v2/openbolt/jobs/#{job_id}/status")
70
+ assert_equal 403, resp.status, resp.body.inspect
71
+ end
72
+ end
73
+
74
+ def test_unprivileged_user_is_forbidden_from_job_result
75
+ job_id = launch_and_wait_for(task: 'acceptance::noop_task')
76
+
77
+ with_unprivileged_user do |client|
78
+ resp = client.get("/api/v2/openbolt/jobs/#{job_id}/result")
79
+ assert_equal 403, resp.status, resp.body.inspect
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../api_acceptance_helper'
4
+
5
+ class ApiJobsTest < ApiAcceptanceTestCase
6
+ def test_jobs_returns_paginated_envelope_with_kind
7
+ job_id = launch_and_wait_for(task: 'acceptance::noop_task')
8
+
9
+ resp = @api.get('/api/v2/openbolt/jobs')
10
+ assert_equal 200, resp.status, resp.body.inspect
11
+
12
+ assert resp.body.key?('total'), 'Expected total key in pagination envelope'
13
+ assert resp.body.key?('page'), 'Expected page key in pagination envelope'
14
+ assert resp.body.key?('per_page'), 'Expected per_page key in pagination envelope'
15
+
16
+ seeded = resp.body['results'].find { |row| row['job_id'] == job_id }
17
+ flunk "Newly launched job #{job_id} not present in /jobs response" unless seeded
18
+ assert_equal 'task', seeded['kind']
19
+ assert_includes %w[success failure exception invalid], seeded['status']
20
+ assert seeded['smart_proxy'].is_a?(Hash), "Expected smart_proxy hash, got: #{seeded['smart_proxy'].inspect}"
21
+ end
22
+
23
+ def test_jobs_per_page_all_returns_every_row_in_one_page
24
+ # Trigger one more job to guarantee at least 1 row.
25
+ launch_and_wait_for(task: 'acceptance::noop_task')
26
+
27
+ resp = @api.get('/api/v2/openbolt/jobs', per_page: 'all')
28
+ assert_equal 200, resp.status, resp.body.inspect
29
+ assert_equal resp.body['total'], resp.body['results'].length,
30
+ 'per_page=all should return every recorded job in one page'
31
+ end
32
+
33
+ def test_jobs_caps_per_page_at100
34
+ resp = @api.get('/api/v2/openbolt/jobs', per_page: 500)
35
+ assert_equal 200, resp.status, resp.body.inspect
36
+ assert_equal 100, resp.body['per_page']
37
+ end
38
+
39
+ def test_status_returns_job_status_payload_with_kind
40
+ job_id = launch_and_wait_for(task: 'acceptance::noop_task')
41
+
42
+ resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/status")
43
+ assert_equal 200, resp.status, resp.body.inspect
44
+ assert_equal 'task', resp.body['kind']
45
+ assert_includes %w[success failure exception invalid], resp.body['status']
46
+ assert_equal 'acceptance::noop_task', resp.body['name']
47
+ assert resp.body['smart_proxy'].is_a?(Hash)
48
+ assert_equal smart_proxy_id, resp.body.dig('smart_proxy', 'id')
49
+ end
50
+
51
+ def test_status_returns_404_for_unknown_job_id
52
+ resp = @api.get('/api/v2/openbolt/jobs/nonexistent-job-id/status')
53
+ assert_equal 404, resp.status, resp.body.inspect
54
+ # error_message handles both 404 envelope shapes Foreman produces
55
+ # (nested via error_layout, flat via not_found(string)).
56
+ assert_match(/not found/i, error_message(resp.body).to_s)
57
+ end
58
+
59
+ def test_result_returns_command_value_log_for_completed_job
60
+ job_id = launch_and_wait_for(task: 'acceptance::noop_task')
61
+
62
+ resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/result")
63
+ assert_equal 200, resp.status, resp.body.inspect
64
+ assert_equal 'task', resp.body['kind']
65
+ assert_equal 'success', resp.body['status'], resp.body.inspect
66
+ assert resp.body.key?('command'), 'expected command key in result payload'
67
+ assert resp.body.key?('value'), 'expected value key in result payload'
68
+ assert resp.body.key?('log'), 'expected log key in result payload'
69
+ assert_match(/acceptance::noop_task/, resp.body['command'].to_s)
70
+ end
71
+
72
+ def test_result_returns_404_for_unknown_job_id
73
+ resp = @api.get('/api/v2/openbolt/jobs/nonexistent-job-id/result')
74
+ assert_equal 404, resp.status, resp.body.inspect
75
+ assert_match(/not found/i, error_message(resp.body).to_s)
76
+ end
77
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../api_acceptance_helper'
4
+
5
+ class ApiLaunchTest < ApiAcceptanceTestCase
6
+ def test_launch_returns_job_id_and_kind
7
+ resp = @api.post('/api/v2/openbolt/launch/task',
8
+ smart_proxy_id: smart_proxy_id,
9
+ task_name: 'acceptance::noop_task',
10
+ targets: 'target1.example.com',
11
+ parameters: {})
12
+ assert_equal 201, resp.status, resp.body.inspect
13
+ assert resp.body['job_id'].is_a?(String) && !resp.body['job_id'].empty?,
14
+ "expected non-empty job_id, got: #{resp.body.inspect}"
15
+ assert_equal 'task', resp.body['kind']
16
+ end
17
+
18
+ def test_launch_returns_400_when_smart_proxy_id_is_missing
19
+ resp = @api.post('/api/v2/openbolt/launch/task',
20
+ task_name: 'acceptance::noop_task',
21
+ targets: 'target1.example.com',
22
+ parameters: {})
23
+ assert_equal 400, resp.status, resp.body.inspect
24
+ assert_kind_of Hash, resp.body['error']
25
+ assert_equal 'Smart Proxy ID is required', resp.body.dig('error', 'message')
26
+ end
27
+
28
+ def test_launch_returns_400_when_task_name_is_missing
29
+ resp = @api.post('/api/v2/openbolt/launch/task',
30
+ smart_proxy_id: smart_proxy_id,
31
+ targets: 'target1.example.com',
32
+ parameters: {})
33
+ assert_equal 400, resp.status, resp.body.inspect
34
+ assert_match(/Task name and targets cannot be empty/, resp.body.dig('error', 'message').to_s)
35
+ end
36
+
37
+ def test_launch_returns_400_when_targets_is_missing
38
+ resp = @api.post('/api/v2/openbolt/launch/task',
39
+ smart_proxy_id: smart_proxy_id,
40
+ task_name: 'acceptance::noop_task',
41
+ parameters: {})
42
+ assert_equal 400, resp.status, resp.body.inspect
43
+ assert_match(/Task name and targets cannot be empty/, resp.body.dig('error', 'message').to_s)
44
+ end
45
+
46
+ def test_launch_returns_400_when_task_does_not_exist_on_proxy
47
+ # The proxy responds with {error: ...} for an unknown task.
48
+ # ForemanOpenbolt::Tasks#dispatch_task re-raises that as a
49
+ # LaunchError, which the API controller renders as 400.
50
+ resp = @api.post('/api/v2/openbolt/launch/task',
51
+ smart_proxy_id: smart_proxy_id,
52
+ task_name: 'acceptance::does_not_exist',
53
+ targets: 'target1.example.com',
54
+ parameters: {})
55
+ assert_equal 400, resp.status, resp.body.inspect
56
+ assert_match(/Task execution failed/, resp.body.dig('error', 'message').to_s)
57
+ end
58
+
59
+ # End-to-end smoke test running launch, status polling, and result fetch with
60
+ # real OpenBolt execution against real targets. If this passes, the wiring
61
+ # from the API controller all the way through ProxyAPI to smart proxy
62
+ # to bolt to PollTaskStatus to TaskJob is working.
63
+ def test_echo_task_end_to_end
64
+ message = "acceptance api e2e #{Time.now.to_i}"
65
+ job_id = launch_and_wait_for(
66
+ task: 'acceptance::echo',
67
+ params: { 'message' => message }
68
+ )
69
+
70
+ result_resp = @api.get("/api/v2/openbolt/jobs/#{job_id}/result")
71
+ assert_equal 200, result_resp.status, result_resp.body.inspect
72
+ assert_equal 'success', result_resp.body['status'], result_resp.body.inspect
73
+ assert_equal 'task', result_resp.body['kind']
74
+ assert_match(/acceptance::echo/, result_resp.body['command'].to_s)
75
+ # The task returns {message, hostname} and the value is the unmodified
76
+ # OpenBolt result hash (per-target results indexed by target).
77
+ assert_match(/#{Regexp.escape(message)}/, result_resp.body['value'].to_s)
78
+ end
79
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../api_acceptance_helper'
4
+
5
+ class ApiProxyTasksTest < ApiAcceptanceTestCase
6
+ def test_tasks_lists_the_acceptance_fixture_tasks
7
+ resp = @api.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks")
8
+ assert_equal 200, resp.status, resp.body.inspect
9
+
10
+ task_names = resp.body.keys
11
+ %w[acceptance::echo acceptance::noop_task acceptance::failing_task
12
+ acceptance::complex_params].each do |expected|
13
+ assert_includes task_names, expected,
14
+ "expected proxy task list to include '#{expected}', got: #{task_names.inspect}"
15
+ end
16
+ end
17
+
18
+ def test_tasks_returns_404_for_unknown_smart_proxy
19
+ resp = @api.get('/api/v2/openbolt/smart_proxies/999999/tasks')
20
+ assert_equal 404, resp.status, resp.body.inspect
21
+ assert_match(/not found/i, error_message(resp.body).to_s)
22
+ end
23
+
24
+ def test_reload_tasks_returns_reloaded_list
25
+ resp = @api.post("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/reload")
26
+ assert_equal 200, resp.status, resp.body.inspect
27
+ assert_includes resp.body.keys, 'acceptance::echo'
28
+ end
29
+
30
+ def test_reload_tasks_returns_404_for_unknown_smart_proxy
31
+ resp = @api.post('/api/v2/openbolt/smart_proxies/999999/tasks/reload')
32
+ assert_equal 404, resp.status, resp.body.inspect
33
+ assert_match(/not found/i, error_message(resp.body).to_s)
34
+ end
35
+
36
+ def test_task_options_returns_options_with_setting_defaults_merged
37
+ # acceptance:up sets these three Foreman settings before tests run.
38
+ # The endpoint should reflect them as the 'default' for each option.
39
+ resp = @api.get("/api/v2/openbolt/smart_proxies/#{smart_proxy_id}/tasks/options")
40
+ assert_equal 200, resp.status, resp.body.inspect
41
+
42
+ assert_equal 'openbolt', resp.body.dig('user', 'default')
43
+ assert_equal '/opt/foreman-proxy/.ssh/id_rsa', resp.body.dig('private-key', 'default')
44
+ assert_equal false, resp.body.dig('host-key-check', 'default')
45
+ end
46
+
47
+ def test_task_options_returns_404_for_unknown_smart_proxy
48
+ resp = @api.get('/api/v2/openbolt/smart_proxies/999999/tasks/options')
49
+ assert_equal 404, resp.status, resp.body.inspect
50
+ end
51
+ end