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
@@ -16,34 +16,35 @@ class TaskControllerTest < ActionController::TestCase
16
16
  WebMock.reset!
17
17
  end
18
18
 
19
- context 'fetch_tasks' do
19
+ context 'tasks' do
20
20
  test 'returns tasks from proxy as JSON' do
21
21
  tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
22
22
  stub_request(:get, "#{@proxy.url}/openbolt/tasks")
23
23
  .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
24
24
 
25
- get :fetch_tasks, params: { proxy_id: @proxy.id }, session: @session
25
+ get :tasks, params: { smart_proxy_id: @proxy.id }, session: @session
26
26
  assert_response :success
27
27
  assert_equal tasks, JSON.parse(response.body)
28
28
  end
29
29
 
30
- test 'returns error when proxy_id is missing' do
31
- get :fetch_tasks, session: @session
30
+ test 'returns error when smart_proxy_id is missing' do
31
+ get :tasks, session: @session
32
32
  assert_response :bad_request
33
33
  body = JSON.parse(response.body)
34
- assert_match(/Smart Proxy ID is required/, body['error'])
34
+ assert_match(/Smart Proxy ID is required/, body['error']['message'])
35
35
  end
36
36
 
37
37
  test 'returns error when proxy not found' do
38
- get :fetch_tasks, params: { proxy_id: -1 }, session: @session
38
+ get :tasks, params: { smart_proxy_id: -1 }, session: @session
39
39
  assert_response :not_found
40
40
  end
41
41
 
42
- test 'returns internal_server_error when proxy is unreachable' do
42
+ test 'returns bad_gateway when proxy is unreachable' do
43
43
  stub_request(:get, "#{@proxy.url}/openbolt/tasks").to_timeout
44
44
 
45
- get :fetch_tasks, params: { proxy_id: @proxy.id }, session: @session
46
- assert_response :internal_server_error
45
+ # Transport failures wrap as ProxyException and render 502.
46
+ get :tasks, params: { smart_proxy_id: @proxy.id }, session: @session
47
+ assert_response :bad_gateway
47
48
  end
48
49
  end
49
50
 
@@ -53,7 +54,7 @@ class TaskControllerTest < ActionController::TestCase
53
54
  stub_request(:get, "#{@proxy.url}/openbolt/tasks/reload")
54
55
  .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
55
56
 
56
- get :reload_tasks, params: { proxy_id: @proxy.id }, session: @session
57
+ post :reload_tasks, params: { smart_proxy_id: @proxy.id }, session: @session
57
58
  assert_response :success
58
59
  assert_equal tasks, JSON.parse(response.body)
59
60
  end
@@ -72,16 +73,17 @@ class TaskControllerTest < ActionController::TestCase
72
73
 
73
74
  test 'launches task and returns job_id' do
74
75
  post :launch_task, params: {
75
- proxy_id: @proxy.id,
76
+ smart_proxy_id: @proxy.id,
76
77
  task_name: 'mymod::install',
77
78
  targets: 'host1.example.com',
78
- params: { 'name' => 'nginx' },
79
+ parameters: { 'name' => 'nginx' },
79
80
  options: { 'transport' => 'ssh' },
80
81
  }, session: @session
81
82
 
82
83
  assert_response :success
83
84
  body = JSON.parse(response.body)
84
85
  assert_equal 'launched-job-1', body['job_id']
86
+ assert_equal 'task', body['kind']
85
87
  end
86
88
 
87
89
  test 'schedules polling after creating task' do
@@ -92,7 +94,7 @@ class TaskControllerTest < ActionController::TestCase
92
94
  )
93
95
 
94
96
  post :launch_task, params: {
95
- proxy_id: @proxy.id,
97
+ smart_proxy_id: @proxy.id,
96
98
  task_name: 'mymod::install',
97
99
  targets: 'host1.example.com',
98
100
  }, session: @session
@@ -103,7 +105,7 @@ class TaskControllerTest < ActionController::TestCase
103
105
  test 'creates a TaskJob record' do
104
106
  assert_difference('ForemanOpenbolt::TaskJob.count', 1) do
105
107
  post :launch_task, params: {
106
- proxy_id: @proxy.id,
108
+ smart_proxy_id: @proxy.id,
107
109
  task_name: 'mymod::install',
108
110
  targets: 'host1.example.com,host2.example.com',
109
111
  }, session: @session
@@ -116,15 +118,15 @@ class TaskControllerTest < ActionController::TestCase
116
118
  end
117
119
 
118
120
  test 'returns error when task_name is missing' do
119
- post :launch_task, params: { proxy_id: @proxy.id, targets: 'host1' }, session: @session
121
+ post :launch_task, params: { smart_proxy_id: @proxy.id, targets: 'host1' }, session: @session
120
122
  assert_response :bad_request
121
- assert_match(/task_name/, JSON.parse(response.body)['error'])
123
+ assert_match(/Task name and targets cannot be empty/, JSON.parse(response.body)['error']['message'])
122
124
  end
123
125
 
124
126
  test 'returns error when targets is missing' do
125
- post :launch_task, params: { proxy_id: @proxy.id, task_name: 'test::task' }, session: @session
127
+ post :launch_task, params: { smart_proxy_id: @proxy.id, task_name: 'test::task' }, session: @session
126
128
  assert_response :bad_request
127
- assert_match(/targets/, JSON.parse(response.body)['error'])
129
+ assert_match(/Task name and targets cannot be empty/, JSON.parse(response.body)['error']['message'])
128
130
  end
129
131
 
130
132
  test 'returns error when proxy returns error in response' do
@@ -133,12 +135,12 @@ class TaskControllerTest < ActionController::TestCase
133
135
  headers: { 'Content-Type' => 'application/json' })
134
136
 
135
137
  post :launch_task, params: {
136
- proxy_id: @proxy.id,
138
+ smart_proxy_id: @proxy.id,
137
139
  task_name: 'missing::task',
138
140
  targets: 'host1',
139
141
  }, session: @session
140
142
  assert_response :bad_request
141
- assert_match(/Task execution failed/, JSON.parse(response.body)['error'])
143
+ assert_match(/Task execution failed/, JSON.parse(response.body)['error']['message'])
142
144
  end
143
145
 
144
146
  test 'returns bad_gateway when proxy returns invalid JSON' do
@@ -147,12 +149,12 @@ class TaskControllerTest < ActionController::TestCase
147
149
  headers: { 'Content-Type' => 'application/json' })
148
150
 
149
151
  post :launch_task, params: {
150
- proxy_id: @proxy.id,
152
+ smart_proxy_id: @proxy.id,
151
153
  task_name: 'mymod::install',
152
154
  targets: 'host1',
153
155
  }, session: @session
154
156
  assert_response :bad_gateway
155
- assert_match(/Smart Proxy error/, JSON.parse(response.body)['error'])
157
+ assert_match(/Smart Proxy error/, JSON.parse(response.body)['error']['message'])
156
158
  end
157
159
 
158
160
  test 'returns error when proxy returns no job ID' do
@@ -161,12 +163,55 @@ class TaskControllerTest < ActionController::TestCase
161
163
  headers: { 'Content-Type' => 'application/json' })
162
164
 
163
165
  post :launch_task, params: {
164
- proxy_id: @proxy.id,
166
+ smart_proxy_id: @proxy.id,
165
167
  task_name: 'test::task',
166
168
  targets: 'host1',
167
169
  }, session: @session
168
170
  assert_response :bad_request
169
- assert_match(/No job ID returned/, JSON.parse(response.body)['error'])
171
+ assert_match(/No job ID returned/, JSON.parse(response.body)['error']['message'])
172
+ end
173
+
174
+ test 'rejects whitespace-only task_name and targets' do
175
+ post :launch_task, params: {
176
+ smart_proxy_id: @proxy.id,
177
+ task_name: ' ',
178
+ targets: ' ',
179
+ }, session: @session
180
+ assert_response :bad_request
181
+ assert_match(/Task name and targets cannot be empty/,
182
+ JSON.parse(response.body)['error']['message'])
183
+ end
184
+
185
+ test 'returns 500 with error shape when TaskJob persistence fails' do
186
+ ForemanOpenbolt::TaskJob.stubs(:create_from_execution!).raises(
187
+ ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new)
188
+ )
189
+
190
+ post :launch_task, params: {
191
+ smart_proxy_id: @proxy.id,
192
+ task_name: 'mymod::install',
193
+ targets: 'host1.example.com',
194
+ }, session: @session
195
+
196
+ assert_response :internal_server_error
197
+ body = JSON.parse(response.body)
198
+ assert_match(/launched-job-1/, body['error']['message'])
199
+ assert_match(/could not.*record/i, body['error']['message'])
200
+ end
201
+
202
+ test 'returns 500 with error shape when async polling cannot be scheduled' do
203
+ ForemanTasks.stubs(:async_task).raises(StandardError, 'dynflow down')
204
+
205
+ post :launch_task, params: {
206
+ smart_proxy_id: @proxy.id,
207
+ task_name: 'mymod::install',
208
+ targets: 'host1.example.com',
209
+ }, session: @session
210
+
211
+ assert_response :internal_server_error
212
+ body = JSON.parse(response.body)
213
+ assert_match(/launched-job-1/, body['error']['message'])
214
+ assert_match(/background polling could not be scheduled/i, body['error']['message'])
170
215
  end
171
216
  end
172
217
 
@@ -179,10 +224,11 @@ class TaskControllerTest < ActionController::TestCase
179
224
 
180
225
  body = JSON.parse(response.body)
181
226
  assert_equal 'running', body['status']
182
- assert_equal job.task_name, body['task_name']
227
+ assert_equal job.task_name, body['name']
183
228
  assert_equal job.targets, body['targets']
184
229
  assert_equal @proxy.id, body['smart_proxy']['id']
185
230
  assert_equal @proxy.name, body['smart_proxy']['name']
231
+ assert_equal 'task', body['kind']
186
232
  end
187
233
 
188
234
  test 'returns error when job_id is missing' do
@@ -209,6 +255,7 @@ class TaskControllerTest < ActionController::TestCase
209
255
  assert_equal job.result, body['value']
210
256
  assert_equal job.log, body['log']
211
257
  assert_equal job.command, body['command']
258
+ assert_equal 'task', body['kind']
212
259
  end
213
260
 
214
261
  test 'returns not_found when job does not exist' do
@@ -232,7 +279,7 @@ class TaskControllerTest < ActionController::TestCase
232
279
  Setting['openbolt_password'] = 'real-secret-password'
233
280
 
234
281
  post :launch_task, params: {
235
- proxy_id: @proxy.id,
282
+ smart_proxy_id: @proxy.id,
236
283
  task_name: 'mymod::install',
237
284
  targets: 'host1.example.com',
238
285
  options: { 'password' => '[Use saved encrypted default]', 'transport' => 'ssh' },
@@ -252,19 +299,19 @@ class TaskControllerTest < ActionController::TestCase
252
299
 
253
300
  test 'returns error when encrypted placeholder used for nonexistent setting' do
254
301
  post :launch_task, params: {
255
- proxy_id: @proxy.id,
302
+ smart_proxy_id: @proxy.id,
256
303
  task_name: 'mymod::install',
257
304
  targets: 'host1.example.com',
258
305
  options: { 'nonexistent-option' => '[Use saved encrypted default]' },
259
306
  }, session: @session
260
307
 
261
308
  assert_response :bad_request
262
- assert_match(/No saved value for encrypted option/, JSON.parse(response.body)['error'])
309
+ assert_match(/No saved value for encrypted option/, JSON.parse(response.body)['error']['message'])
263
310
  end
264
311
 
265
312
  test 'passes non-encrypted options through unchanged' do
266
313
  post :launch_task, params: {
267
- proxy_id: @proxy.id,
314
+ smart_proxy_id: @proxy.id,
268
315
  task_name: 'mymod::install',
269
316
  targets: 'host1.example.com',
270
317
  options: { 'transport' => 'ssh', 'user' => 'admin' },
@@ -277,7 +324,7 @@ class TaskControllerTest < ActionController::TestCase
277
324
  end
278
325
  end
279
326
 
280
- context 'fetch_openbolt_options with settings defaults' do
327
+ context 'task_options with settings defaults' do
281
328
  test 'merges Foreman setting defaults into proxy options' do
282
329
  Setting['openbolt_transport'] = 'winrm'
283
330
  Setting['openbolt_user'] = 'admin'
@@ -290,7 +337,7 @@ class TaskControllerTest < ActionController::TestCase
290
337
  stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
291
338
  .to_return(status: 200, body: proxy_options.to_json, headers: { 'Content-Type' => 'application/json' })
292
339
 
293
- get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
340
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
294
341
  assert_response :success
295
342
 
296
343
  body = JSON.parse(response.body)
@@ -308,15 +355,52 @@ class TaskControllerTest < ActionController::TestCase
308
355
  stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
309
356
  .to_return(status: 200, body: proxy_options.to_json, headers: { 'Content-Type' => 'application/json' })
310
357
 
311
- get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
358
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
312
359
  assert_response :success
313
360
 
314
361
  body = JSON.parse(response.body)
315
362
  assert_equal '[Use saved encrypted default]', body['password']['default']
316
363
  end
364
+
365
+ test 'omits default when a non-encrypted setting is set to empty string' do
366
+ # An empty-string setting must not override the proxy's real default.
367
+ Setting['openbolt_user'] = ''
368
+
369
+ proxy_options = {
370
+ 'user' => { 'type' => 'string', 'default' => 'bolt' },
371
+ }
372
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
373
+ .to_return(status: 200, body: proxy_options.to_json,
374
+ headers: { 'Content-Type' => 'application/json' })
375
+
376
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
377
+ assert_response :success
378
+
379
+ body = JSON.parse(response.body)
380
+ # Proxy default ('bolt') stays untouched. Empty Foreman setting doesn't override.
381
+ assert_equal 'bolt', body['user']['default']
382
+ end
383
+
384
+ test 'omits encrypted placeholder when an encrypted setting is set to empty string' do
385
+ Setting['openbolt_password'] = ''
386
+
387
+ proxy_options = {
388
+ 'password' => { 'type' => 'string', 'sensitive' => true },
389
+ }
390
+ stub_request(:get, "#{@proxy.url}/openbolt/tasks/options")
391
+ .to_return(status: 200, body: proxy_options.to_json,
392
+ headers: { 'Content-Type' => 'application/json' })
393
+
394
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
395
+ assert_response :success
396
+
397
+ body = JSON.parse(response.body)
398
+ assert_not body['password'].key?('default'),
399
+ "expected 'password' to have no 'default' key when the setting is empty"
400
+ end
317
401
  end
318
402
 
319
- context 'fetch_openbolt_options with Choria settings defaults' do
403
+ context 'task_options with Choria settings defaults' do
320
404
  # Mirrors the real GET /openbolt/tasks/options response for Choria
321
405
  # (see smart_proxy_openbolt/lib/smart_proxy_openbolt/main.rb OPENBOLT_OPTIONS).
322
406
  def self.choria_proxy_options
@@ -356,7 +440,7 @@ class TaskControllerTest < ActionController::TestCase
356
440
  .to_return(status: 200, body: self.class.choria_proxy_options.to_json,
357
441
  headers: { 'Content-Type' => 'application/json' })
358
442
 
359
- get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
443
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
360
444
  assert_response :success
361
445
 
362
446
  body = JSON.parse(response.body)
@@ -380,7 +464,7 @@ class TaskControllerTest < ActionController::TestCase
380
464
  .to_return(status: 200, body: self.class.choria_proxy_options.to_json,
381
465
  headers: { 'Content-Type' => 'application/json' })
382
466
 
383
- get :fetch_openbolt_options, params: { proxy_id: @proxy.id }, session: @session
467
+ get :task_options, params: { smart_proxy_id: @proxy.id }, session: @session
384
468
  assert_response :success
385
469
 
386
470
  body = JSON.parse(response.body)
@@ -393,11 +477,11 @@ class TaskControllerTest < ActionController::TestCase
393
477
  end
394
478
  end
395
479
 
396
- context 'fetch_task_history' do
480
+ context 'jobs' do
397
481
  test 'returns paginated task history' do
398
482
  3.times { FactoryBot.create(:task_job, smart_proxy: @proxy) }
399
483
 
400
- get :fetch_task_history, params: { page: 1, per_page: 2 }, session: @session
484
+ get :jobs, params: { page: 1, per_page: 2 }, session: @session
401
485
  assert_response :success
402
486
 
403
487
  body = JSON.parse(response.body)
@@ -405,10 +489,11 @@ class TaskControllerTest < ActionController::TestCase
405
489
  assert_equal 3, body['total']
406
490
  assert_equal 1, body['page']
407
491
  assert_equal 2, body['per_page']
492
+ assert(body['results'].all? { |row| row['kind'] == 'task' })
408
493
  end
409
494
 
410
495
  test 'caps per_page at 100' do
411
- get :fetch_task_history, params: { per_page: 200 }, session: @session
496
+ get :jobs, params: { per_page: 200 }, session: @session
412
497
  assert_response :success
413
498
 
414
499
  body = JSON.parse(response.body)
@@ -416,11 +501,84 @@ class TaskControllerTest < ActionController::TestCase
416
501
  end
417
502
 
418
503
  test 'defaults per_page to 20' do
419
- get :fetch_task_history, session: @session
504
+ get :jobs, session: @session
420
505
  assert_response :success
421
506
 
422
507
  body = JSON.parse(response.body)
423
508
  assert_equal 20, body['per_page']
424
509
  end
510
+
511
+ test 'floors per_page at 1 when zero is requested' do
512
+ get :jobs, params: { per_page: 0 }, session: @session
513
+ assert_response :success
514
+
515
+ body = JSON.parse(response.body)
516
+ assert_equal 1, body['per_page']
517
+ end
518
+
519
+ test 'floors per_page at 1 when a negative value is requested' do
520
+ get :jobs, params: { per_page: -5 }, session: @session
521
+ assert_response :success
522
+
523
+ body = JSON.parse(response.body)
524
+ assert_equal 1, body['per_page']
525
+ end
526
+
527
+ test 'floors page at 1 when zero is requested' do
528
+ get :jobs, params: { page: 0 }, session: @session
529
+ assert_response :success
530
+
531
+ body = JSON.parse(response.body)
532
+ assert_equal 1, body['page']
533
+ end
534
+
535
+ test "per_page='all' returns every job in a single page" do
536
+ 4.times { FactoryBot.create(:task_job, smart_proxy: @proxy) }
537
+
538
+ get :jobs, params: { per_page: 'all' }, session: @session
539
+ assert_response :success
540
+
541
+ body = JSON.parse(response.body)
542
+ assert_equal 4, body['results'].length
543
+ assert_equal 4, body['per_page']
544
+ end
545
+
546
+ test "per_page='all' on empty DB does not 500" do
547
+ # Guards against will_paginate rejecting per_page: 0 when the table
548
+ # is empty. paginated_task_jobs floors the count at 1.
549
+ get :jobs, params: { per_page: 'all' }, session: @session
550
+ assert_response :success
551
+
552
+ body = JSON.parse(response.body)
553
+ assert_equal [], body['results']
554
+ assert_equal 0, body['total']
555
+ end
556
+ end
557
+
558
+ context 'unexpected errors' do
559
+ test 'renders a generic 500 without the exception message' do
560
+ ForemanOpenbolt::TaskJob.stubs(:includes).raises(StandardError, 'secret internals')
561
+
562
+ get :jobs, session: @session
563
+ assert_response :internal_server_error
564
+
565
+ body = JSON.parse(response.body)
566
+ assert_equal 'Internal server error', body['error']['message']
567
+ assert_no_match(/secret internals/, response.body)
568
+ end
569
+ end
570
+
571
+ context 'authorization' do
572
+ test 'forbids unprivileged users from launching tasks' do
573
+ unprivileged = FactoryBot.create(:user)
574
+ User.current = unprivileged
575
+
576
+ post :launch_task, params: {
577
+ smart_proxy_id: @proxy.id,
578
+ task_name: 'mymod::install',
579
+ targets: 'host1.example.com',
580
+ }, session: set_session_user(unprivileged)
581
+ assert_response :forbidden
582
+ end
425
583
  end
426
584
  end
@@ -14,6 +14,7 @@ RUN dnf module enable -y nodejs:22 postgresql:16 && \
14
14
  ruby ruby-devel rubygem-bundler \
15
15
  gcc gcc-c++ make \
16
16
  postgresql-devel postgresql \
17
+ libcap-devel \
17
18
  libffi-devel \
18
19
  nodejs npm \
19
20
  git-core \
@@ -5,38 +5,6 @@ require 'test_plugin_helper'
5
5
  class PollTaskStatusTest < ForemanOpenbolt::PluginTestCase
6
6
  include Dynflow::Testing
7
7
 
8
- context 'extract_proxy_error' do
9
- setup do
10
- @action = create_action(Actions::ForemanOpenbolt::PollTaskStatus)
11
- end
12
-
13
- test 'returns nil for nil response' do
14
- assert_nil @action.extract_proxy_error(nil)
15
- end
16
-
17
- test 'returns nil when no error key' do
18
- assert_nil @action.extract_proxy_error({ 'status' => 'running' })
19
- end
20
-
21
- test 'returns nil for empty error string' do
22
- assert_nil @action.extract_proxy_error({ 'error' => '' })
23
- end
24
-
25
- test 'returns string error directly' do
26
- assert_equal 'something broke', @action.extract_proxy_error({ 'error' => 'something broke' })
27
- end
28
-
29
- test 'returns message from hash error' do
30
- assert_equal 'detailed error', @action.extract_proxy_error({ 'error' => { 'message' => 'detailed error' } })
31
- end
32
-
33
- test 'returns stringified hash when no message key' do
34
- error_hash = { 'code' => 500 }
35
- result = @action.extract_proxy_error({ 'error' => error_hash })
36
- assert_equal error_hash.to_s, result
37
- end
38
- end
39
-
40
8
  context 'plan' do
41
9
  test 'stores job_id and proxy_id in input' do
42
10
  action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, 'job-123', 42)
@@ -103,7 +71,7 @@ class PollTaskStatusTest < ForemanOpenbolt::PluginTestCase
103
71
  assert_equal 'exception', @job.reload.status
104
72
  end
105
73
 
106
- test 'marks exception immediately on proxy application error' do
74
+ test 'marks exception immediately on proxy application error from job_status' do
107
75
  status_stub = stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
108
76
  .to_return(status: 200, body: { 'error' => { 'message' => 'Job not found: test-job' } }.to_json,
109
77
  headers: { 'Content-Type' => 'application/json' })
@@ -115,6 +83,71 @@ class PollTaskStatusTest < ForemanOpenbolt::PluginTestCase
115
83
  assert_equal 'exception', @job.reload.status
116
84
  end
117
85
 
86
+ test 'marks exception immediately on proxy application error from job_result' do
87
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
88
+ .to_return(status: 200, body: { 'status' => 'success' }.to_json,
89
+ headers: { 'Content-Type' => 'application/json' })
90
+ result_stub = stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/result")
91
+ .to_return(status: 200,
92
+ body: { 'error' => { 'message' => 'Result file not found for job' } }.to_json,
93
+ headers: { 'Content-Type' => 'application/json' })
94
+
95
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
96
+ run_action(action)
97
+
98
+ assert_requested(result_stub)
99
+ assert_equal 'exception', @job.reload.status
100
+ end
101
+
102
+ test 'does not strand the job when result fetch fails transiently after completion' do
103
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
104
+ .to_return(status: 200, body: { 'status' => 'success' }.to_json,
105
+ headers: { 'Content-Type' => 'application/json' })
106
+ result_stub = stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/result")
107
+ .to_return(status: 500, body: 'Internal Server Error')
108
+
109
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
110
+ run = run_action(action)
111
+
112
+ assert_requested(result_stub)
113
+ @job.reload
114
+ assert_equal 'running', @job.status
115
+ assert_nil @job.result
116
+ assert_equal 1, run.input[:retry_count]
117
+ end
118
+
119
+ test 'marks exception when the result fetch keeps failing past the retry limit after completion' do
120
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
121
+ .to_return(status: 200, body: { 'status' => 'success' }.to_json,
122
+ headers: { 'Content-Type' => 'application/json' })
123
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/result")
124
+ .to_return(status: 500, body: 'Internal Server Error')
125
+
126
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
127
+ action.input[:retry_count] = Actions::ForemanOpenbolt::PollTaskStatus::RETRY_LIMIT
128
+ run_action(action)
129
+
130
+ @job.reload
131
+ assert_equal 'exception', @job.status
132
+ assert_nil @job.result
133
+ end
134
+
135
+ test 'records completed status when proxy returns a blank result body' do
136
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
137
+ .to_return(status: 200, body: { 'status' => 'success' }.to_json,
138
+ headers: { 'Content-Type' => 'application/json' })
139
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/result")
140
+ .to_return(status: 200, body: {}.to_json,
141
+ headers: { 'Content-Type' => 'application/json' })
142
+
143
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
144
+ run_action(action)
145
+
146
+ @job.reload
147
+ assert_equal 'success', @job.status
148
+ assert_nil @job.result
149
+ end
150
+
118
151
  test 'marks exception immediately when proxy response has no status' do
119
152
  stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
120
153
  .to_return(status: 200, body: { 'unexpected' => 'data' }.to_json,
@@ -131,11 +164,56 @@ class PollTaskStatusTest < ForemanOpenbolt::PluginTestCase
131
164
  .to_return(status: 500, body: 'Internal Server Error')
132
165
 
133
166
  action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
134
- # Set retry count to just above the limit so the next error triggers exhaustion
135
167
  action.input[:retry_count] = Actions::ForemanOpenbolt::PollTaskStatus::RETRY_LIMIT
136
168
  run_action(action)
137
169
 
138
170
  assert_equal 'exception', @job.reload.status
139
171
  end
172
+
173
+ test 'does not raise when exception flip itself fails after retry exhaustion' do
174
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
175
+ .to_return(status: 500, body: 'Internal Server Error')
176
+ ::ForemanOpenbolt::TaskJob.any_instance.stubs(:update!).raises(
177
+ ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new)
178
+ )
179
+
180
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
181
+ action.input[:retry_count] = Actions::ForemanOpenbolt::PollTaskStatus::RETRY_LIMIT
182
+
183
+ assert_nothing_raised { run_action(action) }
184
+ assert_equal 'running', @job.reload.status
185
+ end
186
+
187
+ test 'does not raise when exception flip itself fails after proxy not found' do
188
+ ::ForemanOpenbolt::TaskJob.any_instance.stubs(:update!).raises(
189
+ ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new)
190
+ )
191
+
192
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, -1)
193
+
194
+ assert_nothing_raised { run_action(action) }
195
+ assert_equal 'running', @job.reload.status
196
+ end
197
+
198
+ test 'does not raise when exception flip itself fails after proxy-reported error' do
199
+ stub_request(:get, "#{@proxy.url}/openbolt/job/#{@job.job_id}/status")
200
+ .to_return(status: 200,
201
+ body: { 'error' => { 'message' => 'Job not found on proxy' } }.to_json,
202
+ headers: { 'Content-Type' => 'application/json' })
203
+ ::ForemanOpenbolt::TaskJob.any_instance.stubs(:update!).raises(
204
+ ActiveRecord::RecordInvalid.new(ForemanOpenbolt::TaskJob.new)
205
+ )
206
+
207
+ action = create_and_plan_action(Actions::ForemanOpenbolt::PollTaskStatus, @job.job_id, @proxy.id)
208
+ action.input[:retry_count] = 5
209
+
210
+ run = nil
211
+ assert_nothing_raised do
212
+ run = run_action(action)
213
+ end
214
+ # The stubbed update! never wrote 'exception'.
215
+ assert_equal 'running', @job.reload.status
216
+ assert_equal 5, run.input[:retry_count]
217
+ end
140
218
  end
141
219
  end