foreman_cve_scanner 0.6.1 → 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 +21 -1
@@ -1,12 +1,20 @@
1
1
  /* eslint-disable import/no-unresolved */
2
2
  import React from 'react';
3
3
  import PropTypes from 'prop-types';
4
- import { Button, Checkbox, Pagination, Title } from '@patternfly/react-core';
4
+ import { Button, Checkbox, Pagination } from '@patternfly/react-core';
5
5
  import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
6
6
  import { translate as __ } from 'foremanReact/common/I18n';
7
7
  import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
8
8
  import { formatScanOrigin } from './cve_helpers';
9
9
 
10
+ const SCAN_COUNT_COLUMNS = [
11
+ { key: 'total', label: __('Total'), filter: 'all' },
12
+ { key: 'critical', label: __('Critical'), filter: 'critical' },
13
+ { key: 'high', label: __('High'), filter: 'high' },
14
+ { key: 'medium', label: __('Medium'), filter: 'medium' },
15
+ { key: 'low', label: __('Low'), filter: 'low' },
16
+ ];
17
+
10
18
  const CveScansReports = ({
11
19
  scans,
12
20
  itemCount,
@@ -22,13 +30,8 @@ const CveScansReports = ({
22
30
  onOpenModal,
23
31
  exportUrlFor,
24
32
  }) => (
25
- <section className="cve-scans-section" aria-label="CVE scan reports">
33
+ <section className="cve-scans-section" aria-label="CVE scan history">
26
34
  <div className="cve-scans-section-header">
27
- <div className="cve-scans-section-title">
28
- <Title headingLevel="h3" size="lg" ouiaId="cve-scans-reports-title">
29
- {__('Reports')}
30
- </Title>
31
- </div>
32
35
  <Pagination
33
36
  itemCount={itemCount}
34
37
  perPage={perPage}
@@ -73,11 +76,9 @@ const CveScansReports = ({
73
76
  <Th>{__('Select')}</Th>
74
77
  <Th>{__('Scanned at')}</Th>
75
78
  <Th>{__('Scanner')}</Th>
76
- <Th>{__('Total')}</Th>
77
- <Th>{__('Critical')}</Th>
78
- <Th>{__('High')}</Th>
79
- <Th>{__('Medium')}</Th>
80
- <Th>{__('Low')}</Th>
79
+ {SCAN_COUNT_COLUMNS.map(column => (
80
+ <Th key={column.key}>{column.label}</Th>
81
+ ))}
81
82
  <Th>{__('Export')}</Th>
82
83
  </Tr>
83
84
  </Thead>
@@ -113,56 +114,18 @@ const CveScansReports = ({
113
114
  <Td dataLabel={__('Scanner')}>
114
115
  {formatScanOrigin(scan.scanner, scan.source)}
115
116
  </Td>
116
- <Td dataLabel={__('Total')}>
117
- <Button
118
- variant="link"
119
- className="cve-summary-link"
120
- onClick={() => onOpenModal(scan.id, 'all')}
121
- ouiaId={`cve-scans-total-${scan.id}`}
122
- >
123
- {scan.total}
124
- </Button>
125
- </Td>
126
- <Td dataLabel={__('Critical')}>
127
- <Button
128
- variant="link"
129
- className="cve-summary-link"
130
- onClick={() => onOpenModal(scan.id, 'critical')}
131
- ouiaId={`cve-scans-critical-${scan.id}`}
132
- >
133
- {scan.critical}
134
- </Button>
135
- </Td>
136
- <Td dataLabel={__('High')}>
137
- <Button
138
- variant="link"
139
- className="cve-summary-link"
140
- onClick={() => onOpenModal(scan.id, 'high')}
141
- ouiaId={`cve-scans-high-${scan.id}`}
142
- >
143
- {scan.high}
144
- </Button>
145
- </Td>
146
- <Td dataLabel={__('Medium')}>
147
- <Button
148
- variant="link"
149
- className="cve-summary-link"
150
- onClick={() => onOpenModal(scan.id, 'medium')}
151
- ouiaId={`cve-scans-medium-${scan.id}`}
152
- >
153
- {scan.medium}
154
- </Button>
155
- </Td>
156
- <Td dataLabel={__('Low')}>
157
- <Button
158
- variant="link"
159
- className="cve-summary-link"
160
- onClick={() => onOpenModal(scan.id, 'low')}
161
- ouiaId={`cve-scans-low-${scan.id}`}
162
- >
163
- {scan.low}
164
- </Button>
165
- </Td>
117
+ {SCAN_COUNT_COLUMNS.map(column => (
118
+ <Td key={column.key} dataLabel={column.label}>
119
+ <Button
120
+ variant="link"
121
+ className="cve-summary-link"
122
+ onClick={() => onOpenModal(scan.id, column.filter)}
123
+ ouiaId={`cve-scans-${column.key}-${scan.id}`}
124
+ >
125
+ {scan[column.key]}
126
+ </Button>
127
+ </Td>
128
+ ))}
166
129
  <Td dataLabel={__('Export')}>
167
130
  <Button
168
131
  component="a"
@@ -17,6 +17,7 @@ import { translate as __ } from 'foremanReact/common/I18n';
17
17
  import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
18
18
  import { STATUS } from 'foremanReact/constants';
19
19
  import CveTrendChart from './CveTrendChart';
20
+ import CveFindingsView from './CveFindingsView';
20
21
  import CveScansReports from './CveScansReports';
21
22
  import CveFindingsModal from './CveFindingsModal';
22
23
  import CveCompareModal from './CveCompareModal';
@@ -25,17 +26,19 @@ import { noReportsBody, noReportsTitle } from './cve_helpers';
25
26
  import './cve_scans.scss';
26
27
 
27
28
  const DEFAULT_PER_PAGE = 20;
28
- const OVERVIEW_TAB = 0;
29
- const REPORTS_TAB = 1;
29
+ const LATEST_SCAN_TAB = 0;
30
+ const TRENDS_TAB = 1;
31
+ const HISTORY_TAB = 2;
30
32
 
31
33
  const CveScansTab = ({ response }) => {
32
34
  const hostId = response?.id;
35
+ const hostName = response?.name;
33
36
  const [page, setPage] = useState(1);
34
37
  const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
35
38
  const [isCompareOpen, setIsCompareOpen] = useState(false);
36
39
  const [selectedScanIds, setSelectedScanIds] = useState([]);
37
40
  const [isTrendCompareMode, setIsTrendCompareMode] = useState(false);
38
- const [activeTabKey, setActiveTabKey] = useState(OVERVIEW_TAB);
41
+ const [activeTabKey, setActiveTabKey] = useState(LATEST_SCAN_TAB);
39
42
  const { isOpen, scanId, filter, openModal, closeModal } = useModalScan();
40
43
 
41
44
  const historyUrl = hostId
@@ -43,11 +46,22 @@ const CveScansTab = ({ response }) => {
43
46
  `/api/v2/hosts/${hostId}/cve_scans?page=${page}&per_page=${perPage}`
44
47
  )
45
48
  : null;
49
+ const latestUrl = hostId
50
+ ? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
51
+ : null;
46
52
  const { response: apiResponse, status } = useAPI('get', historyUrl, {
47
53
  key: `CVE_SCANS_TAB_${hostId}_${page}_${perPage}`,
48
54
  });
55
+ const { response: latestResponse, status: latestStatus } = useAPI(
56
+ 'get',
57
+ latestUrl,
58
+ {
59
+ key: `CVE_SCANS_TAB_LATEST_${hostId}`,
60
+ }
61
+ );
49
62
 
50
63
  const scans = useMemo(() => apiResponse?.results || [], [apiResponse]);
64
+ const latestScan = latestResponse?.id ? latestResponse : null;
51
65
  const selectedScans = useMemo(
52
66
  () => scans.filter(scan => selectedScanIds.includes(scan.id)),
53
67
  [scans, selectedScanIds]
@@ -123,9 +137,26 @@ const CveScansTab = ({ response }) => {
123
137
  ouiaId="cve-scans-subtabs"
124
138
  >
125
139
  <Tab
126
- eventKey={OVERVIEW_TAB}
127
- title={<TabTitleText>{__('Overview')}</TabTitleText>}
128
- ouiaId="cve-scans-overview-tab"
140
+ eventKey={LATEST_SCAN_TAB}
141
+ title={<TabTitleText>{__('Latest Scan')}</TabTitleText>}
142
+ ouiaId="cve-scans-latest-tab"
143
+ >
144
+ <section
145
+ className="cve-scans-section"
146
+ aria-label="Latest CVE scan"
147
+ >
148
+ <CveFindingsView
149
+ scan={latestScan}
150
+ status={latestStatus}
151
+ initialFilter="all"
152
+ hostName={hostName}
153
+ />
154
+ </section>
155
+ </Tab>
156
+ <Tab
157
+ eventKey={TRENDS_TAB}
158
+ title={<TabTitleText>{__('Trends')}</TabTitleText>}
159
+ ouiaId="cve-scans-trends-tab"
129
160
  >
130
161
  <section
131
162
  className="cve-scans-section"
@@ -142,9 +173,9 @@ const CveScansTab = ({ response }) => {
142
173
  </section>
143
174
  </Tab>
144
175
  <Tab
145
- eventKey={REPORTS_TAB}
146
- title={<TabTitleText>{__('Reports')}</TabTitleText>}
147
- ouiaId="cve-scans-reports-tab"
176
+ eventKey={HISTORY_TAB}
177
+ title={<TabTitleText>{__('History')}</TabTitleText>}
178
+ ouiaId="cve-scans-history-tab"
148
179
  >
149
180
  <CveScansReports
150
181
  scans={scans}
@@ -169,6 +200,7 @@ const CveScansTab = ({ response }) => {
169
200
  isOpen={isOpen}
170
201
  onClose={closeModal}
171
202
  hostId={hostId}
203
+ hostName={hostName}
172
204
  scanId={scanId}
173
205
  initialFilter={filter}
174
206
  />
@@ -185,6 +217,7 @@ const CveScansTab = ({ response }) => {
185
217
  CveScansTab.propTypes = {
186
218
  response: PropTypes.shape({
187
219
  id: PropTypes.number,
220
+ name: PropTypes.string,
188
221
  }),
189
222
  };
190
223
 
@@ -1,33 +1,45 @@
1
1
  /* eslint-disable import/no-unresolved */
2
- import React, { useState } from 'react';
2
+ import React, { useEffect, useState } from 'react';
3
3
  import PropTypes from 'prop-types';
4
4
  import { Spinner } from '@patternfly/react-core';
5
- import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
6
- import { foremanUrl } from 'foremanReact/common/helpers';
7
5
  import { translate as __ } from 'foremanReact/common/I18n';
8
- import { STATUS } from 'foremanReact/constants';
9
6
  import SeverityIcon from './SeverityIcon';
10
7
  import CveFindingsModal from './CveFindingsModal';
8
+ import {
9
+ getCachedCveSummary,
10
+ subscribeToCveSummary,
11
+ } from './cve_summary_store';
11
12
  import './cve_scans.scss';
12
13
 
13
14
  const CveSummaryCell = ({ hostId, hostName }) => {
14
- const url = hostId
15
- ? foremanUrl(`/api/v2/hosts/${hostId}/cve_scans/latest`)
16
- : null;
17
- const { response, status } = useAPI('get', url, {
18
- key: `CVE_SUMMARY_${hostId}`,
19
- });
20
-
21
15
  const [isModalOpen, setIsModalOpen] = useState(false);
16
+ const [response, setResponse] = useState(
17
+ hostId ? getCachedCveSummary(hostId) : null
18
+ );
19
+ const [isLoaded, setIsLoaded] = useState(
20
+ hostId ? getCachedCveSummary(hostId) !== undefined : true
21
+ );
22
+
23
+ useEffect(() => {
24
+ if (!hostId) {
25
+ setResponse(null);
26
+ setIsLoaded(true);
27
+ return undefined;
28
+ }
29
+
30
+ const cached = getCachedCveSummary(hostId);
31
+ setResponse(cached);
32
+ setIsLoaded(cached !== undefined);
33
+
34
+ return subscribeToCveSummary(hostId, value => {
35
+ setResponse(value);
36
+ setIsLoaded(true);
37
+ });
38
+ }, [hostId]);
39
+
22
40
  if (!hostId) return <span className="cve-summary-empty">--</span>;
23
- if (status === STATUS.PENDING) {
24
- return (
25
- <Spinner
26
- size="sm"
27
- aria-label={__('Loading CVE findings')}
28
- ouiaId="cve-summary-loading"
29
- />
30
- );
41
+ if (!isLoaded) {
42
+ return <Spinner size="sm" aria-label={__('Loading CVE findings')} />;
31
43
  }
32
44
 
33
45
  if (!response) return <span className="cve-summary-empty">--</span>;
@@ -1,13 +1,7 @@
1
1
  /* eslint-disable import/no-unresolved */
2
2
  import React, { useMemo } from 'react';
3
3
  import PropTypes from 'prop-types';
4
- import {
5
- Button,
6
- Text,
7
- TextContent,
8
- TextVariants,
9
- Tooltip,
10
- } from '@patternfly/react-core';
4
+ import { Button, Tooltip } from '@patternfly/react-core';
11
5
  import { translate as __ } from 'foremanReact/common/I18n';
12
6
  import { formatScannedAt } from './cve_helpers';
13
7
 
@@ -97,15 +91,6 @@ const CveTrendChart = ({
97
91
 
98
92
  return (
99
93
  <section className="cve-trend" aria-label="CVE trend chart">
100
- <TextContent className="cve-section-title">
101
- <Text component={TextVariants.h4} ouiaId="cve-trend-title">
102
- {__('Trend')}
103
- </Text>
104
- <Text component={TextVariants.small} ouiaId="cve-trend-subtitle">
105
- {__('Last %s scans').replace('%s', visibleScans.length)}
106
- </Text>
107
- </TextContent>
108
-
109
94
  <div className="cve-trend-summary">
110
95
  <div className="cve-trend-card">
111
96
  <span className="cve-trend-card-label">{__('Latest total')}</span>
@@ -1,4 +1,4 @@
1
- /* eslint-disable import/no-unresolved */
1
+ /* eslint-disable import/no-unresolved, react/prop-types */
2
2
  import React from 'react';
3
3
  import { mount } from 'enzyme';
4
4
  import CveDetailsCard from '../CveDetailsCard';
@@ -7,17 +7,20 @@ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
7
7
  useAPI: jest.fn(),
8
8
  }));
9
9
 
10
- jest.mock('foremanReact/components/HostDetails/Templates/CardItem/CardTemplate', () => (
11
- { children }
12
- ) => <div>{children}</div>);
10
+ jest.mock(
11
+ 'foremanReact/components/HostDetails/Templates/CardItem/CardTemplate',
12
+ () => ({ children }) => <div>{children}</div>
13
+ );
13
14
 
14
- jest.mock('foremanReact/components/common/SkeletonLoader', () => (
15
- { children }
16
- ) => <div>{children}</div>);
15
+ jest.mock(
16
+ 'foremanReact/components/common/SkeletonLoader',
17
+ () => ({ children }) => <div>{children}</div>
18
+ );
17
19
 
18
- jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
19
- { date, defaultValue }
20
- ) => <span>{date || defaultValue}</span>);
20
+ jest.mock(
21
+ 'foremanReact/components/common/dates/RelativeDateTime',
22
+ () => ({ date, defaultValue }) => <span>{date || defaultValue}</span>
23
+ );
21
24
 
22
25
  jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
23
26
 
@@ -32,9 +35,27 @@ describe('CveDetailsCard', () => {
32
35
  const hostDetails = { id: 1 };
33
36
  const historyResponse = {
34
37
  results: [
35
- { id: 1, scanned_at: '2026-02-20', scanner: 'trivy', source: 'rex', total: 10 },
36
- { id: 2, scanned_at: '2026-02-21', scanner: 'trivy', source: 'rex', total: 12 },
37
- { id: 3, scanned_at: '2026-02-22', scanner: 'grype', source: 'external', total: 8 },
38
+ {
39
+ id: 1,
40
+ scanned_at: '2026-02-20',
41
+ scanner: 'trivy',
42
+ source: 'rex',
43
+ total: 10,
44
+ },
45
+ {
46
+ id: 2,
47
+ scanned_at: '2026-02-21',
48
+ scanner: 'trivy',
49
+ source: 'rex',
50
+ total: 12,
51
+ },
52
+ {
53
+ id: 3,
54
+ scanned_at: '2026-02-22',
55
+ scanner: 'grype',
56
+ source: 'external',
57
+ total: 8,
58
+ },
38
59
  ],
39
60
  };
40
61
  const latestResponse = {
@@ -73,6 +94,46 @@ describe('CveDetailsCard', () => {
73
94
  expect(wrapper.text()).toContain('pkg');
74
95
  });
75
96
 
97
+ it('renders fix availability summary when latest scan is enriched', () => {
98
+ const hostDetails = { id: 1 };
99
+ const latestResponse = {
100
+ id: 3,
101
+ scanned_at: '2026-02-22',
102
+ scanner: 'grype',
103
+ source: 'external',
104
+ total: 3,
105
+ summary: { worst: 'high' },
106
+ critical: 0,
107
+ high: 1,
108
+ medium: 1,
109
+ low: 1,
110
+ findings: [
111
+ {
112
+ id: 'CVE-1',
113
+ name: 'openssl',
114
+ version: '1.0',
115
+ severity: 'HIGH',
116
+ katello_fix: { status: 'installable' },
117
+ },
118
+ {
119
+ id: 'CVE-2',
120
+ name: 'curl',
121
+ version: '8.0',
122
+ severity: 'MEDIUM',
123
+ katello_fix: { status: 'applicable' },
124
+ },
125
+ ],
126
+ };
127
+
128
+ useAPI.mockReturnValue({ response: latestResponse, status: 'RESOLVED' });
129
+
130
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
131
+
132
+ expect(wrapper.text()).toContain('Fix availability');
133
+ expect(wrapper.text()).toContain('Fixable: 1');
134
+ expect(wrapper.text()).toContain('Applicable: 1');
135
+ });
136
+
76
137
  it('renders empty state when latest is empty', () => {
77
138
  const hostDetails = { id: 1 };
78
139
  useAPI.mockReturnValue({ response: {}, status: 'RESOLVED' });
@@ -84,7 +145,15 @@ describe('CveDetailsCard', () => {
84
145
  it('prefers latest when present', () => {
85
146
  const hostDetails = { id: 1 };
86
147
  const historyResponse = {
87
- results: [{ id: 1, scanned_at: '2026-02-20', scanner: 'trivy', source: 'rex', total: 10 }],
148
+ results: [
149
+ {
150
+ id: 1,
151
+ scanned_at: '2026-02-20',
152
+ scanner: 'trivy',
153
+ source: 'rex',
154
+ total: 10,
155
+ },
156
+ ],
88
157
  };
89
158
  const latestResponse = {
90
159
  id: 2,
@@ -55,7 +55,7 @@ describe('CveFindingsModal', () => {
55
55
  expect(wrapper.text()).not.toContain('CVE-1');
56
56
  });
57
57
 
58
- it('shows empty state when no findings', () => {
58
+ it('shows success state when no findings', () => {
59
59
  useAPI.mockReturnValue({
60
60
  response: {
61
61
  id: 1,
@@ -78,7 +78,10 @@ describe('CveFindingsModal', () => {
78
78
  />
79
79
  );
80
80
 
81
- expect(wrapper.text()).toContain('No findings for selected filter');
81
+ expect(wrapper.text()).toContain('No CVEs found');
82
+ expect(wrapper.text()).toContain(
83
+ 'The latest CVE scan found no vulnerabilities for this host.'
84
+ );
82
85
  });
83
86
 
84
87
  it('filters findings by search text', () => {
@@ -0,0 +1,153 @@
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 CveFindingsView from '../CveFindingsView';
6
+
7
+ describe('CveFindingsView', () => {
8
+ it('renders no reports state when scan is missing', () => {
9
+ const wrapper = mount(
10
+ <CveFindingsView scan={null} status="RESOLVED" initialFilter="all" />
11
+ );
12
+
13
+ expect(wrapper.text()).toContain('No CVE reports for this host');
14
+ expect(wrapper.text()).toContain('Run a CVE scan to see results here.');
15
+ });
16
+
17
+ it('renders success state when scan has no findings', () => {
18
+ const wrapper = mount(
19
+ <CveFindingsView
20
+ scan={{
21
+ id: 1,
22
+ scanned_at: '2026-02-22T10:00:00Z',
23
+ scanner: 'trivy',
24
+ source: 'rex',
25
+ total: 0,
26
+ findings: [],
27
+ }}
28
+ status="RESOLVED"
29
+ initialFilter="all"
30
+ />
31
+ );
32
+
33
+ expect(wrapper.text()).toContain('No CVEs found');
34
+ expect(wrapper.text()).toContain(
35
+ 'The latest CVE scan found no vulnerabilities for this host.'
36
+ );
37
+ });
38
+
39
+ it('filters findings by severity and search text', () => {
40
+ const wrapper = mount(
41
+ <CveFindingsView
42
+ scan={{
43
+ id: 1,
44
+ scanned_at: '2026-02-22T10:00:00Z',
45
+ scanner: 'trivy',
46
+ source: 'rex',
47
+ total: 2,
48
+ findings: [
49
+ {
50
+ id: 'CVE-1',
51
+ severity: 'HIGH',
52
+ name: 'openssl',
53
+ version: '1.1',
54
+ title: 'OpenSSL issue',
55
+ },
56
+ {
57
+ id: 'CVE-2',
58
+ severity: 'LOW',
59
+ name: 'curl',
60
+ version: '8.0',
61
+ title: 'Curl issue',
62
+ },
63
+ ],
64
+ }}
65
+ status="RESOLVED"
66
+ initialFilter="all"
67
+ />
68
+ );
69
+
70
+ const lowButton = wrapper
71
+ .find('button')
72
+ .filterWhere(node => node.text().toLowerCase() === 'low')
73
+ .first();
74
+ lowButton.simulate('click');
75
+ wrapper.update();
76
+
77
+ expect(wrapper.text()).toContain('CVE-2');
78
+ expect(wrapper.text()).not.toContain('CVE-1');
79
+
80
+ const allButton = wrapper
81
+ .find('button')
82
+ .filterWhere(node => node.text().toLowerCase() === 'all')
83
+ .first();
84
+ allButton.simulate('click');
85
+
86
+ act(() => {
87
+ wrapper.find('SearchInput').prop('onChange')('openssl');
88
+ });
89
+ wrapper.update();
90
+
91
+ expect(wrapper.text()).toContain('CVE-1');
92
+ expect(wrapper.text()).not.toContain('CVE-2');
93
+ });
94
+
95
+ it('renders fix availability when present', () => {
96
+ const wrapper = mount(
97
+ <CveFindingsView
98
+ scan={{
99
+ id: 1,
100
+ scanned_at: '2026-02-22T10:00:00Z',
101
+ scanner: 'trivy',
102
+ source: 'rex',
103
+ total: 2,
104
+ findings: [
105
+ {
106
+ id: 'CVE-1',
107
+ severity: 'HIGH',
108
+ name: 'openssl',
109
+ version: '1.1',
110
+ title: 'OpenSSL issue',
111
+ katello_fix: {
112
+ status: 'installable',
113
+ errata: [{ errata_id: 'RHSA-2026:0001' }],
114
+ },
115
+ },
116
+ {
117
+ id: 'CVE-2',
118
+ severity: 'LOW',
119
+ name: 'curl',
120
+ version: '8.0',
121
+ title: 'Curl issue',
122
+ katello_fix: {
123
+ status: 'applicable',
124
+ errata: [{ id: 1, errata_id: 'RHSA-2026:0002' }],
125
+ },
126
+ },
127
+ ],
128
+ }}
129
+ status="RESOLVED"
130
+ initialFilter="all"
131
+ hostName="host.example.com"
132
+ />
133
+ );
134
+
135
+ expect(wrapper.text()).toContain('Fix availability');
136
+ expect(wrapper.text()).toContain('Installable');
137
+ expect(wrapper.text()).toContain('Applicable');
138
+ const installLink = wrapper
139
+ .find('a')
140
+ .filterWhere(node => node.text() === 'Installable')
141
+ .first();
142
+ const applicableLink = wrapper
143
+ .find('a')
144
+ .filterWhere(node => node.text() === 'Applicable')
145
+ .first();
146
+
147
+ expect(installLink.prop('href')).toContain('/job_invocations/new?');
148
+ expect(installLink.prop('href')).toContain(
149
+ 'katello_errata_install_by_search'
150
+ );
151
+ expect(applicableLink.prop('href')).toBe('/errata/1');
152
+ });
153
+ });