foreman_remote_execution 16.5.2 → 16.5.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffbf84ca570a940e3062ae1116e4125f4917c297fb1f9123f4237dc98b8fe883
4
- data.tar.gz: f98d79b128655e08cf36257c002d55b54c74213d4b90c3abd98d0a22f6e69790
3
+ metadata.gz: 4d4a947b587075d0d60662d365b36c3fab8006b958a363c39354b1f67f771c69
4
+ data.tar.gz: 16bc29adc59c2a3bf1605902aece3e9d0f2bbd67749f4bc1a429ae52507165c8
5
5
  SHA512:
6
- metadata.gz: d10fc19094f9473ba0c16e19205e7b45c33c156fe90fcdd774249d319eb5140ce9d4fb627ed071f45c3885178bd7a81cf606e7fa68f87dc09910422f012b1c5f
7
- data.tar.gz: 0c88bca591e83c4a7590ab018eb52a1b8e7110f20ec6cb5a0d8b8dab9ffab69eef5a943b223d33cbf6ed70c2cf12b4bfe93f105a84c0747dc3b226dfcdf51749
6
+ metadata.gz: 3766b56254d55dd3f520ca16cca021d558362db4a34ccb15fb6833124a14f5900deefc78fc4125b4128c28f8eafd6d41f9c97d5526702a66ff32eb28665cde6f
7
+ data.tar.gz: 1cb89677b91f21c07296af85bd5b18c993572965e701d8b8e55d0880ff5e5fc81b7d7a56a6a053a916309825489c3ec90d929092382f5dd1630fb44fd599a6fc
@@ -109,11 +109,11 @@ module ForemanRemoteExecution
109
109
 
110
110
  def remote_execution_ssh_keys
111
111
  # only include public keys from SSH proxies that don't have SSH cert verification configured
112
- remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey if proxy.ca_pubkey.blank? }.compact.uniq
112
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey(refresh: false) if proxy.ca_pubkey(refresh: false).blank? }.compact.uniq
113
113
  end
114
114
 
115
115
  def remote_execution_ssh_ca_keys
116
- remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.ca_pubkey }.compact.uniq
116
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.ca_pubkey(refresh: false) }.compact.uniq
117
117
  end
118
118
 
119
119
  def drop_execution_interface_cache
@@ -7,12 +7,12 @@ module ForemanRemoteExecution
7
7
  end
8
8
  end
9
9
 
10
- def pubkey
11
- self[:pubkey] || update_pubkey
10
+ def pubkey(refresh: true)
11
+ self[:pubkey] || (refresh && update_pubkey || nil)
12
12
  end
13
13
 
14
- def ca_pubkey
15
- self[:ca_pubkey] || update_ca_pubkey
14
+ def ca_pubkey(refresh: true)
15
+ self[:ca_pubkey] || (refresh && update_ca_pubkey || nil)
16
16
  end
17
17
 
18
18
  def update_pubkey
@@ -1 +1 @@
1
- attribute :ca_pubkey => :remote_execution_ca_pubkey
1
+ node(:remote_execution_ca_pubkey) { |p| p.ca_pubkey(refresh: false) }
@@ -1 +1 @@
1
- attribute :pubkey => :remote_execution_pubkey
1
+ node(:remote_execution_pubkey) { |p| p.pubkey(refresh: false) }
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.5.2'.freeze
2
+ VERSION = '16.5.3'.freeze
3
3
  end
@@ -11,8 +11,9 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
11
11
  let(:sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQ foo@example.com' }
12
12
 
13
13
  before do
14
- SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
15
- SmartProxy.any_instance.stubs(:ca_pubkey).returns(nil)
14
+ host.subnet.remote_execution_proxies.each do |proxy|
15
+ proxy.update(pubkey: sshkey, ca_pubkey: nil)
16
+ end
16
17
  Setting[:remote_execution_ssh_user] = 'root'
17
18
  Setting[:remote_execution_effective_user_method] = 'sudo'
18
19
  end
@@ -60,6 +61,17 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
60
61
  User.current = nil
61
62
  assert_includes host.remote_execution_ssh_keys, sshkey
62
63
  end
64
+
65
+ it 'triggers no calls to the proxy' do
66
+ host.subnet.remote_execution_proxies.each do |proxy|
67
+ proxy.update(pubkey: nil)
68
+ end
69
+
70
+ SmartProxy.any_instance.expects(:update_pubkey).never
71
+ SmartProxy.any_instance.expects(:update_ca_pubkey).never
72
+
73
+ host.host_param('remote_execution_ssh_keys')
74
+ end
63
75
  end
64
76
 
65
77
  describe 'has ssh CA key configured' do
@@ -68,8 +80,9 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
68
80
  let(:ca_sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJE bar@example.com' }
69
81
 
70
82
  before do
71
- SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
72
- SmartProxy.any_instance.stubs(:ca_pubkey).returns(ca_sshkey)
83
+ host.subnet.remote_execution_proxies.each do |proxy|
84
+ proxy.update(pubkey: sshkey, ca_pubkey: ca_sshkey)
85
+ end
73
86
  Setting[:remote_execution_ssh_user] = 'root'
74
87
  Setting[:remote_execution_effective_user_method] = 'sudo'
75
88
  end
@@ -102,6 +115,17 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
102
115
  assert_includes host.host_param('remote_execution_ssh_ca_keys'), key
103
116
  assert_includes host.host_param('remote_execution_ssh_ca_keys'), ca_sshkey
104
117
  end
118
+
119
+ it 'triggers no calls to the proxy' do
120
+ host.subnet.remote_execution_proxies.each do |proxy|
121
+ proxy.update(ca_pubkey: nil)
122
+ end
123
+
124
+ SmartProxy.any_instance.expects(:update_pubkey).never
125
+ SmartProxy.any_instance.expects(:update_ca_pubkey).never
126
+
127
+ host.host_param('remote_execution_ssh_ca_keys')
128
+ end
105
129
  end
106
130
 
107
131
  context 'host has multiple nics' do
@@ -0,0 +1,83 @@
1
+ require 'test_plugin_helper'
2
+
3
+ class ForemanRemoteExecutionSmartProxyExtensionsTest < ActiveSupport::TestCase
4
+ let(:proxy) { FactoryBot.create(:smart_proxy, :ssh) }
5
+
6
+ describe '#pubkey' do
7
+ context 'when key is cached in the database' do
8
+ it 'returns the cached key without fetching from proxy' do
9
+ proxy.expects(:update_pubkey).never
10
+ assert_equal 'ssh-rsa AAAAB3N...', proxy.pubkey
11
+ assert_equal 'ssh-rsa AAAAB3N...', proxy.pubkey(refresh: false)
12
+ end
13
+ end
14
+
15
+ context 'when key is not cached' do
16
+ before { proxy.update(pubkey: nil) }
17
+
18
+ it 'fetches from proxy by default' do
19
+ proxy.expects(:update_pubkey).returns('ssh-rsa FETCHED...')
20
+ assert_equal 'ssh-rsa FETCHED...', proxy.pubkey
21
+ end
22
+
23
+ it 'returns nil without fetching when refresh: false' do
24
+ proxy.expects(:update_pubkey).never
25
+ assert_nil proxy.pubkey(refresh: false)
26
+ end
27
+ end
28
+ end
29
+
30
+ describe '#ca_pubkey' do
31
+ context 'when key is cached in the database' do
32
+ before { proxy.update(ca_pubkey: 'ssh-rsa CA_KEY...') }
33
+
34
+ it 'returns the cached key without fetching from proxy' do
35
+ proxy.expects(:update_ca_pubkey).never
36
+ assert_equal 'ssh-rsa CA_KEY...', proxy.ca_pubkey
37
+ assert_equal 'ssh-rsa CA_KEY...', proxy.ca_pubkey(refresh: false)
38
+ end
39
+ end
40
+
41
+ context 'when key is not cached' do
42
+ before { proxy.update(ca_pubkey: nil) }
43
+
44
+ it 'fetches from proxy by default' do
45
+ proxy.expects(:update_ca_pubkey).returns('ssh-rsa FETCHED_CA...')
46
+ assert_equal 'ssh-rsa FETCHED_CA...', proxy.ca_pubkey
47
+ end
48
+
49
+ it 'returns nil without fetching when refresh: false' do
50
+ proxy.expects(:update_ca_pubkey).never
51
+ assert_nil proxy.ca_pubkey(refresh: false)
52
+ end
53
+ end
54
+ end
55
+
56
+ describe '#refresh' do
57
+ context 'when the key is cached' do
58
+ before do
59
+ proxy.update(pubkey: 'ssh-rsa KEY...', ca_pubkey: 'ssh-rsa CA_KEY...')
60
+ end
61
+
62
+ it 'fetches the keys' do
63
+ proxy.expects(:update_pubkey).once
64
+ proxy.expects(:update_ca_pubkey).once
65
+
66
+ proxy.refresh
67
+ end
68
+ end
69
+
70
+ context 'when the key is not cached' do
71
+ before do
72
+ proxy.update(pubkey: nil, ca_pubkey: nil)
73
+ end
74
+
75
+ it 'fetches the keys' do
76
+ proxy.expects(:update_pubkey).once
77
+ proxy.expects(:update_ca_pubkey).once
78
+
79
+ proxy.refresh
80
+ end
81
+ end
82
+ end
83
+ end
@@ -11,6 +11,10 @@ import { mockTemplateInvocationResponse } from './fixtures';
11
11
  jest.spyOn(api, 'get');
12
12
  jest.mock('../JobInvocationSelectors');
13
13
 
14
+ jest.mock('foremanReact/components/ToastsList', () => ({
15
+ addToast: jest.fn(payload => ({ type: 'ADD_TOAST', payload })),
16
+ }));
17
+
14
18
  const mockStore = configureMockStore([]);
15
19
  const store = mockStore({
16
20
  HOSTS_API: {
@@ -186,4 +190,60 @@ describe('TemplateInvocation', () => {
186
190
  await screen.findByText('Successfully copied to clipboard!')
187
191
  ).toBeInTheDocument();
188
192
  });
193
+
194
+ describe('Cancel/Abort task buttons API calls', () => {
195
+ const responseWithCancellableTask = {
196
+ ...mockTemplateInvocationResponse,
197
+ task: { id: 'task-123', cancellable: true },
198
+ permissions: {
199
+ view_foreman_tasks: true,
200
+ cancel_job_invocations: true,
201
+ execute_jobs: true,
202
+ },
203
+ };
204
+
205
+ beforeEach(() => {
206
+ selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
207
+ 'RESOLVED'
208
+ );
209
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
210
+ responseWithCancellableTask
211
+ );
212
+ jest.spyOn(api.APIActions, 'post').mockReturnValue({ type: 'MOCK_POST' });
213
+ });
214
+
215
+ test('clicking the `Cancel Task` button calls API with cancel param', () => {
216
+ render(
217
+ <Provider store={store}>
218
+ <TemplateInvocation {...mockProps} />
219
+ </Provider>
220
+ );
221
+ fireEvent.click(screen.getByText('Cancel Task'));
222
+
223
+ const postCall = api.APIActions.post.mock.calls.find(
224
+ call => call[0].key === 'CANCEL_TASK'
225
+ )?.[0];
226
+ expect(postCall.url).toBe(
227
+ `/foreman_tasks/tasks/${responseWithCancellableTask.task.id}/cancel`
228
+ );
229
+ expect(postCall.key).toBe('CANCEL_TASK');
230
+ });
231
+
232
+ test('clicking the `Abort Task` button calls API with abort param', () => {
233
+ render(
234
+ <Provider store={store}>
235
+ <TemplateInvocation {...mockProps} />
236
+ </Provider>
237
+ );
238
+ fireEvent.click(screen.getByText('Abort task'));
239
+
240
+ const postCall = api.APIActions.post.mock.calls.find(
241
+ call => call[0].key === 'ABORT_TASK'
242
+ )?.[0];
243
+ expect(postCall.url).toBe(
244
+ `/foreman_tasks/tasks/${responseWithCancellableTask.task.id}/abort`
245
+ );
246
+ expect(postCall.key).toBe('ABORT_TASK');
247
+ });
248
+ });
189
249
  });
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable max-lines */
2
2
  /* eslint-disable camelcase */
3
- import React, { useState, useEffect, useCallback } from 'react';
3
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { useDispatch, useSelector } from 'react-redux';
5
5
  import PropTypes from 'prop-types';
6
6
  import { Wizard } from '@patternfly/react-core/deprecated';
@@ -51,6 +51,8 @@ export const JobWizard = ({ rerunData }) => {
51
51
  const [category, setCategory] = useState(
52
52
  rerunData?.job_category || jobCategoriesResponse?.default_category || ''
53
53
  );
54
+ const categoryRef = useRef(category);
55
+ categoryRef.current = category;
54
56
  const [advancedValues, setAdvancedValues] = useState({ templateValues: {} });
55
57
  const [templateValues, setTemplateValues] = useState({});
56
58
  const [scheduleValue, setScheduleValue] = useState(initialScheduleState);
@@ -89,6 +91,10 @@ export const JobWizard = ({ rerunData }) => {
89
91
  concurrency_control = {},
90
92
  },
91
93
  }) => {
94
+ if (categoryRef.current !== job_category) {
95
+ setCategory(job_category);
96
+ }
97
+
92
98
  const advancedTemplateValues = {};
93
99
  const defaultTemplateValues = {};
94
100
  const inputs = template_inputs;
@@ -1,5 +1,7 @@
1
+ /* eslint-disable camelcase */
1
2
  import URI from 'urijs';
2
3
  import { get } from 'lodash';
4
+ import { createSelector } from 'reselect';
3
5
  import {
4
6
  selectAPIResponse,
5
7
  selectAPIStatus,
@@ -17,8 +19,12 @@ import {
17
19
  JOB_API_KEY,
18
20
  } from './JobWizardConstants';
19
21
 
22
+ /** Stable fallbacks so useSelector does not see a new reference every run */
23
+ const EMPTY_ARRAY = [];
24
+ const EMPTY_OBJECT = {};
25
+
20
26
  export const selectRerunJobInvocationResponse = state =>
21
- selectAPIResponse(state, JOB_API_KEY) || {};
27
+ selectAPIResponse(state, JOB_API_KEY) || EMPTY_OBJECT;
22
28
 
23
29
  export const selectRerunJobInvocationStatus = state =>
24
30
  selectAPIStatus(state, JOB_API_KEY);
@@ -27,19 +33,26 @@ export const selectJobTemplatesStatus = state =>
27
33
  selectAPIStatus(state, JOB_TEMPLATES);
28
34
 
29
35
  export const filterJobTemplates = templates =>
30
- templates?.filter(template => !template.snippet) || [];
36
+ templates?.filter(template => !template.snippet) || EMPTY_ARRAY;
37
+
38
+ const selectJobTemplatesResults = state =>
39
+ selectAPIResponse(state, JOB_TEMPLATES)?.results;
31
40
 
32
- export const selectJobTemplates = state =>
33
- filterJobTemplates(selectAPIResponse(state, JOB_TEMPLATES)?.results);
41
+ export const selectJobTemplates = createSelector(
42
+ [selectJobTemplatesResults],
43
+ results => filterJobTemplates(results)
44
+ );
34
45
 
35
46
  export const selectJobTemplatesSearch = state =>
36
47
  selectAPIResponse(state, JOB_TEMPLATES)?.search;
37
48
 
38
49
  export const selectJobCategoriesResponse = state =>
39
- selectAPIResponse(state, JOB_CATEGORIES) || {};
50
+ selectAPIResponse(state, JOB_CATEGORIES) || EMPTY_OBJECT;
40
51
 
41
- export const selectJobCategories = state =>
42
- selectJobCategoriesResponse(state).job_categories || [];
52
+ export const selectJobCategories = state => {
53
+ const { job_categories: jobCategories } = selectJobCategoriesResponse(state);
54
+ return jobCategories || EMPTY_ARRAY;
55
+ };
43
56
 
44
57
  export const selectWithKatello = state =>
45
58
  selectJobCategoriesResponse(state).with_katello || false;
@@ -58,7 +71,7 @@ export const selectJobCategoriesMissingPermissions = state => {
58
71
  'data',
59
72
  'error',
60
73
  'missing_permissions',
61
- ]) || []
74
+ ]) || EMPTY_ARRAY
62
75
  );
63
76
  };
64
77
 
@@ -75,29 +88,32 @@ export const selectEffectiveUser = state =>
75
88
  selectAPIResponse(state, JOB_TEMPLATE).effective_user;
76
89
 
77
90
  export const selectAdvancedTemplateInputs = state =>
78
- selectAPIResponse(state, JOB_TEMPLATE).advanced_template_inputs || [];
91
+ selectAPIResponse(state, JOB_TEMPLATE)?.advanced_template_inputs ||
92
+ EMPTY_ARRAY;
79
93
 
80
94
  export const selectTemplateInputs = state =>
81
- selectAPIResponse(state, JOB_TEMPLATE).template_inputs || [];
95
+ selectAPIResponse(state, JOB_TEMPLATE)?.template_inputs || EMPTY_ARRAY;
82
96
 
83
97
  export const selectHostsResponse = state => selectAPIResponse(state, HOSTS_API);
84
98
 
85
99
  export const selectHostCount = state =>
86
100
  selectHostsResponse(state).subtotal || 0;
87
101
 
88
- export const selectHosts = state => {
89
- const hosts = selectHostsResponse(state).results || [];
102
+ const selectHostsResults = state => selectHostsResponse(state).results;
103
+
104
+ export const selectHosts = createSelector([selectHostsResults], results => {
105
+ const hosts = results || EMPTY_ARRAY;
90
106
  return hosts.map(host => ({
91
107
  name: host.name,
92
108
  display_name: host.display_name,
93
109
  }));
94
- };
110
+ });
95
111
 
96
112
  export const selectHostsMissingPermissions = state => {
97
113
  const hostsResponse = selectHostsResponse(state);
98
114
  return (
99
115
  get(hostsResponse, ['response', 'data', 'error', 'missing_permissions']) ||
100
- []
116
+ EMPTY_ARRAY
101
117
  );
102
118
  };
103
119
 
@@ -115,6 +131,6 @@ export const selectIsSubmitting = state =>
115
131
  selectAPIStatus(state, JOB_INVOCATION) === STATUS.RESOLVED;
116
132
 
117
133
  export const selectRouterSearch = state => {
118
- const { search } = selectRouterLocation(state);
134
+ const { search } = selectRouterLocation(state) || {};
119
135
  return URI.parseQuery(search);
120
136
  };
@@ -30,6 +30,12 @@ export const pupptetJobTemplate = {
30
30
 
31
31
  export const jobTemplates = [jobTemplate];
32
32
 
33
+ export const wizardJobTemplatesMockList = [
34
+ jobTemplate,
35
+ pupptetJobTemplate,
36
+ { ...jobTemplate, id: 2, name: 'template2' },
37
+ ];
38
+
33
39
  export const jobTemplateResponse = {
34
40
  job_template: jobTemplate,
35
41
  effective_user: {
@@ -137,11 +143,9 @@ export const testSetup = (selectors, api) => {
137
143
  () => jobTemplateResponse.advanced_template_inputs
138
144
  );
139
145
  selectors.selectJobCategories.mockImplementation(() => jobCategories);
140
- selectors.selectJobTemplates.mockImplementation(() => [
141
- jobTemplate,
142
- pupptetJobTemplate,
143
- { ...jobTemplate, id: 2, name: 'template2' },
144
- ]);
146
+ selectors.selectJobTemplates.mockImplementation(
147
+ () => wizardJobTemplatesMockList
148
+ );
145
149
  selectors.selectJobTemplate.mockImplementation(() => jobTemplateResponse);
146
150
 
147
151
  selectors.selectEffectiveUser.mockImplementation(
@@ -187,13 +191,25 @@ export const mockApi = api => {
187
191
  },
188
192
  });
189
193
  } else if (action.key === 'JOB_TEMPLATE') {
190
- handleSuccess &&
191
- handleSuccess({
192
- data:
193
- action.url === '/ui_job_wizard/template/163'
194
- ? { ...jobTemplateResponse, job_template: pupptetJobTemplate }
195
- : jobTemplateResponse,
196
- });
194
+ const url = String(action.url);
195
+ let templatePayload = jobTemplateResponse;
196
+ if (url.includes('/template/163')) {
197
+ templatePayload = {
198
+ ...jobTemplateResponse,
199
+ job_template: pupptetJobTemplate,
200
+ };
201
+ } else if (url.includes('/template/2')) {
202
+ const jobTemplate2 = { ...jobTemplate, id: 2, name: 'template2' };
203
+ templatePayload = {
204
+ ...jobTemplateResponse,
205
+ job_template: jobTemplate2,
206
+ effective_user: {
207
+ ...jobTemplateResponse.effective_user,
208
+ job_template_id: 2,
209
+ },
210
+ };
211
+ }
212
+ handleSuccess && handleSuccess({ data: templatePayload });
197
213
  } else if (action.key === 'JOB_TEMPLATES') {
198
214
  handleSuccess &&
199
215
  handleSuccess({
@@ -202,7 +218,7 @@ export const mockApi = api => {
202
218
  action.url.search() ===
203
219
  '?search=job_category%3D%22Puppet%22&per_page=all'
204
220
  ? [pupptetJobTemplate]
205
- : [jobTemplate],
221
+ : wizardJobTemplatesMockList,
206
222
  },
207
223
  });
208
224
  } else if (action.key === 'HOST_IDS') {
@@ -1,4 +1,4 @@
1
- import React, { useEffect } from 'react';
1
+ import React, { useEffect, memo } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import PropTypes from 'prop-types';
4
4
  import URI from 'urijs';
@@ -133,4 +133,4 @@ ConnectedCategoryAndTemplate.propTypes = {
133
133
  };
134
134
  ConnectedCategoryAndTemplate.defaultProps = { jobTemplate: null };
135
135
 
136
- export default ConnectedCategoryAndTemplate;
136
+ export default memo(ConnectedCategoryAndTemplate);
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.5.2
4
+ version: 16.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-17 00:00:00.000000000 Z
10
+ date: 2026-03-31 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -390,6 +390,7 @@ files:
390
390
  - test/unit/concerns/foreman_tasks_cleaner_extensions_test.rb
391
391
  - test/unit/concerns/host_extensions_test.rb
392
392
  - test/unit/concerns/nic_extensions_test.rb
393
+ - test/unit/concerns/smart_proxy_extensions_test.rb
393
394
  - test/unit/execution_task_status_mapper_test.rb
394
395
  - test/unit/input_template_renderer_test.rb
395
396
  - test/unit/job_invocation_composer_test.rb