foreman_cve_scanner 0.6.0 → 0.7.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -4
  3. data/app/controllers/api/v2/cve_scans_controller.rb +23 -1
  4. data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +122 -1
  5. data/app/models/host_status/cve_status.rb +4 -0
  6. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +9 -7
  7. data/app/services/foreman_cve_scanner/katello_fix_availability.rb +208 -0
  8. data/app/services/foreman_cve_scanner/latest_scan_summaries.rb +51 -0
  9. data/app/services/foreman_cve_scanner/scan_importer.rb +2 -16
  10. data/app/services/foreman_cve_scanner/scan_post_processor.rb +29 -0
  11. data/app/views/api/v2/cve_scans/main.json.rabl +3 -1
  12. data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +94 -57
  13. data/config/routes.rb +1 -0
  14. data/lib/foreman_cve_scanner/engine.rb +28 -19
  15. data/lib/foreman_cve_scanner/version.rb +1 -1
  16. data/test/controllers/api/v2/cve_scans_controller_test.rb +96 -0
  17. data/test/models/foreman_cve_scanner/host_extensions_test.rb +91 -0
  18. data/test/models/host_status/cve_status_test.rb +8 -0
  19. data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +86 -2
  20. data/test/services/foreman_cve_scanner/katello_fix_availability_test.rb +164 -0
  21. data/test/services/foreman_cve_scanner/latest_scan_summaries_test.rb +95 -0
  22. data/test/services/foreman_cve_scanner/scan_comparison_test.rb +28 -0
  23. data/test/services/foreman_cve_scanner/scan_importer_test.rb +55 -0
  24. data/test/services/foreman_cve_scanner/scan_post_processor_test.rb +59 -0
  25. data/webpack/components/CveCompareModal.js +9 -28
  26. data/webpack/components/CveDetailsCard.js +54 -65
  27. data/webpack/components/CveFindingsModal.js +15 -234
  28. data/webpack/components/CveFindingsView.js +307 -0
  29. data/webpack/components/CveKatelloFixLink.js +59 -0
  30. data/webpack/components/CveLink.js +28 -0
  31. data/webpack/components/CveScansReports.js +25 -62
  32. data/webpack/components/CveScansTab.js +42 -9
  33. data/webpack/components/CveSummaryCell.js +31 -19
  34. data/webpack/components/CveTrendChart.js +1 -16
  35. data/webpack/components/__tests__/CveDetailsCard.test.js +83 -14
  36. data/webpack/components/__tests__/CveFindingsModal.test.js +5 -2
  37. data/webpack/components/__tests__/CveFindingsView.test.js +153 -0
  38. data/webpack/components/__tests__/CveScansTab.test.js +141 -147
  39. data/webpack/components/__tests__/CveSummaryCell.test.js +68 -0
  40. data/webpack/components/__tests__/CveTrendChart.test.js +1 -3
  41. data/webpack/components/__tests__/cve_helpers.test.js +84 -0
  42. data/webpack/components/cve_helpers.js +114 -0
  43. data/webpack/components/cve_scans.scss +49 -0
  44. data/webpack/components/cve_summary_store.js +91 -0
  45. data/webpack/components/useSortBy.js +34 -0
  46. metadata +24 -4
@@ -23,82 +23,117 @@ jest.mock('../CveCompareModal', () => ({ isOpen, scanIds }) =>
23
23
  const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
24
24
 
25
25
  describe('CveScansTab', () => {
26
+ const historyResponse = {
27
+ results: [
28
+ {
29
+ id: 1,
30
+ scanned_at: '2026-02-20',
31
+ scanner: 'trivy',
32
+ source: 'rex',
33
+ total: 10,
34
+ critical: 1,
35
+ high: 2,
36
+ medium: 3,
37
+ low: 4,
38
+ },
39
+ {
40
+ id: 2,
41
+ scanned_at: '2026-02-21',
42
+ scanner: 'grype',
43
+ source: 'external',
44
+ total: 5,
45
+ critical: 0,
46
+ high: 1,
47
+ medium: 1,
48
+ low: 3,
49
+ },
50
+ ],
51
+ total: 2,
52
+ };
53
+ const latestResponse = {
54
+ id: 2,
55
+ scanned_at: '2026-02-21',
56
+ scanner: 'grype',
57
+ source: 'external',
58
+ total: 5,
59
+ findings: [
60
+ {
61
+ id: 'CVE-2026-0001',
62
+ name: 'openssl',
63
+ version: '1.0',
64
+ fixed: '1.1',
65
+ status: 'open',
66
+ severity: 'HIGH',
67
+ title: 'OpenSSL issue',
68
+ published: '2026-02-11T00:00:00Z',
69
+ url: 'https://example.test/CVE-2026-0001',
70
+ },
71
+ ],
72
+ };
73
+
74
+ const mockScanApis = ({
75
+ latest = latestResponse,
76
+ history = historyResponse,
77
+ latestStatus = 'RESOLVED',
78
+ historyStatus = 'RESOLVED',
79
+ } = {}) => {
80
+ useAPI.mockImplementation((_method, url) => {
81
+ if (url && url.includes('/cve_scans/latest')) {
82
+ return { response: latest, status: latestStatus };
83
+ }
84
+ return { response: history, status: historyStatus };
85
+ });
86
+ };
87
+
26
88
  beforeEach(() => {
27
89
  useAPI.mockReset();
28
90
  });
29
91
 
30
- it('renders overview and reports sub-tabs', () => {
31
- const scansResponse = {
32
- results: [
33
- {
34
- id: 1,
35
- scanned_at: '2026-02-20',
36
- scanner: 'trivy',
37
- source: 'rex',
38
- total: 10,
39
- critical: 1,
40
- high: 2,
41
- medium: 3,
42
- low: 4,
43
- },
44
- {
45
- id: 2,
46
- scanned_at: '2026-02-21',
47
- scanner: 'grype',
48
- source: 'external',
49
- total: 5,
50
- critical: 0,
51
- high: 1,
52
- medium: 1,
53
- low: 3,
54
- },
55
- ],
56
- total: 2,
57
- };
58
-
59
- useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
92
+ it('renders latest scan, trends and history tabs', () => {
93
+ mockScanApis();
60
94
 
61
95
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
62
96
 
63
- expect(wrapper.text()).toContain('Overview');
64
- expect(wrapper.text()).toContain('Reports');
65
- expect(wrapper.text()).toContain('Trend');
66
- expect(wrapper.text()).toContain('Latest total');
97
+ expect(wrapper.text()).toContain('Latest Scan');
98
+ expect(wrapper.text()).toContain('Trends');
99
+ expect(wrapper.text()).toContain('History');
100
+ expect(wrapper.text()).toContain('Report from');
101
+ expect(wrapper.text()).toContain('grype / external');
102
+ expect(wrapper.text()).toContain('openssl');
67
103
  });
68
104
 
69
- it('opens compare modal from overview trend selection', () => {
70
- const scansResponse = {
71
- results: [
72
- {
73
- id: 1,
74
- scanned_at: '2026-02-20',
75
- scanner: 'trivy',
76
- source: 'rex',
77
- total: 10,
78
- critical: 1,
79
- high: 2,
80
- medium: 3,
81
- low: 4,
82
- },
83
- {
84
- id: 2,
85
- scanned_at: '2026-02-21',
86
- scanner: 'grype',
87
- source: 'external',
88
- total: 5,
89
- critical: 0,
90
- high: 1,
91
- medium: 1,
92
- low: 3,
93
- },
94
- ],
95
- total: 2,
96
- };
97
-
98
- useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
105
+ it('renders a success state when the latest scan has no findings', () => {
106
+ mockScanApis({
107
+ latest: {
108
+ id: 2,
109
+ scanned_at: '2026-02-21',
110
+ scanner: 'grype',
111
+ source: 'external',
112
+ total: 0,
113
+ findings: [],
114
+ },
115
+ });
116
+
117
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
118
+
119
+ expect(wrapper.text()).toContain('No CVEs found');
120
+ expect(wrapper.text()).toContain(
121
+ 'The latest CVE scan found no vulnerabilities for this host.'
122
+ );
123
+ });
124
+
125
+ it('opens compare modal from trends selection', () => {
126
+ mockScanApis();
99
127
 
100
128
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
101
129
 
130
+ wrapper
131
+ .find('[role="tab"]')
132
+ .filterWhere(node => node.text() === 'Trends')
133
+ .first()
134
+ .simulate('click');
135
+ wrapper.update();
136
+
102
137
  wrapper
103
138
  .find('button')
104
139
  .filterWhere(node => node.text() === 'Compare 2 reports')
@@ -113,42 +148,14 @@ describe('CveScansTab', () => {
113
148
  expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
114
149
  });
115
150
 
116
- it('renders scan rows in the reports sub-tab', () => {
117
- const scansResponse = {
118
- results: [
119
- {
120
- id: 1,
121
- scanned_at: '2026-02-20',
122
- scanner: 'trivy',
123
- source: 'rex',
124
- total: 10,
125
- critical: 1,
126
- high: 2,
127
- medium: 3,
128
- low: 4,
129
- },
130
- {
131
- id: 2,
132
- scanned_at: '2026-02-21',
133
- scanner: 'grype',
134
- source: 'external',
135
- total: 5,
136
- critical: 0,
137
- high: 1,
138
- medium: 1,
139
- low: 3,
140
- },
141
- ],
142
- total: 2,
143
- };
144
-
145
- useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
151
+ it('renders scan rows in the history tab', () => {
152
+ mockScanApis();
146
153
 
147
154
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
148
155
 
149
156
  wrapper
150
157
  .find('[role="tab"]')
151
- .filterWhere(node => node.text() === 'Reports')
158
+ .filterWhere(node => node.text() === 'History')
152
159
  .first()
153
160
  .simulate('click');
154
161
  wrapper.update();
@@ -161,41 +168,13 @@ describe('CveScansTab', () => {
161
168
  });
162
169
 
163
170
  it('opens compare modal when two scans are selected', () => {
164
- const scansResponse = {
165
- results: [
166
- {
167
- id: 1,
168
- scanned_at: '2026-02-20',
169
- scanner: 'trivy',
170
- source: 'rex',
171
- total: 10,
172
- critical: 1,
173
- high: 2,
174
- medium: 3,
175
- low: 4,
176
- },
177
- {
178
- id: 2,
179
- scanned_at: '2026-02-21',
180
- scanner: 'grype',
181
- source: 'external',
182
- total: 5,
183
- critical: 0,
184
- high: 1,
185
- medium: 1,
186
- low: 3,
187
- },
188
- ],
189
- total: 2,
190
- };
191
-
192
- useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
171
+ mockScanApis();
193
172
 
194
173
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
195
174
 
196
175
  wrapper
197
176
  .find('[role="tab"]')
198
- .filterWhere(node => node.text() === 'Reports')
177
+ .filterWhere(node => node.text() === 'History')
199
178
  .first()
200
179
  .simulate('click');
201
180
  wrapper.update();
@@ -215,40 +194,55 @@ describe('CveScansTab', () => {
215
194
  });
216
195
 
217
196
  it('renders empty state with no scans', () => {
218
- useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
197
+ mockScanApis({ latest: null, history: { results: [] } });
219
198
 
220
199
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
221
200
  expect(wrapper.text()).toContain('No CVE reports for this host');
222
201
  });
223
202
 
224
- it('renders trend overview when recent scans have no cves', () => {
225
- const scansResponse = {
226
- results: [
227
- {
228
- id: 1,
229
- scanned_at: '2026-02-20',
230
- scanner: 'trivy',
231
- source: 'rex',
232
- total: 0,
233
- critical: 0,
234
- high: 0,
235
- medium: 0,
236
- low: 0,
237
- },
238
- ],
239
- total: 1,
240
- };
241
-
242
- useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
203
+ it('renders trend content in the trends tab when recent scans have no cves', () => {
204
+ mockScanApis({
205
+ latest: {
206
+ id: 1,
207
+ scanned_at: '2026-02-20',
208
+ scanner: 'trivy',
209
+ source: 'rex',
210
+ total: 0,
211
+ findings: [],
212
+ },
213
+ history: {
214
+ results: [
215
+ {
216
+ id: 1,
217
+ scanned_at: '2026-02-20',
218
+ scanner: 'trivy',
219
+ source: 'rex',
220
+ total: 0,
221
+ critical: 0,
222
+ high: 0,
223
+ medium: 0,
224
+ low: 0,
225
+ },
226
+ ],
227
+ total: 1,
228
+ },
229
+ });
243
230
 
244
231
  const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
245
- expect(wrapper.text()).toContain('Trend');
232
+
233
+ wrapper
234
+ .find('[role="tab"]')
235
+ .filterWhere(node => node.text() === 'Trends')
236
+ .first()
237
+ .simulate('click');
238
+ wrapper.update();
239
+
246
240
  expect(wrapper.text()).toContain('Latest total');
247
241
  expect(wrapper.text()).toContain('0');
248
242
  });
249
243
 
250
244
  it('does not render when host id is missing', () => {
251
- useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
245
+ mockScanApis({ latest: null, history: { results: [] } });
252
246
 
253
247
  const wrapper = mount(<CveScansTab response={{}} />);
254
248
  expect(wrapper.isEmptyRender()).toBe(true);
@@ -0,0 +1,68 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import { act } from 'react-dom/test-utils';
5
+ import CveSummaryCell from '../CveSummaryCell';
6
+ import { resetCveSummaryStore } from '../cve_summary_store';
7
+
8
+ jest.mock('../CveFindingsModal', () => () => null);
9
+
10
+ const flushBatch = () =>
11
+ new Promise(resolve => {
12
+ setTimeout(resolve, 0);
13
+ });
14
+
15
+ describe('CveSummaryCell', () => {
16
+ beforeEach(() => {
17
+ resetCveSummaryStore();
18
+ global.fetch = jest.fn().mockResolvedValue({
19
+ ok: true,
20
+ json: async () => ({
21
+ results: [
22
+ {
23
+ id: 11,
24
+ host_id: 1,
25
+ total: 2,
26
+ summary: { worst: 'high' },
27
+ },
28
+ {
29
+ id: 22,
30
+ host_id: 2,
31
+ total: 0,
32
+ summary: { worst: 'none' },
33
+ },
34
+ ],
35
+ }),
36
+ });
37
+ });
38
+
39
+ afterEach(() => {
40
+ resetCveSummaryStore();
41
+ delete global.fetch;
42
+ });
43
+
44
+ it('batches host summary requests across cells', async () => {
45
+ let wrapper;
46
+
47
+ await act(async () => {
48
+ wrapper = mount(
49
+ <div>
50
+ <CveSummaryCell hostId={1} hostName="alpha" />
51
+ <CveSummaryCell hostId={2} hostName="beta" />
52
+ </div>
53
+ );
54
+ await flushBatch();
55
+ await flushBatch();
56
+ });
57
+ wrapper.update();
58
+
59
+ expect(global.fetch).toHaveBeenCalledTimes(1);
60
+ expect(global.fetch.mock.calls[0][0]).toContain(
61
+ '/api/v2/cve_scans/latest_by_hosts?'
62
+ );
63
+ expect(global.fetch.mock.calls[0][0]).toContain('host_ids%5B%5D=1');
64
+ expect(global.fetch.mock.calls[0][0]).toContain('host_ids%5B%5D=2');
65
+ expect(wrapper.text()).toContain('2');
66
+ expect(wrapper.text()).toContain('0');
67
+ });
68
+ });
@@ -4,7 +4,7 @@ import { mount } from 'enzyme';
4
4
  import CveTrendChart from '../CveTrendChart';
5
5
 
6
6
  describe('CveTrendChart', () => {
7
- it('renders the last scan summary and legend', () => {
7
+ it('renders the scan summary and legend', () => {
8
8
  const wrapper = mount(
9
9
  <CveTrendChart
10
10
  scans={[
@@ -30,8 +30,6 @@ describe('CveTrendChart', () => {
30
30
  />
31
31
  );
32
32
 
33
- expect(wrapper.text()).toContain('Trend');
34
- expect(wrapper.text()).toContain('Last 2 scans');
35
33
  expect(wrapper.text()).toContain('Latest total');
36
34
  expect(wrapper.text()).toContain('18');
37
35
  expect(wrapper.text()).toContain('Critical');
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable import/no-unresolved */
2
2
  import {
3
+ findingIdentity,
3
4
  severityRank,
4
5
  riskLevelFromWorst,
5
6
  formatDateTime,
@@ -7,6 +8,12 @@ import {
7
8
  formatScannedAt,
8
9
  compareStrings,
9
10
  visibleScanSource,
11
+ katelloFixLabel,
12
+ katelloFixTitle,
13
+ katelloFixCounts,
14
+ katelloFixErrataSearch,
15
+ katelloFixInstallUrl,
16
+ shouldShowKatelloFixColumn,
10
17
  } from '../cve_helpers';
11
18
 
12
19
  describe('cve_helpers', () => {
@@ -57,4 +64,81 @@ describe('cve_helpers', () => {
57
64
  expect(visibleScanSource('rex')).toBe('');
58
65
  expect(visibleScanSource('external')).toBe('external');
59
66
  });
67
+
68
+ it('builds finding identity from cve and package', () => {
69
+ expect(findingIdentity({ id: 'CVE-2026-0001', name: 'openssl' })).toBe(
70
+ 'CVE-2026-0001::openssl'
71
+ );
72
+ });
73
+
74
+ it('formats fix availability status labels and titles', () => {
75
+ const fix = {
76
+ status: 'installable',
77
+ errata: [{ errata_id: 'RHSA-2026:0001' }],
78
+ };
79
+
80
+ expect(katelloFixLabel(fix)).toBe('Installable');
81
+ expect(katelloFixTitle(fix)).toContain('RHSA-2026:0001');
82
+ });
83
+
84
+ it('hides fix availability column when findings are not enriched', () => {
85
+ expect(
86
+ shouldShowKatelloFixColumn([
87
+ {
88
+ id: 'CVE-2026-0001',
89
+ },
90
+ ])
91
+ ).toBe(false);
92
+
93
+ expect(
94
+ shouldShowKatelloFixColumn([
95
+ {
96
+ katello_fix: {
97
+ status: 'applicable',
98
+ reason: null,
99
+ },
100
+ },
101
+ ])
102
+ ).toBe(true);
103
+ });
104
+
105
+ it('counts fix availability statuses', () => {
106
+ expect(
107
+ katelloFixCounts([
108
+ { katello_fix: { status: 'installable' } },
109
+ { katello_fix: { status: 'installable' } },
110
+ { katello_fix: { status: 'applicable' } },
111
+ { katello_fix: { status: 'unavailable' } },
112
+ ])
113
+ ).toEqual({ installable: 2, applicable: 1 });
114
+ });
115
+
116
+ it('builds errata install remote execution urls for installable fixes', () => {
117
+ const fix = {
118
+ status: 'installable',
119
+ errata: [{ errata_id: 'RHSA-2026:0001' }],
120
+ };
121
+ const url = katelloFixInstallUrl({ hostName: 'host.example.com', fix });
122
+ const params = new URL(url, 'http://example.test').searchParams;
123
+
124
+ expect(katelloFixErrataSearch(fix)).toBe('errata_id = RHSA-2026:0001');
125
+ expect(url).toContain('/job_invocations/new?');
126
+ expect(params.get('feature')).toBe('katello_errata_install_by_search');
127
+ expect(params.get('search')).toBe('name ^ (host.example.com)');
128
+ expect(params.get('inputs[Errata search query]')).toBe(
129
+ 'errata_id = RHSA-2026:0001'
130
+ );
131
+ });
132
+
133
+ it('does not build install urls for non-installable fixes', () => {
134
+ expect(
135
+ katelloFixInstallUrl({
136
+ hostName: 'host.example.com',
137
+ fix: {
138
+ status: 'applicable',
139
+ errata: [{ errata_id: 'RHSA-2026:0001' }],
140
+ },
141
+ })
142
+ ).toBe('');
143
+ });
60
144
  });
@@ -13,6 +13,14 @@ const COMPARE_STATUS_RANK = {
13
13
  updated: 2,
14
14
  unchanged: 1,
15
15
  };
16
+ const KATELLO_FIX_RANK = {
17
+ installable: 3,
18
+ applicable: 2,
19
+ unavailable: 1,
20
+ unknown: 0,
21
+ };
22
+ const KATELLO_ERRATA_INSTALL_FEATURE = 'katello_errata_install_by_search';
23
+ const ERRATA_SEARCH_INPUT = 'Errata search query';
16
24
  const COMPARISON_DIFF_LABELS = {
17
25
  severity: __('Severity'),
18
26
  version: __('Version'),
@@ -88,6 +96,8 @@ export const findingMatchesSearch = (finding, query) =>
88
96
  finding.title,
89
97
  finding.url,
90
98
  finding.severity,
99
+ katelloFixLabel(finding.katello_fix),
100
+ katelloFixTitle(finding.katello_fix),
91
101
  ].some(value =>
92
102
  (value || '')
93
103
  .toString()
@@ -95,11 +105,16 @@ export const findingMatchesSearch = (finding, query) =>
95
105
  .includes(query)
96
106
  );
97
107
 
108
+ export const findingIdentity = finding =>
109
+ [finding.id, finding.name].map(value => value || '').join('::');
110
+
98
111
  export const findingSorters = {
99
112
  published: (a, b) => new Date(a.published || 0) - new Date(b.published || 0),
100
113
  name: (a, b) => compareStrings(a.name, b.name),
101
114
  version: (a, b) => compareStrings(a.version, b.version),
102
115
  fixed: (a, b) => compareStrings(a.fixed, b.fixed),
116
+ katello_fix: (a, b) =>
117
+ katelloFixRank(a.katello_fix) - katelloFixRank(b.katello_fix),
103
118
  id: (a, b) => compareStrings(a.id, b.id),
104
119
  status: (a, b) => compareStrings(a.status, b.status),
105
120
  title: (a, b) => compareStrings(a.title, b.title),
@@ -172,3 +187,102 @@ export const formatScanOrigin = (scanner, source, fallback = __('Unknown')) => {
172
187
  };
173
188
  export const formatScannedAt = value =>
174
189
  formatDateTime(value) || __('Unknown time');
190
+
191
+ export const katelloFixRank = fix =>
192
+ KATELLO_FIX_RANK[(fix?.status || '').toLowerCase()] ?? 0;
193
+
194
+ export const katelloFixLabel = fix => {
195
+ switch ((fix?.status || '').toLowerCase()) {
196
+ case 'installable':
197
+ return __('Installable');
198
+ case 'applicable':
199
+ return __('Applicable');
200
+ case 'unavailable':
201
+ return __('No managed fix');
202
+ case 'unknown':
203
+ return __('Unknown');
204
+ default:
205
+ return '';
206
+ }
207
+ };
208
+
209
+ export const katelloFixTitle = fix => {
210
+ const errataIds = (fix?.errata || [])
211
+ .map(erratum => erratum.errata_id)
212
+ .filter(Boolean)
213
+ .join(', ');
214
+
215
+ switch ((fix?.status || '').toLowerCase()) {
216
+ case 'installable':
217
+ return errataIds
218
+ ? `${__(
219
+ 'A matching erratum is installable for this host'
220
+ )}: ${errataIds}`
221
+ : __('A matching erratum is installable for this host');
222
+ case 'applicable':
223
+ return errataIds
224
+ ? `${__(
225
+ 'A matching erratum applies to this host, but is not installable in the current content'
226
+ )}: ${errataIds}`
227
+ : __(
228
+ 'A matching erratum applies to this host, but is not installable in the current content'
229
+ );
230
+ case 'unavailable':
231
+ return __(
232
+ 'No matching managed erratum was found for this CVE and package'
233
+ );
234
+ case 'unknown':
235
+ if (fix?.reason === 'missing_cve') {
236
+ return __('This finding has no CVE identifier');
237
+ }
238
+ if (fix?.reason === 'query_failed') {
239
+ return __('Fix availability could not be calculated');
240
+ }
241
+ return __('Fix availability is unknown');
242
+ default:
243
+ return '';
244
+ }
245
+ };
246
+
247
+ export const shouldShowKatelloFixColumn = findings =>
248
+ (findings || []).some(finding => finding.katello_fix);
249
+
250
+ export const katelloFixCounts = findings =>
251
+ (findings || []).reduce(
252
+ (counts, finding) => {
253
+ const status = (finding.katello_fix?.status || '').toLowerCase();
254
+ if (status === 'installable') counts.installable += 1;
255
+ if (status === 'applicable') counts.applicable += 1;
256
+ return counts;
257
+ },
258
+ { installable: 0, applicable: 0 }
259
+ );
260
+
261
+ export const katelloFixErrataIds = fix =>
262
+ (fix?.errata || []).map(erratum => erratum.errata_id).filter(Boolean);
263
+
264
+ export const katelloFixErrataSearch = fix => {
265
+ const errataIds = katelloFixErrataIds(fix);
266
+ if (errataIds.length === 0) return '';
267
+ if (errataIds.length === 1) return `errata_id = ${errataIds[0]}`;
268
+ return `errata_id ^ (${errataIds.join(',')})`;
269
+ };
270
+
271
+ export const katelloFixInstallUrl = ({ hostName, fix }) => {
272
+ if ((fix?.status || '').toLowerCase() !== 'installable') return '';
273
+ const errataSearch = katelloFixErrataSearch(fix);
274
+ if (!hostName || !errataSearch) return '';
275
+
276
+ const params = new URLSearchParams();
277
+ params.set('feature', KATELLO_ERRATA_INSTALL_FEATURE);
278
+ params.set('search', `name ^ (${hostName})`);
279
+ params.set(`inputs[${ERRATA_SEARCH_INPUT}]`, errataSearch);
280
+ return `/job_invocations/new?${params.toString()}`;
281
+ };
282
+
283
+ export const katelloFixDetailsUrl = fix => {
284
+ const erratumId = (fix?.errata || [])
285
+ .map(erratum => erratum.id)
286
+ .find(Boolean);
287
+ return erratumId ? `/errata/${erratumId}` : '';
288
+ };