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
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module Api
6
+ module V2
7
+ class OpenboltJobsControllerTest < ActionController::TestCase
8
+ setup do
9
+ @proxy = FactoryBot.create(:smart_proxy)
10
+ @session = set_session_user
11
+ WebMock.reset!
12
+ end
13
+
14
+ teardown do
15
+ WebMock.reset!
16
+ end
17
+
18
+ context 'jobs' do
19
+ test 'returns empty results with pagination envelope when no jobs exist' do
20
+ get :jobs, session: @session
21
+ assert_response :success
22
+ body = JSON.parse(response.body)
23
+ assert_equal [], body['results']
24
+ assert_equal 0, body['total']
25
+ assert_equal 1, body['page']
26
+ end
27
+
28
+ test 'returns paginated results with kind on each entry' do
29
+ 3.times { FactoryBot.create(:task_job, smart_proxy: @proxy) }
30
+
31
+ get :jobs, params: { page: 1, per_page: 2 }, session: @session
32
+ assert_response :success
33
+ body = JSON.parse(response.body)
34
+ assert_equal 2, body['results'].length
35
+ assert_equal 3, body['total']
36
+ assert_equal 1, body['page']
37
+ assert_equal 2, body['per_page']
38
+ assert(body['results'].all? { |row| row['kind'] == 'task' })
39
+
40
+ row = body['results'].first
41
+ %w[job_id name description parameters targets status
42
+ submitted_at completed_at duration].each do |field|
43
+ assert row.key?(field), "expected results[0] to contain '#{field}'"
44
+ end
45
+ assert_equal @proxy.id, row['smart_proxy']['id']
46
+ assert_equal @proxy.name, row['smart_proxy']['name']
47
+ end
48
+
49
+ test 'caps per_page at 100' do
50
+ get :jobs, params: { per_page: 500 }, session: @session
51
+ assert_response :success
52
+ body = JSON.parse(response.body)
53
+ assert_equal 100, body['per_page']
54
+ end
55
+
56
+ test 'floors per_page at 1 when zero is requested' do
57
+ get :jobs, params: { per_page: 0 }, session: @session
58
+ assert_response :success
59
+ body = JSON.parse(response.body)
60
+ assert_equal 1, body['per_page']
61
+ end
62
+
63
+ test 'floors per_page at 1 when a negative value is requested' do
64
+ get :jobs, params: { per_page: -5 }, session: @session
65
+ assert_response :success
66
+ body = JSON.parse(response.body)
67
+ assert_equal 1, body['per_page']
68
+ end
69
+
70
+ test 'defaults per_page to 20' do
71
+ get :jobs, session: @session
72
+ assert_response :success
73
+ body = JSON.parse(response.body)
74
+ assert_equal 20, body['per_page']
75
+ end
76
+
77
+ test 'per_page=all returns all jobs in one page' do
78
+ 5.times { FactoryBot.create(:task_job, smart_proxy: @proxy) }
79
+ get :jobs, params: { per_page: 'all' }, session: @session
80
+ assert_response :success
81
+ body = JSON.parse(response.body)
82
+ assert_equal 5, body['results'].length
83
+ assert_equal 5, body['per_page']
84
+ end
85
+
86
+ test 'per_page=all on empty DB does not 500' do
87
+ get :jobs, params: { per_page: 'all' }, session: @session
88
+ assert_response :success
89
+ body = JSON.parse(response.body)
90
+ assert_equal [], body['results']
91
+ assert_equal 0, body['total']
92
+ end
93
+
94
+ test 'floors page at 1 when zero is requested' do
95
+ get :jobs, params: { page: 0 }, session: @session
96
+ assert_response :success
97
+ body = JSON.parse(response.body)
98
+ assert_equal 1, body['page']
99
+ end
100
+
101
+ test 'floors page at 1 when a negative value is requested' do
102
+ get :jobs, params: { page: -5 }, session: @session
103
+ assert_response :success
104
+ body = JSON.parse(response.body)
105
+ assert_equal 1, body['page']
106
+ end
107
+
108
+ test 'floors page at 1 when a non-numeric value is requested' do
109
+ get :jobs, params: { page: 'bogus' }, session: @session
110
+ assert_response :success
111
+ body = JSON.parse(response.body)
112
+ assert_equal 1, body['page']
113
+ end
114
+
115
+ test 'page beyond the last page returns empty results' do
116
+ FactoryBot.create(:task_job, smart_proxy: @proxy)
117
+ get :jobs, params: { page: 99 }, session: @session
118
+ assert_response :success
119
+ body = JSON.parse(response.body)
120
+ assert_equal [], body['results']
121
+ assert_equal 1, body['total']
122
+ end
123
+
124
+ test 'returns rows in DESC submitted_at order' do
125
+ oldest = FactoryBot.create(:task_job, smart_proxy: @proxy, submitted_at: 3.hours.ago)
126
+ middle = FactoryBot.create(:task_job, smart_proxy: @proxy, submitted_at: 2.hours.ago)
127
+ newest = FactoryBot.create(:task_job, smart_proxy: @proxy, submitted_at: 1.hour.ago)
128
+
129
+ get :jobs, session: @session
130
+ assert_response :success
131
+ body = JSON.parse(response.body)
132
+ assert_equal([newest.job_id, middle.job_id, oldest.job_id],
133
+ body['results'].map { |row| row['job_id'] })
134
+ end
135
+ end
136
+
137
+ context 'job_status' do
138
+ test 'returns job status with kind' do
139
+ job = FactoryBot.create(:task_job, :running, smart_proxy: @proxy)
140
+
141
+ get :job_status, params: { job_id: job.job_id }, session: @session
142
+ assert_response :success
143
+ body = JSON.parse(response.body)
144
+ assert_equal 'task', body['kind']
145
+ assert_equal 'running', body['status']
146
+ assert_equal job.task_name, body['name']
147
+ assert_equal job.targets, body['targets']
148
+ assert_equal @proxy.id, body['smart_proxy']['id']
149
+ end
150
+
151
+ test 'returns not_found when job does not exist' do
152
+ get :job_status, params: { job_id: 'nonexistent' }, session: @session
153
+ assert_response :not_found
154
+ assert_match(/Job nonexistent not found/,
155
+ JSON.parse(response.body)['error']['message'])
156
+ end
157
+ end
158
+
159
+ context 'job_result' do
160
+ test 'returns result fields with kind for completed job' do
161
+ ForemanTasks.stubs(:async_task)
162
+ job = FactoryBot.create(:task_job, :success, smart_proxy: @proxy)
163
+
164
+ get :job_result, params: { job_id: job.job_id }, session: @session
165
+ assert_response :success
166
+ body = JSON.parse(response.body)
167
+ assert_equal 'task', body['kind']
168
+ assert_equal 'success', body['status']
169
+ assert_equal job.result, body['value']
170
+ assert_equal job.log, body['log']
171
+ assert_equal job.command, body['command']
172
+ end
173
+
174
+ test 'returns not_found when job does not exist' do
175
+ get :job_result, params: { job_id: 'nonexistent' }, session: @session
176
+ assert_response :not_found
177
+ assert_match(/Job nonexistent not found/,
178
+ JSON.parse(response.body)['error']['message'])
179
+ end
180
+ end
181
+
182
+ context 'authorization' do
183
+ test 'forbids unprivileged users from listing jobs' do
184
+ reset_api_credentials
185
+ unprivileged = FactoryBot.create(:user)
186
+ User.current = unprivileged
187
+
188
+ get :jobs, session: set_session_user(unprivileged)
189
+ assert_response :forbidden
190
+ end
191
+
192
+ test 'forbids unprivileged users from reading job status' do
193
+ job = FactoryBot.create(:task_job, smart_proxy: @proxy)
194
+ reset_api_credentials
195
+ unprivileged = FactoryBot.create(:user)
196
+ User.current = unprivileged
197
+
198
+ get :job_status, params: { job_id: job.job_id },
199
+ session: set_session_user(unprivileged)
200
+ assert_response :forbidden
201
+ end
202
+
203
+ test 'forbids unprivileged users from reading job result' do
204
+ job = FactoryBot.create(:task_job, :success, smart_proxy: @proxy)
205
+ reset_api_credentials
206
+ unprivileged = FactoryBot.create(:user)
207
+ User.current = unprivileged
208
+
209
+ get :job_result, params: { job_id: job.job_id },
210
+ session: set_session_user(unprivileged)
211
+ assert_response :forbidden
212
+ end
213
+
214
+ test 'execute_openbolt without view_smart_proxies still lists jobs' do
215
+ FactoryBot.create(:task_job, smart_proxy: @proxy)
216
+ reset_api_credentials
217
+ granted = FactoryBot.create(:user)
218
+ setup_user('execute', 'openbolt', nil, granted)
219
+ User.current = granted
220
+
221
+ get :jobs, session: set_session_user(granted)
222
+ assert_response :success
223
+ body = JSON.parse(response.body)
224
+ assert_equal 1, body['results'].length
225
+ end
226
+
227
+ test 'execute_openbolt without view_smart_proxies still reads job status' do
228
+ job = FactoryBot.create(:task_job, :running, smart_proxy: @proxy)
229
+ reset_api_credentials
230
+ granted = FactoryBot.create(:user)
231
+ setup_user('execute', 'openbolt', nil, granted)
232
+ User.current = granted
233
+
234
+ get :job_status, params: { job_id: job.job_id }, session: set_session_user(granted)
235
+ assert_response :success
236
+ end
237
+
238
+ test 'execute_openbolt without view_smart_proxies still reads job result' do
239
+ job = FactoryBot.create(:task_job, :success, smart_proxy: @proxy)
240
+ reset_api_credentials
241
+ granted = FactoryBot.create(:user)
242
+ setup_user('execute', 'openbolt', nil, granted)
243
+ User.current = granted
244
+
245
+ get :job_result, params: { job_id: job.job_id }, session: set_session_user(granted)
246
+ assert_response :success
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ module Api
6
+ module V2
7
+ class OpenboltTasksControllerTest < ActionController::TestCase
8
+ setup do
9
+ @proxy = FactoryBot.create(:smart_proxy)
10
+ @session = set_session_user
11
+ WebMock.reset!
12
+ end
13
+
14
+ teardown do
15
+ WebMock.reset!
16
+ end
17
+
18
+ context 'tasks' do
19
+ test 'returns tasks list from the proxy as JSON' do
20
+ tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
21
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
22
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
23
+
24
+ get :tasks, params: { smart_proxy_id: @proxy.id }, session: @session
25
+ assert_response :success
26
+ assert_equal tasks, JSON.parse(response.body)
27
+ end
28
+
29
+ test 'returns not_found when smart_proxy does not exist' do
30
+ get :tasks, params: { smart_proxy_id: -1 }, session: @session
31
+ assert_response :not_found
32
+ assert_match(/not found/, JSON.parse(response.body)['error']['message'])
33
+ end
34
+
35
+ test 'returns bad_gateway when proxy returns invalid JSON' do
36
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
37
+ .to_return(status: 200, body: 'not valid json',
38
+ headers: { 'Content-Type' => 'application/json' })
39
+
40
+ get :tasks, params: { smart_proxy_id: @proxy.id }, session: @session
41
+ assert_response :bad_gateway
42
+ end
43
+
44
+ test 'returns bad_gateway when ProxyAPI::Openbolt.new raises' do
45
+ ::ProxyAPI::Openbolt.expects(:new).raises(StandardError, 'ssl setup failed')
46
+
47
+ get :tasks, params: { smart_proxy_id: @proxy.id }, session: @session
48
+ assert_response :bad_gateway
49
+ assert_match(/Failed to connect to Smart Proxy/,
50
+ JSON.parse(response.body)['error']['message'])
51
+ end
52
+ end
53
+
54
+ context 'reload_tasks' do
55
+ test 'returns reloaded task list' do
56
+ tasks = { 'new::task' => {} }
57
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/reload")
58
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
59
+
60
+ post :reload_tasks, params: { smart_proxy_id: @proxy.id }, session: @session
61
+ assert_response :success
62
+ assert_equal tasks, JSON.parse(response.body)
63
+ end
64
+ end
65
+
66
+ context 'task_options' do
67
+ test 'returns options with Foreman setting defaults merged in' do
68
+ Setting['openbolt_transport'] = 'winrm'
69
+
70
+ proxy_options = {
71
+ 'transport' => { 'type' => %w[ssh winrm] },
72
+ 'verbose' => { 'type' => 'boolean' },
73
+ }
74
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
75
+ .to_return(status: 200, body: proxy_options.to_json,
76
+ headers: { 'Content-Type' => 'application/json' })
77
+
78
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
79
+ assert_response :success
80
+ body = JSON.parse(response.body)
81
+ assert_equal 'winrm', body['transport']['default']
82
+ assert_equal false, body['verbose']['default']
83
+ end
84
+
85
+ test 'shows encrypted placeholder instead of the saved secret value' do
86
+ Setting['openbolt_password'] = 'real-secret-value'
87
+
88
+ proxy_options = {
89
+ 'password' => { 'type' => 'string', 'sensitive' => true },
90
+ }
91
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
92
+ .to_return(status: 200, body: proxy_options.to_json,
93
+ headers: { 'Content-Type' => 'application/json' })
94
+
95
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
96
+ assert_response :success
97
+ body = JSON.parse(response.body)
98
+ assert_equal '[Use saved encrypted default]', body['password']['default']
99
+ assert_no_match(/real-secret-value/, response.body)
100
+ end
101
+ end
102
+
103
+ context 'launch_task' do
104
+ setup do
105
+ @tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
106
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks")
107
+ .to_return(status: 200, body: @tasks.to_json, headers: { 'Content-Type' => 'application/json' })
108
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
109
+ .to_return(status: 200, body: { 'id' => 'api-job-1' }.to_json,
110
+ headers: { 'Content-Type' => 'application/json' })
111
+ ForemanTasks.stubs(:async_task)
112
+ end
113
+
114
+ test 'launches task and returns job_id with kind' do
115
+ post :launch_task, params: {
116
+ smart_proxy_id: @proxy.id,
117
+ task_name: 'mymod::install',
118
+ targets: 'host1.example.com',
119
+ }, session: @session
120
+
121
+ assert_response :success
122
+ body = JSON.parse(response.body)
123
+ assert_equal 'api-job-1', body['job_id']
124
+ assert_equal 'task', body['kind']
125
+ end
126
+
127
+ test 'schedules polling after launch' do
128
+ ForemanTasks.expects(:async_task).with(
129
+ Actions::ForemanOpenbolt::PollTaskStatus,
130
+ 'api-job-1',
131
+ @proxy.id
132
+ )
133
+
134
+ post :launch_task, params: {
135
+ smart_proxy_id: @proxy.id,
136
+ task_name: 'mymod::install',
137
+ targets: 'host1.example.com',
138
+ }, session: @session
139
+
140
+ assert_response :success
141
+ end
142
+
143
+ test 'creates a TaskJob record' do
144
+ assert_difference('ForemanOpenbolt::TaskJob.count', 1) do
145
+ post :launch_task, params: {
146
+ smart_proxy_id: @proxy.id,
147
+ task_name: 'mymod::install',
148
+ targets: 'host1.example.com,host2.example.com',
149
+ }, session: @session
150
+ end
151
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
152
+ assert_equal 'mymod::install', job.task_name
153
+ assert_equal %w[host1.example.com host2.example.com], job.targets
154
+ end
155
+
156
+ test 'returns bad_request when task_name and targets are missing' do
157
+ post :launch_task, params: { smart_proxy_id: @proxy.id }, session: @session
158
+ assert_response :bad_request
159
+ end
160
+
161
+ test 'returns bad_request and persists nothing when proxy returns error' do
162
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
163
+ .to_return(status: 200, body: { 'error' => 'Task not found' }.to_json,
164
+ headers: { 'Content-Type' => 'application/json' })
165
+ ForemanTasks.expects(:async_task).never
166
+
167
+ assert_no_difference('ForemanOpenbolt::TaskJob.count') do
168
+ post :launch_task, params: {
169
+ smart_proxy_id: @proxy.id,
170
+ task_name: 'missing::task',
171
+ targets: 'host1',
172
+ }, session: @session
173
+ end
174
+ assert_response :bad_request
175
+ end
176
+
177
+ test 'returns bad_request and persists nothing when proxy returns no job id' do
178
+ stub_request(:post, "#{@proxy.url}/openbolt/launch/task")
179
+ .to_return(status: 200, body: { 'status' => 'ok' }.to_json,
180
+ headers: { 'Content-Type' => 'application/json' })
181
+ ForemanTasks.expects(:async_task).never
182
+
183
+ assert_no_difference('ForemanOpenbolt::TaskJob.count') do
184
+ post :launch_task, params: {
185
+ smart_proxy_id: @proxy.id,
186
+ task_name: 'test::task',
187
+ targets: 'host1',
188
+ }, session: @session
189
+ end
190
+ assert_response :bad_request
191
+ end
192
+
193
+ test 'returns bad_request when smart_proxy_id is missing' do
194
+ post :launch_task, params: { task_name: 'test::task', targets: 'host1' }, session: @session
195
+ assert_response :bad_request
196
+ body = JSON.parse(response.body)
197
+ assert_kind_of Hash, body['error']
198
+ assert_match(/Smart Proxy ID is required/, body['error']['message'])
199
+ end
200
+
201
+ test 'returns bad_request and persists nothing when encrypted placeholder is used for nonexistent setting' do
202
+ ForemanTasks.expects(:async_task).never
203
+
204
+ assert_no_difference('ForemanOpenbolt::TaskJob.count') do
205
+ post :launch_task, params: {
206
+ smart_proxy_id: @proxy.id,
207
+ task_name: 'mymod::install',
208
+ targets: 'host1.example.com',
209
+ options: { 'nonexistent-option' => '[Use saved encrypted default]' },
210
+ }, session: @session
211
+ end
212
+ assert_response :bad_request
213
+ assert_match(/No saved value for encrypted option/,
214
+ JSON.parse(response.body)['error']['message'])
215
+ end
216
+
217
+ test 'sends literal encrypted-option value to proxy but scrubs it in the database' do
218
+ post :launch_task, params: {
219
+ smart_proxy_id: @proxy.id,
220
+ task_name: 'mymod::install',
221
+ targets: 'host1.example.com',
222
+ options: { 'password' => 'literal-typed-secret', 'transport' => 'ssh' },
223
+ }, session: @session
224
+
225
+ assert_response :success
226
+ launch_url = "#{@proxy.url}/openbolt/launch/task"
227
+ assert_requested(:post, launch_url) do |req|
228
+ sent = JSON.parse(req.body)
229
+ sent['options']['password'] == 'literal-typed-secret'
230
+ end
231
+
232
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
233
+ assert_equal '*****', job.openbolt_options['password']
234
+ assert_equal 'ssh', job.openbolt_options['transport']
235
+ end
236
+
237
+ test 'sends real encrypted value to proxy and scrubs it in the database' do
238
+ Setting['openbolt_password'] = 'real-secret-password'
239
+
240
+ post :launch_task, params: {
241
+ smart_proxy_id: @proxy.id,
242
+ task_name: 'mymod::install',
243
+ targets: 'host1.example.com',
244
+ options: { 'password' => '[Use saved encrypted default]', 'transport' => 'ssh' },
245
+ }, session: @session
246
+
247
+ assert_response :success
248
+ launch_url = "#{@proxy.url}/openbolt/launch/task"
249
+ assert_requested(:post, launch_url) do |req|
250
+ sent = JSON.parse(req.body)
251
+ sent['options']['password'] == 'real-secret-password' &&
252
+ sent['options']['transport'] == 'ssh'
253
+ end
254
+
255
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
256
+ assert_equal '*****', job.openbolt_options['password']
257
+ assert_equal 'ssh', job.openbolt_options['transport']
258
+ end
259
+
260
+ test 'returns 500 and marks the row exception when async_task scheduling fails' do
261
+ ForemanTasks.stubs(:async_task).raises(StandardError, 'Dynflow executor unavailable')
262
+
263
+ post :launch_task, params: {
264
+ smart_proxy_id: @proxy.id,
265
+ task_name: 'mymod::install',
266
+ targets: 'host1.example.com',
267
+ }, session: @session
268
+
269
+ assert_response :internal_server_error
270
+ assert_match(/background polling could not be scheduled/,
271
+ JSON.parse(response.body)['error']['message'])
272
+
273
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
274
+ assert_not_nil job, 'TaskJob row should be persisted even when polling scheduling fails'
275
+ assert_equal 'exception', job.status
276
+ end
277
+
278
+ test 'logs the persisted status when the exception flip itself fails' do
279
+ ForemanTasks.stubs(:async_task).raises(StandardError, 'Dynflow executor unavailable')
280
+ ForemanOpenbolt::TaskJob.any_instance.stubs(:update!).raises(
281
+ ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new)
282
+ )
283
+
284
+ Foreman::Logging.stubs(:exception)
285
+ Foreman::Logging.expects(:exception)
286
+ .with(regexp_matches(/Row will remain in 'pending' state/), anything)
287
+ .at_least_once
288
+
289
+ post :launch_task, params: {
290
+ smart_proxy_id: @proxy.id,
291
+ task_name: 'mymod::install',
292
+ targets: 'host1.example.com',
293
+ }, session: @session
294
+
295
+ assert_response :internal_server_error
296
+ assert_match(/background polling could not be scheduled/,
297
+ JSON.parse(response.body)['error']['message'])
298
+
299
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
300
+ assert_not_nil job, 'TaskJob row should still be persisted'
301
+ assert_equal 'pending', job.status
302
+ end
303
+
304
+ test 'persists with empty description when metadata fetch fails with ProxyException' do
305
+ tasks_url = "#{@proxy.url}/openbolt/tasks"
306
+ ::ProxyAPI::Openbolt.any_instance.stubs(:tasks).raises(
307
+ ::ProxyAPI::ProxyException.new(tasks_url, RuntimeError.new('boom'), 'proxy down post-launch')
308
+ )
309
+
310
+ post :launch_task, params: {
311
+ smart_proxy_id: @proxy.id,
312
+ task_name: 'mymod::install',
313
+ targets: 'host1.example.com',
314
+ }, session: @session
315
+
316
+ assert_response :success
317
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
318
+ assert_not_nil job
319
+ assert_equal '', job.task_description
320
+ assert_equal 'pending', job.status
321
+ end
322
+
323
+ test 'persists with empty description when metadata fetch fails with transport error' do
324
+ ::ProxyAPI::Openbolt.any_instance.stubs(:tasks).raises(Errno::ECONNREFUSED)
325
+
326
+ post :launch_task, params: {
327
+ smart_proxy_id: @proxy.id,
328
+ task_name: 'mymod::install',
329
+ targets: 'host1.example.com',
330
+ }, session: @session
331
+
332
+ assert_response :success
333
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
334
+ assert_not_nil job
335
+ assert_equal '', job.task_description
336
+ assert_equal 'pending', job.status
337
+ end
338
+
339
+ test 'returns 500 with the proxy job id when TaskJob persistence fails post-launch' do
340
+ ForemanOpenbolt::TaskJob.stubs(:create_from_execution!)
341
+ .raises(ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new))
342
+
343
+ post :launch_task, params: {
344
+ smart_proxy_id: @proxy.id,
345
+ task_name: 'mymod::install',
346
+ targets: 'host1.example.com',
347
+ }, session: @session
348
+
349
+ assert_response :internal_server_error
350
+ body = JSON.parse(response.body)
351
+ assert_match(/api-job-1/, body['error']['message'])
352
+ assert_match(/could not record/i, body['error']['message'])
353
+ end
354
+
355
+ test 'forwards parameters body field to the proxy' do
356
+ post :launch_task, params: {
357
+ smart_proxy_id: @proxy.id,
358
+ task_name: 'mymod::install',
359
+ targets: 'host1.example.com',
360
+ parameters: { 'name' => 'nginx', 'version' => '1.21' },
361
+ }, session: @session
362
+
363
+ assert_response :success
364
+ launch_url = "#{@proxy.url}/openbolt/launch/task"
365
+ assert_requested(:post, launch_url) do |req|
366
+ sent = JSON.parse(req.body)
367
+ sent['parameters'] == { 'name' => 'nginx', 'version' => '1.21' }
368
+ end
369
+
370
+ job = ForemanOpenbolt::TaskJob.find_by(job_id: 'api-job-1')
371
+ assert_equal({ 'name' => 'nginx', 'version' => '1.21' }, job.task_parameters)
372
+ end
373
+ end
374
+
375
+ context 'authorization' do
376
+ test 'forbids users without execute_openbolt permission' do
377
+ reset_api_credentials
378
+ unprivileged = FactoryBot.create(:user)
379
+ User.current = unprivileged
380
+
381
+ get :tasks, params: { smart_proxy_id: @proxy.id },
382
+ session: set_session_user(unprivileged)
383
+ assert_response :forbidden
384
+ end
385
+
386
+ test 'forbids unprivileged users from launching tasks' do
387
+ reset_api_credentials
388
+ unprivileged = FactoryBot.create(:user)
389
+ User.current = unprivileged
390
+
391
+ post :launch_task, params: {
392
+ smart_proxy_id: @proxy.id,
393
+ task_name: 'mymod::install',
394
+ targets: 'host1.example.com',
395
+ }, session: set_session_user(unprivileged)
396
+ assert_response :forbidden
397
+ end
398
+
399
+ test 'allows users granted execute_openbolt via setup_user' do
400
+ tasks_url = "#{@proxy.url}/openbolt/tasks"
401
+ tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
402
+ stub_request(:get, tasks_url)
403
+ .to_return(status: 200, body: tasks.to_json,
404
+ headers: { 'Content-Type' => 'application/json' })
405
+ reset_api_credentials
406
+ granted = FactoryBot.create(:user)
407
+ setup_user('execute', 'openbolt', nil, granted)
408
+ setup_user('view', 'smart_proxies', nil, granted)
409
+ User.current = granted
410
+
411
+ get :tasks, params: { smart_proxy_id: @proxy.id },
412
+ session: set_session_user(granted)
413
+ assert_response :success
414
+ end
415
+
416
+ test 'forbids unprivileged users from fetching task options' do
417
+ reset_api_credentials
418
+ unprivileged = FactoryBot.create(:user)
419
+ User.current = unprivileged
420
+
421
+ get :task_options, params: { smart_proxy_id: @proxy.id },
422
+ session: set_session_user(unprivileged)
423
+ assert_response :forbidden
424
+ end
425
+
426
+ test 'forbids unprivileged users from reloading the task cache' do
427
+ reset_api_credentials
428
+ unprivileged = FactoryBot.create(:user)
429
+ User.current = unprivileged
430
+
431
+ post :reload_tasks, params: { smart_proxy_id: @proxy.id },
432
+ session: set_session_user(unprivileged)
433
+ assert_response :forbidden
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end