foreman_cve_scanner 0.0.2 → 0.5.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -16
  3. data/Rakefile +2 -0
  4. data/app/controllers/api/v2/cve_scans_controller.rb +66 -0
  5. data/app/lib/actions/foreman_cve_scanner/cve_scanner_job.rb +3 -18
  6. data/app/models/concerns/foreman_cve_scanner/host_extensions.rb +16 -0
  7. data/app/models/foreman_cve_scanner/cve_scan.rb +15 -0
  8. data/app/models/host_status/cve_status.rb +71 -0
  9. data/app/services/foreman_cve_scanner/cve_report_scanner.rb +37 -35
  10. data/app/services/foreman_cve_scanner/scan_importer.rb +105 -0
  11. data/app/views/api/v2/cve_scans/base.json.rabl +5 -0
  12. data/app/views/api/v2/cve_scans/index.json.rabl +5 -0
  13. data/app/views/api/v2/cve_scans/latest.json.rabl +5 -0
  14. data/app/views/api/v2/cve_scans/main.json.rabl +7 -0
  15. data/app/views/api/v2/cve_scans/show.json.rabl +5 -0
  16. data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +4 -2
  17. data/config/routes.rb +20 -0
  18. data/db/migrate/20260221000000_create_foreman_cve_scanner_cve_scans.rb +22 -0
  19. data/db/seeds.d/75_job_templates.rb +17 -0
  20. data/lib/foreman_cve_scanner/engine.rb +25 -3
  21. data/lib/foreman_cve_scanner/version.rb +3 -1
  22. data/lib/foreman_cve_scanner.rb +4 -1
  23. data/lib/tasks/foreman_cve_scanner_seeds.rake +12 -0
  24. data/lib/tasks/rubocop.rake +33 -0
  25. data/package.json +48 -0
  26. data/test/actions/foreman_cve_scanner/cve_scanner_job_test.rb +54 -0
  27. data/test/controllers/api/v2/cve_scans_controller_test.rb +78 -0
  28. data/test/models/host_status/cve_status_test.rb +65 -0
  29. data/test/services/foreman_cve_scanner/cve_report_scanner_test.rb +45 -17
  30. data/test/services/foreman_cve_scanner/scan_importer_test.rb +85 -0
  31. data/test/test_plugin_helper.rb +2 -0
  32. data/webpack/components/CveDetailsCard.js +258 -0
  33. data/webpack/components/CveFindingsModal.js +274 -0
  34. data/webpack/components/CveHistoryTable.js +58 -0
  35. data/webpack/components/CveOverviewCard.js +67 -0
  36. data/webpack/components/CveScansTab.js +192 -0
  37. data/webpack/components/CveSummaryCell.js +72 -0
  38. data/webpack/components/SeverityIcon.js +56 -0
  39. data/webpack/components/__tests__/CveDetailsCard.test.js +126 -0
  40. data/webpack/components/__tests__/CveFindingsModal.test.js +78 -0
  41. data/webpack/components/__tests__/CveScansTab.test.js +76 -0
  42. data/webpack/components/__tests__/cve_helpers.test.js +42 -0
  43. data/webpack/components/cve_helpers.js +35 -0
  44. data/webpack/components/cve_scans.scss +200 -0
  45. data/webpack/fills.js +1 -0
  46. data/webpack/fills_index.js +38 -0
  47. data/webpack/index.js +1 -0
  48. metadata +49 -5
@@ -0,0 +1,192 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React, { useMemo, useState } from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import {
5
+ Button,
6
+ Pagination,
7
+ EmptyState,
8
+ EmptyStateIcon,
9
+ EmptyStateBody,
10
+ Title,
11
+ } from '@patternfly/react-core';
12
+ import { SearchIcon } from '@patternfly/react-icons';
13
+ import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
14
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
15
+ import { foremanUrl } from 'foremanReact/common/helpers';
16
+ import { translate as __ } from 'foremanReact/common/I18n';
17
+ import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
18
+ import { STATUS } from 'foremanReact/constants';
19
+ import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
20
+ import CveFindingsModal from './CveFindingsModal';
21
+ import { noReportsBody, noReportsTitle } from './cve_helpers';
22
+
23
+ const DEFAULT_PER_PAGE = 20;
24
+
25
+ const CveScansTab = ({ response }) => {
26
+ const hostId = response?.id;
27
+ const [page, setPage] = useState(1);
28
+ const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
29
+ const [isModalOpen, setIsModalOpen] = useState(false);
30
+ const [modalScanId, setModalScanId] = useState(null);
31
+ const [modalFilter, setModalFilter] = useState('all');
32
+
33
+ const historyUrl = hostId
34
+ ? foremanUrl(
35
+ `/api/v2/hosts/${hostId}/cve_scans?page=${page}&per_page=${perPage}`
36
+ )
37
+ : null;
38
+ const { response: apiResponse, status } = useAPI('get', historyUrl, {
39
+ key: `CVE_SCANS_TAB_${hostId}_${page}_${perPage}`,
40
+ });
41
+
42
+ const scans = useMemo(() => apiResponse?.results || [], [apiResponse]);
43
+ const itemCount = apiResponse?.total ?? scans.length;
44
+ if (!hostId) return null;
45
+
46
+ const openModal = (scanId, filter) => {
47
+ setModalScanId(scanId);
48
+ setModalFilter(filter || 'all');
49
+ setIsModalOpen(true);
50
+ };
51
+
52
+ const onSetPage = (_event, newPage) => setPage(newPage);
53
+ const onPerPageSelect = (_event, newPerPage) => {
54
+ setPerPage(newPerPage);
55
+ setPage(1);
56
+ };
57
+
58
+ return (
59
+ <>
60
+ <SkeletonLoader status={status || STATUS.PENDING}>
61
+ {scans.length === 0 ? (
62
+ <EmptyState>
63
+ <EmptyStateIcon icon={SearchIcon} />
64
+ <Title headingLevel="h4" size="md" ouiaId="cve-scans-empty-title">
65
+ {noReportsTitle()}
66
+ </Title>
67
+ <EmptyStateBody>{noReportsBody()}</EmptyStateBody>
68
+ </EmptyState>
69
+ ) : (
70
+ <>
71
+ <Pagination
72
+ itemCount={itemCount}
73
+ perPage={perPage}
74
+ page={page}
75
+ onSetPage={onSetPage}
76
+ onPerPageSelect={onPerPageSelect}
77
+ variant="top"
78
+ isCompact
79
+ ouiaId="cve-scans-pagination"
80
+ />
81
+ <Table
82
+ variant="compact"
83
+ aria-label="CVE scans history"
84
+ ouiaId="cve-scans-table"
85
+ >
86
+ <Thead>
87
+ <Tr ouiaId="cve-scans-header">
88
+ <Th>{__('Reported at')}</Th>
89
+ <Th>{__('Scanner')}</Th>
90
+ <Th>{__('Total')}</Th>
91
+ <Th>{__('Critical')}</Th>
92
+ <Th>{__('High')}</Th>
93
+ <Th>{__('Medium')}</Th>
94
+ <Th>{__('Low')}</Th>
95
+ </Tr>
96
+ </Thead>
97
+ <Tbody>
98
+ {scans.map((scan, index) => (
99
+ <Tr key={scan.id} ouiaId={`cve-scans-row-${index}`}>
100
+ <Td dataLabel={__('Reported at')}>
101
+ <Button
102
+ variant="link"
103
+ className="cve-summary-link"
104
+ onClick={() => openModal(scan.id, 'all')}
105
+ ouiaId={`cve-scans-open-${scan.id}`}
106
+ >
107
+ <RelativeDateTime
108
+ date={scan.created_at}
109
+ defaultValue={__('Unknown time')}
110
+ />
111
+ </Button>
112
+ </Td>
113
+ <Td dataLabel={__('Scanner')}>{scan.scanner}</Td>
114
+ <Td dataLabel={__('Total')}>
115
+ <Button
116
+ variant="link"
117
+ className="cve-summary-link"
118
+ onClick={() => openModal(scan.id, 'all')}
119
+ ouiaId={`cve-scans-total-${scan.id}`}
120
+ >
121
+ {scan.total}
122
+ </Button>
123
+ </Td>
124
+ <Td dataLabel={__('Critical')}>
125
+ <Button
126
+ variant="link"
127
+ className="cve-summary-link"
128
+ onClick={() => openModal(scan.id, 'critical')}
129
+ ouiaId={`cve-scans-critical-${scan.id}`}
130
+ >
131
+ {scan.critical}
132
+ </Button>
133
+ </Td>
134
+ <Td dataLabel={__('High')}>
135
+ <Button
136
+ variant="link"
137
+ className="cve-summary-link"
138
+ onClick={() => openModal(scan.id, 'high')}
139
+ ouiaId={`cve-scans-high-${scan.id}`}
140
+ >
141
+ {scan.high}
142
+ </Button>
143
+ </Td>
144
+ <Td dataLabel={__('Medium')}>
145
+ <Button
146
+ variant="link"
147
+ className="cve-summary-link"
148
+ onClick={() => openModal(scan.id, 'medium')}
149
+ ouiaId={`cve-scans-medium-${scan.id}`}
150
+ >
151
+ {scan.medium}
152
+ </Button>
153
+ </Td>
154
+ <Td dataLabel={__('Low')}>
155
+ <Button
156
+ variant="link"
157
+ className="cve-summary-link"
158
+ onClick={() => openModal(scan.id, 'low')}
159
+ ouiaId={`cve-scans-low-${scan.id}`}
160
+ >
161
+ {scan.low}
162
+ </Button>
163
+ </Td>
164
+ </Tr>
165
+ ))}
166
+ </Tbody>
167
+ </Table>
168
+ </>
169
+ )}
170
+ </SkeletonLoader>
171
+ <CveFindingsModal
172
+ isOpen={isModalOpen}
173
+ onClose={() => setIsModalOpen(false)}
174
+ hostId={hostId}
175
+ scanId={modalScanId}
176
+ initialFilter={modalFilter}
177
+ />
178
+ </>
179
+ );
180
+ };
181
+
182
+ CveScansTab.propTypes = {
183
+ response: PropTypes.shape({
184
+ id: PropTypes.number,
185
+ }),
186
+ };
187
+
188
+ CveScansTab.defaultProps = {
189
+ response: undefined,
190
+ };
191
+
192
+ export default CveScansTab;
@@ -0,0 +1,72 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React, { useState } from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import { Spinner } from '@patternfly/react-core';
5
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
6
+ import { foremanUrl } from 'foremanReact/common/helpers';
7
+ import { translate as __ } from 'foremanReact/common/I18n';
8
+ import { STATUS } from 'foremanReact/constants';
9
+ import SeverityIcon from './SeverityIcon';
10
+ import CveFindingsModal from './CveFindingsModal';
11
+ import './cve_scans.scss';
12
+
13
+ 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
+ const [isModalOpen, setIsModalOpen] = useState(false);
22
+ 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
+ );
31
+ }
32
+
33
+ if (!response) return <span className="cve-summary-empty">--</span>;
34
+
35
+ const total = response.total || 0;
36
+ const worst = response.summary?.worst || 'none';
37
+ const scanId = response.id;
38
+ const title = `${hostName || __('Host')}: ${total} ${__('findings')}`;
39
+
40
+ return (
41
+ <>
42
+ <button
43
+ type="button"
44
+ className="cve-summary cve-summary-link"
45
+ title={title}
46
+ onClick={() => setIsModalOpen(true)}
47
+ >
48
+ <SeverityIcon severity={worst} />
49
+ <span className="cve-summary-count">{total}</span>
50
+ </button>
51
+ <CveFindingsModal
52
+ isOpen={isModalOpen && !!scanId}
53
+ onClose={() => setIsModalOpen(false)}
54
+ hostId={hostId}
55
+ scanId={scanId}
56
+ initialFilter="all"
57
+ />
58
+ </>
59
+ );
60
+ };
61
+
62
+ CveSummaryCell.propTypes = {
63
+ hostId: PropTypes.number,
64
+ hostName: PropTypes.string,
65
+ };
66
+
67
+ CveSummaryCell.defaultProps = {
68
+ hostId: undefined,
69
+ hostName: undefined,
70
+ };
71
+
72
+ export default CveSummaryCell;
@@ -0,0 +1,56 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import PropTypes from 'prop-types';
4
+ import {
5
+ ExclamationCircleIcon,
6
+ ExclamationTriangleIcon,
7
+ InfoCircleIcon,
8
+ CheckCircleIcon,
9
+ } from '@patternfly/react-icons';
10
+ import { Icon } from '@patternfly/react-core';
11
+ import './cve_scans.scss';
12
+
13
+ const SeverityIcon = ({ severity }) => {
14
+ switch (severity) {
15
+ case 'critical':
16
+ return (
17
+ <Icon className="cve-severity cve-severity--critical">
18
+ <ExclamationCircleIcon />
19
+ </Icon>
20
+ );
21
+ case 'high':
22
+ return (
23
+ <Icon className="cve-severity cve-severity--high">
24
+ <ExclamationTriangleIcon />
25
+ </Icon>
26
+ );
27
+ case 'medium':
28
+ return (
29
+ <Icon className="cve-severity cve-severity--medium">
30
+ <InfoCircleIcon />
31
+ </Icon>
32
+ );
33
+ case 'low':
34
+ return (
35
+ <Icon className="cve-severity cve-severity--low">
36
+ <InfoCircleIcon />
37
+ </Icon>
38
+ );
39
+ default:
40
+ return (
41
+ <Icon className="cve-severity cve-severity--none">
42
+ <CheckCircleIcon />
43
+ </Icon>
44
+ );
45
+ }
46
+ };
47
+
48
+ SeverityIcon.propTypes = {
49
+ severity: PropTypes.string,
50
+ };
51
+
52
+ SeverityIcon.defaultProps = {
53
+ severity: 'none',
54
+ };
55
+
56
+ export default SeverityIcon;
@@ -0,0 +1,126 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import CveDetailsCard from '../CveDetailsCard';
5
+
6
+ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
7
+ useAPI: jest.fn(),
8
+ }));
9
+
10
+ jest.mock('foremanReact/components/HostDetails/Templates/CardItem/CardTemplate', () => (
11
+ { children }
12
+ ) => <div>{children}</div>);
13
+
14
+ jest.mock('foremanReact/components/common/SkeletonLoader', () => (
15
+ { children }
16
+ ) => <div>{children}</div>);
17
+
18
+ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
19
+ { date, defaultValue }
20
+ ) => <span>{date || defaultValue}</span>);
21
+
22
+ jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
23
+
24
+ const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
25
+
26
+ describe('CveDetailsCard', () => {
27
+ beforeEach(() => {
28
+ useAPI.mockReset();
29
+ });
30
+
31
+ it('renders recent scans and findings', () => {
32
+ const hostDetails = { id: 1 };
33
+ const historyResponse = {
34
+ results: [
35
+ { id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 },
36
+ { id: 2, created_at: '2026-02-21', scanner: 'trivy', total: 12 },
37
+ { id: 3, created_at: '2026-02-22', scanner: 'grype', total: 8 },
38
+ ],
39
+ };
40
+ const latestResponse = {
41
+ id: 3,
42
+ created_at: '2026-02-22',
43
+ scanner: 'grype',
44
+ total: 8,
45
+ summary: { worst: 'high' },
46
+ critical: 1,
47
+ high: 1,
48
+ medium: 2,
49
+ low: 4,
50
+ findings: [
51
+ {
52
+ id: 'CVE-1',
53
+ name: 'pkg',
54
+ version: '1.0',
55
+ severity: 'HIGH',
56
+ published: '2026-02-10',
57
+ },
58
+ ],
59
+ };
60
+
61
+ useAPI.mockImplementation((_method, url) => {
62
+ if (url && url.includes('/latest')) {
63
+ return { response: latestResponse, status: 'RESOLVED' };
64
+ }
65
+ return { response: historyResponse, status: 'RESOLVED' };
66
+ });
67
+
68
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
69
+
70
+ expect(wrapper.text()).toContain('Recent scans');
71
+ expect(wrapper.find('table').length).toBeGreaterThan(0);
72
+ expect(wrapper.text()).toContain('CVEs');
73
+ expect(wrapper.text()).toContain('pkg');
74
+ });
75
+
76
+ it('falls back to history when latest is empty', () => {
77
+ const hostDetails = { id: 1 };
78
+ const historyResponse = {
79
+ results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
80
+ };
81
+
82
+ useAPI.mockImplementation((_method, url) => {
83
+ if (url && url.includes('/latest')) {
84
+ return { response: {}, status: 'RESOLVED' };
85
+ }
86
+ return { response: historyResponse, status: 'RESOLVED' };
87
+ });
88
+
89
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
90
+ expect(wrapper.text()).toContain('Report');
91
+ expect(wrapper.text()).toContain('trivy');
92
+ });
93
+
94
+ it('prefers latest when present', () => {
95
+ const hostDetails = { id: 1 };
96
+ const historyResponse = {
97
+ results: [{ id: 1, created_at: '2026-02-20', scanner: 'trivy', total: 10 }],
98
+ };
99
+ const latestResponse = {
100
+ id: 2,
101
+ created_at: '2026-02-21',
102
+ scanner: 'grype',
103
+ total: 8,
104
+ summary: { worst: 'high' },
105
+ findings: [],
106
+ };
107
+
108
+ useAPI.mockImplementation((_method, url) => {
109
+ if (url && url.includes('/latest')) {
110
+ return { response: latestResponse, status: 'RESOLVED' };
111
+ }
112
+ return { response: historyResponse, status: 'RESOLVED' };
113
+ });
114
+
115
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
116
+ expect(wrapper.text()).toContain('grype');
117
+ });
118
+
119
+ it('renders empty state when no scans', () => {
120
+ const hostDetails = { id: 1 };
121
+ useAPI.mockReturnValue({ response: null, status: 'RESOLVED' });
122
+
123
+ const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
124
+ expect(wrapper.text()).toContain('No CVE reports for this host');
125
+ });
126
+ });
@@ -0,0 +1,78 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import CveFindingsModal from '../CveFindingsModal';
5
+
6
+ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
7
+ useAPI: jest.fn(),
8
+ }));
9
+
10
+ const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
11
+
12
+ describe('CveFindingsModal', () => {
13
+ beforeEach(() => {
14
+ useAPI.mockReset();
15
+ });
16
+
17
+ it('filters findings by severity', () => {
18
+ useAPI.mockReturnValue({
19
+ response: {
20
+ id: 1,
21
+ created_at: '2026-02-22T10:00:00Z',
22
+ total: 2,
23
+ findings: [
24
+ { id: 'CVE-1', severity: 'HIGH', name: 'a', version: '1' },
25
+ { id: 'CVE-2', severity: 'LOW', name: 'b', version: '2' },
26
+ ],
27
+ },
28
+ status: 'RESOLVED',
29
+ });
30
+
31
+ const wrapper = mount(
32
+ <CveFindingsModal
33
+ isOpen
34
+ onClose={() => {}}
35
+ hostId={1}
36
+ scanId={1}
37
+ initialFilter="all"
38
+ />
39
+ );
40
+
41
+ expect(wrapper.text()).toContain('CVE-1');
42
+ expect(wrapper.text()).toContain('CVE-2');
43
+
44
+ const lowButton = wrapper
45
+ .find('button')
46
+ .filterWhere(node => node.text().toLowerCase() === 'low')
47
+ .first();
48
+ lowButton.simulate('click');
49
+ wrapper.update();
50
+
51
+ expect(wrapper.text()).toContain('CVE-2');
52
+ expect(wrapper.text()).not.toContain('CVE-1');
53
+ });
54
+
55
+ it('shows empty state when no findings', () => {
56
+ useAPI.mockReturnValue({
57
+ response: {
58
+ id: 1,
59
+ created_at: '2026-02-22T10:00:00Z',
60
+ total: 0,
61
+ findings: [],
62
+ },
63
+ status: 'RESOLVED',
64
+ });
65
+
66
+ const wrapper = mount(
67
+ <CveFindingsModal
68
+ isOpen
69
+ onClose={() => {}}
70
+ hostId={1}
71
+ scanId={1}
72
+ initialFilter="all"
73
+ />
74
+ );
75
+
76
+ expect(wrapper.text()).toContain('No findings for selected filter');
77
+ });
78
+ });
@@ -0,0 +1,76 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import React from 'react';
3
+ import { mount } from 'enzyme';
4
+ import CveScansTab from '../CveScansTab';
5
+
6
+ jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
7
+ useAPI: jest.fn(),
8
+ }));
9
+
10
+ jest.mock('foremanReact/components/common/SkeletonLoader', () => (
11
+ { children }
12
+ ) => <div>{children}</div>);
13
+
14
+ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
15
+ { date, defaultValue }
16
+ ) => <span>{date || defaultValue}</span>);
17
+
18
+ jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
19
+
20
+ const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
21
+
22
+ describe('CveScansTab', () => {
23
+ beforeEach(() => {
24
+ useAPI.mockReset();
25
+ });
26
+
27
+ it('renders rows for scans', () => {
28
+ const scansResponse = {
29
+ results: [
30
+ {
31
+ id: 1,
32
+ created_at: '2026-02-20',
33
+ scanner: 'trivy',
34
+ total: 10,
35
+ critical: 1,
36
+ high: 2,
37
+ medium: 3,
38
+ low: 4,
39
+ },
40
+ {
41
+ id: 2,
42
+ created_at: '2026-02-21',
43
+ scanner: 'grype',
44
+ total: 5,
45
+ critical: 0,
46
+ high: 1,
47
+ medium: 1,
48
+ low: 3,
49
+ },
50
+ ],
51
+ total: 2,
52
+ };
53
+
54
+ useAPI.mockReturnValue({ response: scansResponse, status: 'RESOLVED' });
55
+
56
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
57
+
58
+ expect(wrapper.text()).toContain('Reported at');
59
+ expect(wrapper.text()).toContain('trivy');
60
+ expect(wrapper.text()).toContain('grype');
61
+ });
62
+
63
+ it('renders empty state with no scans', () => {
64
+ useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
65
+
66
+ const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
67
+ expect(wrapper.text()).toContain('No CVE reports for this host');
68
+ });
69
+
70
+ it('does not render when host id is missing', () => {
71
+ useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
72
+
73
+ const wrapper = mount(<CveScansTab response={{}} />);
74
+ expect(wrapper.isEmptyRender()).toBe(true);
75
+ });
76
+ });
@@ -0,0 +1,42 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import {
3
+ severityRank,
4
+ riskLevelFromWorst,
5
+ formatDateTime,
6
+ compareStrings,
7
+ } from '../cve_helpers';
8
+
9
+ describe('cve_helpers', () => {
10
+ it('maps severity ranks', () => {
11
+ expect(severityRank('CRITICAL')).toBe(4);
12
+ expect(severityRank('high')).toBe(3);
13
+ expect(severityRank('MEDIUM')).toBe(2);
14
+ expect(severityRank('low')).toBe(1);
15
+ expect(severityRank('unknown')).toBe(0);
16
+ });
17
+
18
+ it('maps risk levels from worst', () => {
19
+ expect(riskLevelFromWorst('critical')).toBe('high');
20
+ expect(riskLevelFromWorst('high')).toBe('high');
21
+ expect(riskLevelFromWorst('medium')).toBe('medium');
22
+ expect(riskLevelFromWorst('low')).toBe('low');
23
+ expect(riskLevelFromWorst('none')).toBe('none');
24
+ });
25
+
26
+ it('formats dates into yyyy-mm-dd hh:mm', () => {
27
+ const result = formatDateTime('2026-02-22T10:05:00Z');
28
+ expect(result).toContain('2026-02-22');
29
+ expect(result).toContain('10:05');
30
+ });
31
+
32
+ it('returns input when date is invalid', () => {
33
+ expect(formatDateTime('not-a-date')).toBe('not-a-date');
34
+ });
35
+
36
+ it('compares strings safely', () => {
37
+ expect(compareStrings('a', 'b')).toBeLessThan(0);
38
+ expect(compareStrings('b', 'a')).toBeGreaterThan(0);
39
+ expect(compareStrings('a', 'a')).toBe(0);
40
+ expect(compareStrings(null, 'a')).toBeLessThan(0);
41
+ });
42
+ });
@@ -0,0 +1,35 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ import { translate as __ } from 'foremanReact/common/I18n';
3
+
4
+ const SEVERITY_RANK = {
5
+ CRITICAL: 4,
6
+ HIGH: 3,
7
+ MEDIUM: 2,
8
+ LOW: 1,
9
+ };
10
+
11
+ export const severityRank = sev =>
12
+ SEVERITY_RANK[(sev || '').toUpperCase()] || 0;
13
+
14
+ export const riskLevelFromWorst = worst => {
15
+ if (['critical', 'high'].includes(worst)) return 'high';
16
+ if (worst === 'medium') return 'medium';
17
+ if (worst === 'low') return 'low';
18
+ return 'none';
19
+ };
20
+
21
+ export const formatDateTime = value => {
22
+ if (!value) return '';
23
+ const d = new Date(value);
24
+ if (Number.isNaN(d.getTime())) return value;
25
+ const pad = n => String(n).padStart(2, '0');
26
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
27
+ d.getHours()
28
+ )}:${pad(d.getMinutes())}`;
29
+ };
30
+
31
+ export const compareStrings = (a, b) =>
32
+ (a || '').toString().localeCompare((b || '').toString());
33
+
34
+ export const noReportsTitle = () => __('No CVE reports for this host');
35
+ export const noReportsBody = () => __('Run a CVE scan to see results here.');