foreman_openbolt 1.0.0 → 1.1.1

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +190 -19
  3. data/Rakefile +17 -93
  4. data/app/controllers/foreman_openbolt/task_controller.rb +61 -49
  5. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +11 -10
  6. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +70 -60
  7. data/app/models/foreman_openbolt/task_job.rb +16 -17
  8. data/config/routes.rb +0 -1
  9. data/lib/foreman_openbolt/engine.rb +11 -11
  10. data/lib/foreman_openbolt/version.rb +1 -1
  11. data/lib/proxy_api/openbolt.rb +25 -9
  12. data/lib/tasks/foreman_openbolt_tasks.rake +1 -22
  13. data/locale/gemspec.rb +1 -1
  14. data/package.json +11 -15
  15. data/test/acceptance/acceptance_helper.rb +146 -0
  16. data/test/acceptance/docker/docker-compose.yml +69 -0
  17. data/test/acceptance/docker/foreman/Dockerfile +45 -0
  18. data/test/acceptance/docker/foreman/entrypoint.sh +26 -0
  19. data/test/acceptance/docker/target/Dockerfile +29 -0
  20. data/test/acceptance/docker/target/entrypoint.sh +11 -0
  21. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.json +30 -0
  22. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.sh +16 -0
  23. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.json +13 -0
  24. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.sh +3 -0
  25. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.json +8 -0
  26. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.sh +3 -0
  27. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.json +8 -0
  28. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.sh +2 -0
  29. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.json +14 -0
  30. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.sh +3 -0
  31. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.json +13 -0
  32. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.sh +9 -0
  33. data/test/acceptance/fixtures/openbolt.yml +7 -0
  34. data/test/acceptance/tests/error_handling_test.rb +40 -0
  35. data/test/acceptance/tests/host_selector_test.rb +31 -0
  36. data/test/acceptance/tests/launch_task_test.rb +96 -0
  37. data/test/acceptance/tests/parameter_table_test.rb +61 -0
  38. data/test/acceptance/tests/settings_test.rb +95 -0
  39. data/test/acceptance/tests/ssh_options_test.rb +77 -0
  40. data/test/acceptance/tests/task_execution_test.rb +40 -0
  41. data/test/acceptance/tests/task_history_test.rb +84 -0
  42. data/test/acceptance/tests/transport_options_test.rb +121 -0
  43. data/test/test_plugin_helper.rb +12 -3
  44. data/test/unit/controllers/task_controller_test.rb +351 -0
  45. data/test/unit/docker/Dockerfile +47 -0
  46. data/test/unit/docker/docker-compose.yml +33 -0
  47. data/test/unit/docker/entrypoint.sh +4 -0
  48. data/test/unit/factories/foreman_openbolt_factories.rb +39 -0
  49. data/test/unit/lib/actions/cleanup_proxy_artifacts_test.rb +51 -0
  50. data/test/unit/lib/actions/poll_task_status_test.rb +141 -0
  51. data/test/unit/lib/proxy_api/openbolt_test.rb +174 -0
  52. data/test/unit/models/task_job_test.rb +278 -0
  53. data/webpack/__mocks__/foremanReact/common/I18n.js +15 -0
  54. data/webpack/__mocks__/foremanReact/components/ToastsList/index.js +6 -0
  55. data/webpack/__mocks__/foremanReact/redux/API/index.js +11 -0
  56. data/webpack/src/Components/LaunchTask/FieldTable.js +8 -5
  57. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +74 -62
  58. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +11 -13
  59. data/webpack/src/Components/LaunchTask/HostSelector/index.js +28 -33
  60. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +3 -2
  61. data/webpack/src/Components/LaunchTask/ParameterField.js +2 -0
  62. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +2 -1
  63. data/webpack/src/Components/LaunchTask/TaskSelect.js +3 -3
  64. data/webpack/src/Components/LaunchTask/__tests__/EmptyContent.test.js +10 -0
  65. data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +83 -0
  66. data/webpack/src/Components/LaunchTask/__tests__/ParameterField.test.js +86 -0
  67. data/webpack/src/Components/LaunchTask/__tests__/ParametersSection.test.js +50 -0
  68. data/webpack/src/Components/LaunchTask/__tests__/SmartProxySelect.test.js +63 -0
  69. data/webpack/src/Components/LaunchTask/__tests__/TaskSelect.test.js +39 -0
  70. data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +90 -0
  71. data/webpack/src/Components/LaunchTask/hooks/__tests__/useSmartProxies.test.js +69 -0
  72. data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +103 -0
  73. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +9 -11
  74. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +12 -13
  75. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +6 -13
  76. data/webpack/src/Components/LaunchTask/index.js +9 -27
  77. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +29 -29
  78. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +9 -10
  79. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +7 -2
  80. data/webpack/src/Components/TaskExecution/ResultDisplay.js +13 -17
  81. data/webpack/src/Components/TaskExecution/TaskDetails.js +58 -67
  82. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDetails.test.js +47 -0
  83. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDisplay.test.js +29 -0
  84. data/webpack/src/Components/TaskExecution/__tests__/LoadingIndicator.test.js +25 -0
  85. data/webpack/src/Components/TaskExecution/__tests__/ResultDisplay.test.js +28 -0
  86. data/webpack/src/Components/TaskExecution/__tests__/TaskDetails.test.js +38 -0
  87. data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +80 -0
  88. data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +177 -0
  89. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +34 -33
  90. data/webpack/src/Components/TaskExecution/index.js +10 -12
  91. data/webpack/src/Components/TaskHistory/TaskPopover.js +9 -12
  92. data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +109 -0
  93. data/webpack/src/Components/TaskHistory/__tests__/TaskPopover.test.js +26 -0
  94. data/webpack/src/Components/TaskHistory/index.js +21 -29
  95. data/webpack/src/Components/common/HostsPopover.js +12 -3
  96. data/webpack/src/Components/common/__tests__/HostsPopover.test.js +20 -0
  97. data/webpack/src/Components/common/__tests__/helpers.test.js +135 -0
  98. data/webpack/src/Components/common/helpers.js +34 -5
  99. data/webpack/test_setup.js +34 -11
  100. metadata +65 -87
  101. data/test/factories/foreman_openbolt_factories.rb +0 -7
  102. data/test/unit/foreman_openbolt_test.rb +0 -13
  103. data/webpack/global_test_setup.js +0 -11
  104. data/webpack/webpack.config.js +0 -7
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ class ProxyApiOpenboltTest < ForemanOpenbolt::PluginTestCase
6
+ PROXY_URL = 'https://proxy.example.com:8443'
7
+
8
+ setup do
9
+ @api = ProxyAPI::Openbolt.new(url: PROXY_URL)
10
+ end
11
+
12
+ context 'task_names' do
13
+ test 'returns task names from fetched tasks' do
14
+ tasks = { 'mymod::install' => {}, 'mymod::mytask' => {} }
15
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
16
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
17
+
18
+ assert_equal %w[mymod::install mymod::mytask], @api.task_names
19
+ end
20
+ end
21
+
22
+ context 'fetch_tasks' do
23
+ test 'fetches and parses task list from proxy' do
24
+ tasks = { 'mymod::install' => { 'description' => 'Install a package' } }
25
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
26
+ .to_return(status: 200, body: tasks.to_json, headers: { 'Content-Type' => 'application/json' })
27
+
28
+ result = @api.fetch_tasks
29
+ assert_equal tasks, result
30
+ end
31
+
32
+ test 'raises on proxy HTTP error' do
33
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
34
+ .to_return(status: 500, body: 'Internal Server Error')
35
+
36
+ assert_raises(RestClient::InternalServerError) { @api.fetch_tasks }
37
+ end
38
+
39
+ test 'raises ProxyException on unparseable response body' do
40
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
41
+ .to_return(status: 200, body: nil)
42
+
43
+ assert_raises(ProxyAPI::ProxyException) { @api.fetch_tasks }
44
+ end
45
+
46
+ test 'raises ProxyException on invalid JSON' do
47
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
48
+ .to_return(status: 200, body: 'not json', headers: { 'Content-Type' => 'text/plain' })
49
+
50
+ assert_raises(ProxyAPI::ProxyException) { @api.fetch_tasks }
51
+ end
52
+ end
53
+
54
+ context 'reload_tasks' do
55
+ test 'fetches from reload endpoint and updates cached tasks' do
56
+ original_tasks = { 'mymod::install' => { 'description' => 'Install something' } }
57
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks")
58
+ .to_return(status: 200, body: original_tasks.to_json, headers: { 'Content-Type' => 'application/json' })
59
+
60
+ assert_equal original_tasks, @api.tasks
61
+
62
+ reloaded_tasks = { 'mymod::install' => {}, 'mymod::mytask' => { 'description' => 'A new task' } }
63
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks/reload")
64
+ .to_return(status: 200, body: reloaded_tasks.to_json, headers: { 'Content-Type' => 'application/json' })
65
+
66
+ result = @api.reload_tasks
67
+ assert_equal reloaded_tasks, result
68
+ assert_equal reloaded_tasks, @api.tasks
69
+ end
70
+ end
71
+
72
+ context 'openbolt_options' do
73
+ test 'fetches and parses options from proxy' do
74
+ options = { 'transport' => { 'type' => 'string', 'default' => 'ssh' } }
75
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks/options")
76
+ .to_return(status: 200, body: options.to_json, headers: { 'Content-Type' => 'application/json' })
77
+
78
+ result = @api.openbolt_options
79
+ assert_equal options, result
80
+ end
81
+
82
+ test 'memoizes the result' do
83
+ options = { 'transport' => { 'type' => 'string' } }
84
+ stub = stub_request(:get, "#{PROXY_URL}/openbolt/tasks/options")
85
+ .to_return(status: 200, body: options.to_json, headers: { 'Content-Type' => 'application/json' })
86
+
87
+ @api.openbolt_options
88
+ @api.openbolt_options
89
+ assert_requested(stub, times: 1)
90
+ end
91
+ end
92
+
93
+ context 'launch_task' do
94
+ test 'posts task request and returns parsed response' do
95
+ response_body = { 'id' => 'job-abc-123' }
96
+ stub_request(:post, "#{PROXY_URL}/openbolt/launch/task")
97
+ .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
98
+
99
+ result = @api.launch_task(
100
+ name: 'mymod::install',
101
+ targets: 'host1.example.com',
102
+ parameters: { 'name' => 'nginx' },
103
+ options: { 'transport' => 'ssh' }
104
+ )
105
+ assert_equal response_body, result
106
+ end
107
+
108
+ test 'sends correct JSON payload with parameters, options, and targets' do
109
+ stub = stub_request(:post, "#{PROXY_URL}/openbolt/launch/task")
110
+ .with do |request|
111
+ body = JSON.parse(request.body)
112
+ body['name'] == 'mymod::mytask' &&
113
+ body['targets'] == 'host1.example.com,host2.example.com' &&
114
+ body['parameters'] == { 'name' => 'nginx', 'version' => '1.0' } &&
115
+ body['options'] == { 'transport' => 'ssh', 'run-as' => 'root' }
116
+ end
117
+ .to_return(status: 200, body: '{"id": "job-1"}', headers: { 'Content-Type' => 'application/json' })
118
+
119
+ @api.launch_task(
120
+ name: 'mymod::mytask',
121
+ targets: 'host1.example.com,host2.example.com',
122
+ parameters: { 'name' => 'nginx', 'version' => '1.0' },
123
+ options: { 'transport' => 'ssh', 'run-as' => 'root' }
124
+ )
125
+ assert_requested(stub)
126
+ end
127
+ end
128
+
129
+ context 'job_status' do
130
+ test 'fetches job status by ID' do
131
+ status_body = { 'status' => 'running' }
132
+ stub_request(:get, "#{PROXY_URL}/openbolt/job/test-123/status")
133
+ .to_return(status: 200, body: status_body.to_json, headers: { 'Content-Type' => 'application/json' })
134
+
135
+ result = @api.job_status(job_id: 'test-123')
136
+ assert_equal status_body, result
137
+ end
138
+ end
139
+
140
+ context 'job_result' do
141
+ test 'fetches job result by ID' do
142
+ result_body = { 'value' => { 'items' => [] }, 'log' => 'done' }
143
+ stub_request(:get, "#{PROXY_URL}/openbolt/job/test-123/result")
144
+ .to_return(status: 200, body: result_body.to_json, headers: { 'Content-Type' => 'application/json' })
145
+
146
+ result = @api.job_result(job_id: 'test-123')
147
+ assert_equal result_body, result
148
+ end
149
+ end
150
+
151
+ context 'delete_job_artifacts' do
152
+ test 'sends DELETE for job artifacts' do
153
+ stub_request(:delete, "#{PROXY_URL}/openbolt/job/test-123/artifacts")
154
+ .to_return(status: 200, body: '{"deleted": true}', headers: { 'Content-Type' => 'application/json' })
155
+
156
+ result = @api.delete_job_artifacts(job_id: 'test-123')
157
+ assert_equal({ 'deleted' => true }, result)
158
+ end
159
+ end
160
+
161
+ context 'connection errors' do
162
+ test 'raises on timeout' do
163
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks").to_timeout
164
+
165
+ assert_raises(RestClient::Exceptions::OpenTimeout) { @api.fetch_tasks }
166
+ end
167
+
168
+ test 'raises on connection refused' do
169
+ stub_request(:get, "#{PROXY_URL}/openbolt/tasks").to_raise(Errno::ECONNREFUSED)
170
+
171
+ assert_raises(Errno::ECONNREFUSED) { @api.fetch_tasks }
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_plugin_helper'
4
+
5
+ class TaskJobTest < ForemanOpenbolt::PluginTestCase
6
+ context 'status helpers' do
7
+ test 'completed? returns true for completed statuses' do
8
+ ForemanOpenbolt::TaskJob::COMPLETED_STATUSES.each do |status|
9
+ job = FactoryBot.build(:task_job, status: status)
10
+ assert job.completed?, "Expected completed? to be true for status '#{status}'"
11
+ end
12
+ end
13
+
14
+ test 'completed? returns false for running statuses' do
15
+ ForemanOpenbolt::TaskJob::RUNNING_STATUSES.each do |status|
16
+ job = FactoryBot.build(:task_job, status: status)
17
+ assert_not job.completed?, "Expected completed? to be false for status '#{status}'"
18
+ end
19
+ end
20
+
21
+ test 'running? returns true for running statuses' do
22
+ ForemanOpenbolt::TaskJob::RUNNING_STATUSES.each do |status|
23
+ job = FactoryBot.build(:task_job, status: status)
24
+ assert job.running?, "Expected running? to be true for status '#{status}'"
25
+ end
26
+ end
27
+
28
+ test 'running? returns false for completed statuses' do
29
+ ForemanOpenbolt::TaskJob::COMPLETED_STATUSES.each do |status|
30
+ job = FactoryBot.build(:task_job, status: status)
31
+ assert_not job.running?, "Expected running? to be false for status '#{status}'"
32
+ end
33
+ end
34
+ end
35
+
36
+ context 'duration' do
37
+ # submitted_at could be nil for old rows that predate the column
38
+ test 'returns nil when submitted_at is nil' do
39
+ job = FactoryBot.build(:task_job, submitted_at: nil)
40
+ assert_nil job.duration
41
+ end
42
+
43
+ test 'returns nil when completed_at is nil' do
44
+ job = FactoryBot.build(:task_job, completed_at: nil)
45
+ assert_nil job.duration
46
+ end
47
+
48
+ test 'returns difference in seconds when both timestamps present' do
49
+ submitted = Time.current
50
+ completed = submitted + 45.seconds
51
+ job = FactoryBot.build(:task_job, submitted_at: submitted, completed_at: completed)
52
+ assert_in_delta 45.0, job.duration, 0.1
53
+ end
54
+ end
55
+
56
+ context 'target_count' do
57
+ test 'returns 0 when targets is nil' do
58
+ job = FactoryBot.build(:task_job)
59
+ job.targets = nil
60
+ assert_equal 0, job.target_count
61
+ end
62
+
63
+ test 'returns correct count for populated targets' do
64
+ job = FactoryBot.build(:task_job, targets: ['a.com', 'b.com', 'c.com'])
65
+ assert_equal 3, job.target_count
66
+ end
67
+ end
68
+
69
+ context 'formatted_targets' do
70
+ test 'returns empty string when targets is nil' do
71
+ job = FactoryBot.build(:task_job)
72
+ job.targets = nil
73
+ assert_equal '', job.formatted_targets
74
+ end
75
+
76
+ test 'returns CSV string for populated targets' do
77
+ job = FactoryBot.build(:task_job, targets: ['host1.com', 'host2.com'])
78
+ assert_equal 'host1.com, host2.com', job.formatted_targets
79
+ end
80
+ end
81
+
82
+ context 'validations' do
83
+ test 'valid with all required attributes' do
84
+ job = FactoryBot.build(:task_job)
85
+ assert job.valid?
86
+ end
87
+
88
+ test 'invalid without task_name' do
89
+ job = FactoryBot.build(:task_job, task_name: nil)
90
+ assert_not job.valid?
91
+ assert_includes job.errors[:task_name], "can't be blank"
92
+ end
93
+
94
+ test 'invalid without job_id' do
95
+ job = FactoryBot.build(:task_job, job_id: nil)
96
+ assert_not job.valid?
97
+ assert_includes job.errors[:job_id], "can't be blank"
98
+ end
99
+
100
+ test 'invalid with duplicate job_id' do
101
+ FactoryBot.create(:task_job, job_id: 'unique-id')
102
+ duplicate = FactoryBot.build(:task_job, job_id: 'unique-id')
103
+ assert_not duplicate.valid?
104
+ assert_includes duplicate.errors[:job_id], 'has already been taken'
105
+ end
106
+
107
+ test 'invalid with unknown status' do
108
+ job = FactoryBot.build(:task_job, status: 'unknown')
109
+ assert_not job.valid?
110
+ assert_includes job.errors[:status], 'is not included in the list'
111
+ end
112
+
113
+ test 'invalid without targets' do
114
+ job = FactoryBot.build(:task_job, targets: nil)
115
+ assert_not job.valid?
116
+ assert_includes job.errors[:targets], "can't be blank"
117
+ end
118
+ end
119
+
120
+ context 'scopes' do
121
+ setup do
122
+ @proxy = FactoryBot.create(:smart_proxy)
123
+ @pending_job = FactoryBot.create(:task_job, smart_proxy: @proxy, status: 'pending')
124
+ @running_job = FactoryBot.create(:task_job, smart_proxy: @proxy, status: 'running')
125
+ @success_job = FactoryBot.create(:task_job, :success, smart_proxy: @proxy)
126
+ @failure_job = FactoryBot.create(:task_job, :failure, smart_proxy: @proxy)
127
+ end
128
+
129
+ test 'running scope returns pending and running jobs' do
130
+ results = ForemanOpenbolt::TaskJob.running
131
+ assert_includes results, @pending_job
132
+ assert_includes results, @running_job
133
+ assert_not_includes results, @success_job
134
+ assert_not_includes results, @failure_job
135
+ end
136
+
137
+ test 'completed scope returns completed jobs' do
138
+ results = ForemanOpenbolt::TaskJob.completed
139
+ assert_includes results, @success_job
140
+ assert_includes results, @failure_job
141
+ assert_not_includes results, @pending_job
142
+ assert_not_includes results, @running_job
143
+ end
144
+
145
+ test 'recent scope orders by submitted_at' do
146
+ results = ForemanOpenbolt::TaskJob.recent
147
+ submitted_times = results.map(&:submitted_at)
148
+ assert_equal submitted_times, submitted_times.sort.reverse
149
+ end
150
+
151
+ test 'for_proxy scope filters by smart proxy' do
152
+ other_proxy = FactoryBot.create(:smart_proxy)
153
+ other_job = FactoryBot.create(:task_job, smart_proxy: other_proxy)
154
+
155
+ results = ForemanOpenbolt::TaskJob.for_proxy(@proxy)
156
+ assert_includes results, @pending_job
157
+ assert_not_includes results, other_job
158
+ end
159
+ end
160
+
161
+ context 'callbacks' do
162
+ test 'set_completed_at sets completed_at when status changes to completed' do
163
+ job = FactoryBot.create(:task_job, status: 'running')
164
+ assert_nil job.completed_at
165
+
166
+ job.update!(status: 'success')
167
+ assert_not_nil job.completed_at
168
+ end
169
+
170
+ test 'set_completed_at does not set completed_at when status stays running' do
171
+ job = FactoryBot.create(:task_job, status: 'running')
172
+ job.update!(task_name: 'different::task')
173
+ assert_nil job.completed_at
174
+ end
175
+
176
+ test 'cleanup_proxy_artifacts schedules Dynflow action when result is saved' do
177
+ job = FactoryBot.create(:task_job, status: 'success', completed_at: Time.current)
178
+ ForemanTasks.expects(:async_task).with(
179
+ ::Actions::ForemanOpenbolt::CleanupProxyArtifacts,
180
+ job.smart_proxy_id,
181
+ job.job_id
182
+ )
183
+
184
+ job.update!(result: { 'items' => [] })
185
+ end
186
+
187
+ test 'cleanup_proxy_artifacts does not schedule cleanup for running jobs' do
188
+ job = FactoryBot.create(:task_job, status: 'running')
189
+ ForemanTasks.expects(:async_task).never
190
+
191
+ job.update!(result: { 'items' => [] })
192
+ end
193
+
194
+ test 'cleanup_proxy_artifacts does not raise when scheduling fails' do
195
+ job = FactoryBot.create(:task_job, status: 'success', completed_at: Time.current)
196
+ ForemanTasks.expects(:async_task).raises(StandardError, 'Dynflow unavailable')
197
+
198
+ assert_nothing_raised do
199
+ job.update!(result: { 'items' => [] })
200
+ end
201
+ assert_equal({ 'items' => [] }, job.reload.result)
202
+ end
203
+ end
204
+
205
+ context 'create_from_execution!' do
206
+ test 'creates a task job with the correct attributes' do
207
+ proxy = FactoryBot.create(:smart_proxy)
208
+ job = ForemanOpenbolt::TaskJob.create_from_execution!(
209
+ proxy: proxy,
210
+ task_name: 'mymod::mytask',
211
+ task_description: 'Restart a service',
212
+ targets: ['web1.example.com'],
213
+ job_id: 'exec-123',
214
+ parameters: { 'service' => 'nginx' },
215
+ options: { 'transport' => 'ssh' }
216
+ )
217
+
218
+ assert_equal 'exec-123', job.job_id
219
+ assert_equal proxy, job.smart_proxy
220
+ assert_equal 'mymod::mytask', job.task_name
221
+ assert_equal 'Restart a service', job.task_description
222
+ assert_equal ['web1.example.com'], job.targets
223
+ assert_equal({ 'service' => 'nginx' }, job.task_parameters)
224
+ assert_equal({ 'transport' => 'ssh' }, job.openbolt_options)
225
+ assert_equal 'pending', job.status
226
+ assert_not_nil job.submitted_at
227
+ end
228
+ end
229
+
230
+ context 'update_from_proxy_result!' do
231
+ setup do
232
+ @job = FactoryBot.create(:task_job, status: 'running')
233
+ ForemanTasks.stubs(:async_task)
234
+ end
235
+
236
+ test 'updates status, command, result, and log from proxy result' do
237
+ @job.update_from_proxy_result!({
238
+ 'status' => 'success',
239
+ 'command' => 'bolt task run mymod::install',
240
+ 'value' => { 'items' => [{ 'status' => 'success' }] },
241
+ 'log' => 'Task completed successfully',
242
+ })
243
+
244
+ @job.reload
245
+ assert_equal 'success', @job.status
246
+ assert_equal 'bolt task run mymod::install', @job.command
247
+ assert_equal({ 'items' => [{ 'status' => 'success' }] }, @job.result)
248
+ assert_equal 'Task completed successfully', @job.log
249
+ end
250
+
251
+ test 'skips update for blank proxy result' do
252
+ original_status = @job.status
253
+ @job.update_from_proxy_result!(nil)
254
+ assert_equal original_status, @job.reload.status
255
+ end
256
+
257
+ test 'skips update for empty hash' do
258
+ original_status = @job.status
259
+ @job.update_from_proxy_result!({})
260
+ assert_equal original_status, @job.reload.status
261
+ end
262
+
263
+ test 'only updates fields present in the result' do
264
+ @job.update_from_proxy_result!({ 'status' => 'failure' })
265
+ @job.reload
266
+ assert_equal 'failure', @job.status
267
+ assert_nil @job.command
268
+ assert_nil @job.result
269
+ end
270
+
271
+ test 'sets result to nil when key is present with nil value' do
272
+ @job.update!(result: { 'items' => [{ 'status' => 'success' }] })
273
+ @job.update_from_proxy_result!({ 'status' => 'success', 'value' => nil })
274
+ @job.reload
275
+ assert_nil @job.result
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,15 @@
1
+ // Mock for Foreman's I18n module. The real module depends on jed (gettext)
2
+ // and Foreman's runtime locale configuration, which aren't available in
3
+ // the plugin test environment. sprintf is mocked with minimal %s/%d
4
+ // replacement so rendered strings are testable.
5
+ export const translate = text => text;
6
+
7
+ export const sprintf = (fmt, ...args) => {
8
+ let result = fmt;
9
+ args.forEach(arg => {
10
+ result = result.replace(/%[sd]/, arg);
11
+ });
12
+ return result;
13
+ };
14
+
15
+ export default { translate, sprintf };
@@ -0,0 +1,6 @@
1
+ // Mock for Foreman's toast notification system. Uses jest.fn() so tests
2
+ // can assert that error/success toasts are dispatched correctly.
3
+ export const addToast = jest.fn(toast => ({
4
+ type: 'ADD_TOAST',
5
+ payload: toast,
6
+ }));
@@ -0,0 +1,11 @@
1
+ // Mock for Foreman's API wrapper. Uses jest.fn() so tests can stub
2
+ // responses with mockResolvedValue/mockRejectedValue and assert calls.
3
+ export const API = {
4
+ get: jest.fn(),
5
+ post: jest.fn(),
6
+ put: jest.fn(),
7
+ delete: jest.fn(),
8
+ patch: jest.fn(),
9
+ };
10
+
11
+ export default API;
@@ -37,12 +37,13 @@ const FieldTable = ({ rows }) => {
37
37
  isStickyHeader
38
38
  gridBreakPoint="grid-md"
39
39
  style={{ wordBreak: 'break-word' }}
40
+ aria-label={__('Parameters and options')}
40
41
  >
41
42
  <Thead>
42
43
  <Tr>
43
44
  <Th aria-label="Row expand control" />
44
- <Th width={25}>Name</Th>
45
- <Th width={75}>Value</Th>
45
+ <Th width={25}>{__('Name')}</Th>
46
+ <Th width={75}>{__('Value')}</Th>
46
47
  </Tr>
47
48
  </Thead>
48
49
 
@@ -74,7 +75,7 @@ const FieldTable = ({ rows }) => {
74
75
  onToggle: () => toggle(rowKey),
75
76
  }}
76
77
  />
77
- <Td dataLabel="Name">
78
+ <Td dataLabel={__('Name')}>
78
79
  <span className="pf-v5-u-font-family-monospace">{name}</span>
79
80
  {required && (
80
81
  <span
@@ -82,13 +83,15 @@ const FieldTable = ({ rows }) => {
82
83
  color: 'red',
83
84
  marginLeft: '0.25rem',
84
85
  }}
85
- title="Required"
86
+ title={__('Required')}
87
+ role="img"
88
+ aria-label={__('Required')}
86
89
  >
87
90
  *
88
91
  </span>
89
92
  )}
90
93
  </Td>
91
- <Td dataLabel="Value">{valueCell}</Td>
94
+ <Td dataLabel={__('Value')}>{valueCell}</Td>
92
95
  </Tr>
93
96
 
94
97
  {(type || description) && (