foreman_cve_scanner 0.5.1 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +101 -3
- data/app/controllers/api/v2/cve_scans_controller.rb +170 -1
- data/app/models/foreman_cve_scanner/cve_scan.rb +15 -2
- data/app/models/host_status/cve_status.rb +3 -1
- data/app/services/concerns/foreman_cve_scanner/profiles_uploader.rb +25 -0
- data/app/services/foreman_cve_scanner/cve_report_scanner.rb +3 -6
- data/app/services/foreman_cve_scanner/scan_cleanup.rb +34 -0
- data/app/services/foreman_cve_scanner/scan_comparison.rb +145 -0
- data/app/services/foreman_cve_scanner/scan_importer.rb +17 -15
- data/app/views/api/v2/cve_scans/base.json.rabl +1 -1
- data/app/views/api/v2/cve_scans/main.json.rabl +1 -1
- data/app/views/foreman_cve_scanner/job_templates/install_cve_scanners.erb +3 -3
- data/app/views/foreman_cve_scanner/job_templates/run_cve_scanner.erb +5 -3
- data/config/routes.rb +6 -1
- data/db/migrate/20260514080000_add_source_and_scanned_at_to_foreman_cve_scanner_cve_scans.rb +26 -0
- data/lib/foreman_cve_scanner/engine.rb +68 -17
- data/lib/foreman_cve_scanner/template_helpers.rb +28 -0
- data/lib/foreman_cve_scanner/version.rb +1 -1
- data/lib/tasks/foreman_cve_scanner_tasks.rake +11 -0
- data/package.json +1 -1
- data/test/controllers/api/v2/cve_scans_controller_test.rb +260 -5
- data/test/lib/foreman_cve_scanner/profiles_uploader_test.rb +84 -0
- data/test/lib/foreman_cve_scanner/template_helpers_test.rb +29 -0
- data/test/models/foreman_cve_scanner/cve_scan_test.rb +120 -0
- data/test/models/host_status/cve_status_test.rb +12 -3
- data/test/services/foreman_cve_scanner/scan_cleanup_test.rb +69 -0
- data/test/services/foreman_cve_scanner/scan_comparison_test.rb +84 -0
- data/test/services/foreman_cve_scanner/scan_importer_test.rb +68 -5
- data/webpack/components/CveCompareModal.js +298 -0
- data/webpack/components/CveDetailsCard.js +141 -121
- data/webpack/components/CveFindingsModal.js +131 -111
- data/webpack/components/CveScansReports.js +227 -0
- data/webpack/components/CveScansTab.js +122 -119
- data/webpack/components/CveTrendChart.js +264 -0
- data/webpack/components/__tests__/CveCompareModal.test.js +104 -0
- data/webpack/components/__tests__/CveDetailsCard.test.js +106 -20
- data/webpack/components/__tests__/CveFindingsModal.test.js +54 -2
- data/webpack/components/__tests__/CveScansTab.test.js +185 -5
- data/webpack/components/__tests__/CveTrendChart.test.js +122 -0
- data/webpack/components/__tests__/cve_helpers.test.js +18 -0
- data/webpack/components/cve_helpers.js +139 -0
- data/webpack/components/cve_scans.scss +464 -9
- data/webpack/components/useModalScan.js +26 -0
- metadata +24 -3
- data/webpack/components/CveHistoryTable.js +0 -58
- data/webpack/components/CveOverviewCard.js +0 -67
|
@@ -32,15 +32,16 @@ describe('CveDetailsCard', () => {
|
|
|
32
32
|
const hostDetails = { id: 1 };
|
|
33
33
|
const historyResponse = {
|
|
34
34
|
results: [
|
|
35
|
-
{ id: 1,
|
|
36
|
-
{ id: 2,
|
|
37
|
-
{ id: 3,
|
|
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
38
|
],
|
|
39
39
|
};
|
|
40
40
|
const latestResponse = {
|
|
41
41
|
id: 3,
|
|
42
|
-
|
|
42
|
+
scanned_at: '2026-02-22',
|
|
43
43
|
scanner: 'grype',
|
|
44
|
+
source: 'external',
|
|
44
45
|
total: 8,
|
|
45
46
|
summary: { worst: 'high' },
|
|
46
47
|
critical: 1,
|
|
@@ -67,39 +68,29 @@ describe('CveDetailsCard', () => {
|
|
|
67
68
|
|
|
68
69
|
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
69
70
|
|
|
70
|
-
expect(wrapper.text()).toContain('Recent scans');
|
|
71
71
|
expect(wrapper.find('table').length).toBeGreaterThan(0);
|
|
72
72
|
expect(wrapper.text()).toContain('CVEs');
|
|
73
73
|
expect(wrapper.text()).toContain('pkg');
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it('
|
|
76
|
+
it('renders empty state when latest is empty', () => {
|
|
77
77
|
const hostDetails = { id: 1 };
|
|
78
|
-
|
|
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
|
-
});
|
|
78
|
+
useAPI.mockReturnValue({ response: {}, status: 'RESOLVED' });
|
|
88
79
|
|
|
89
80
|
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
90
|
-
expect(wrapper.text()).toContain('
|
|
91
|
-
expect(wrapper.text()).toContain('trivy');
|
|
81
|
+
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
92
82
|
});
|
|
93
83
|
|
|
94
84
|
it('prefers latest when present', () => {
|
|
95
85
|
const hostDetails = { id: 1 };
|
|
96
86
|
const historyResponse = {
|
|
97
|
-
results: [{ id: 1,
|
|
87
|
+
results: [{ id: 1, scanned_at: '2026-02-20', scanner: 'trivy', source: 'rex', total: 10 }],
|
|
98
88
|
};
|
|
99
89
|
const latestResponse = {
|
|
100
90
|
id: 2,
|
|
101
|
-
|
|
91
|
+
scanned_at: '2026-02-21',
|
|
102
92
|
scanner: 'grype',
|
|
93
|
+
source: 'external',
|
|
103
94
|
total: 8,
|
|
104
95
|
summary: { worst: 'high' },
|
|
105
96
|
findings: [],
|
|
@@ -116,6 +107,76 @@ describe('CveDetailsCard', () => {
|
|
|
116
107
|
expect(wrapper.text()).toContain('grype');
|
|
117
108
|
});
|
|
118
109
|
|
|
110
|
+
it('keeps unknown severities out of the preview when ranked CVEs exist', () => {
|
|
111
|
+
const hostDetails = { id: 1 };
|
|
112
|
+
const latestResponse = {
|
|
113
|
+
id: 2,
|
|
114
|
+
scanned_at: '2026-02-21',
|
|
115
|
+
scanner: 'grype',
|
|
116
|
+
source: 'external',
|
|
117
|
+
total: 6,
|
|
118
|
+
summary: { worst: 'critical' },
|
|
119
|
+
findings: [
|
|
120
|
+
{
|
|
121
|
+
id: 'CVE-unknown',
|
|
122
|
+
name: 'unknown-pkg',
|
|
123
|
+
version: '1.0',
|
|
124
|
+
severity: 'UNKNOWN',
|
|
125
|
+
published: '2026-02-20',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'CVE-critical',
|
|
129
|
+
name: 'critical-pkg',
|
|
130
|
+
version: '1.0',
|
|
131
|
+
severity: 'CRITICAL',
|
|
132
|
+
published: '2026-02-10',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'CVE-high',
|
|
136
|
+
name: 'high-pkg',
|
|
137
|
+
version: '1.0',
|
|
138
|
+
severity: 'HIGH',
|
|
139
|
+
published: '2026-02-11',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'CVE-medium',
|
|
143
|
+
name: 'medium-pkg',
|
|
144
|
+
version: '1.0',
|
|
145
|
+
severity: 'MEDIUM',
|
|
146
|
+
published: '2026-02-12',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'CVE-low-a',
|
|
150
|
+
name: 'low-a-pkg',
|
|
151
|
+
version: '1.0',
|
|
152
|
+
severity: 'LOW',
|
|
153
|
+
published: '2026-02-13',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'CVE-low-b',
|
|
157
|
+
name: 'low-b-pkg',
|
|
158
|
+
version: '1.0',
|
|
159
|
+
severity: 'LOW',
|
|
160
|
+
published: '2026-02-14',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
useAPI.mockImplementation((_method, url) => {
|
|
166
|
+
if (url && url.includes('/latest')) {
|
|
167
|
+
return { response: latestResponse, status: 'RESOLVED' };
|
|
168
|
+
}
|
|
169
|
+
return { response: { results: [] }, status: 'RESOLVED' };
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
173
|
+
|
|
174
|
+
expect(wrapper.text()).toContain('critical-pkg');
|
|
175
|
+
expect(wrapper.text()).toContain('low-b-pkg');
|
|
176
|
+
expect(wrapper.text()).not.toContain('unknown-pkg');
|
|
177
|
+
expect(wrapper.text()).toContain('More');
|
|
178
|
+
});
|
|
179
|
+
|
|
119
180
|
it('renders empty state when no scans', () => {
|
|
120
181
|
const hostDetails = { id: 1 };
|
|
121
182
|
useAPI.mockReturnValue({ response: null, status: 'RESOLVED' });
|
|
@@ -123,4 +184,29 @@ describe('CveDetailsCard', () => {
|
|
|
123
184
|
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
124
185
|
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
125
186
|
});
|
|
187
|
+
|
|
188
|
+
it('renders success state when latest scan has no findings', () => {
|
|
189
|
+
const hostDetails = { id: 1 };
|
|
190
|
+
const latestResponse = {
|
|
191
|
+
id: 2,
|
|
192
|
+
scanned_at: '2026-02-21',
|
|
193
|
+
scanner: 'trivy',
|
|
194
|
+
source: 'rex',
|
|
195
|
+
total: 0,
|
|
196
|
+
summary: { worst: 'none' },
|
|
197
|
+
critical: 0,
|
|
198
|
+
high: 0,
|
|
199
|
+
medium: 0,
|
|
200
|
+
low: 0,
|
|
201
|
+
findings: [],
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
useAPI.mockReturnValue({ response: latestResponse, status: 'RESOLVED' });
|
|
205
|
+
|
|
206
|
+
const wrapper = mount(<CveDetailsCard hostDetails={hostDetails} />);
|
|
207
|
+
expect(wrapper.text()).toContain('No CVEs found');
|
|
208
|
+
expect(wrapper.text()).toContain(
|
|
209
|
+
'The latest CVE scan found no vulnerabilities for this host.'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
126
212
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable import/no-unresolved */
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { mount } from 'enzyme';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
4
5
|
import CveFindingsModal from '../CveFindingsModal';
|
|
5
6
|
|
|
6
7
|
jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
|
|
@@ -18,7 +19,9 @@ describe('CveFindingsModal', () => {
|
|
|
18
19
|
useAPI.mockReturnValue({
|
|
19
20
|
response: {
|
|
20
21
|
id: 1,
|
|
21
|
-
|
|
22
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
23
|
+
scanner: 'trivy',
|
|
24
|
+
source: 'rex',
|
|
22
25
|
total: 2,
|
|
23
26
|
findings: [
|
|
24
27
|
{ id: 'CVE-1', severity: 'HIGH', name: 'a', version: '1' },
|
|
@@ -56,7 +59,9 @@ describe('CveFindingsModal', () => {
|
|
|
56
59
|
useAPI.mockReturnValue({
|
|
57
60
|
response: {
|
|
58
61
|
id: 1,
|
|
59
|
-
|
|
62
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
63
|
+
scanner: 'trivy',
|
|
64
|
+
source: 'rex',
|
|
60
65
|
total: 0,
|
|
61
66
|
findings: [],
|
|
62
67
|
},
|
|
@@ -75,4 +80,51 @@ describe('CveFindingsModal', () => {
|
|
|
75
80
|
|
|
76
81
|
expect(wrapper.text()).toContain('No findings for selected filter');
|
|
77
82
|
});
|
|
83
|
+
|
|
84
|
+
it('filters findings by search text', () => {
|
|
85
|
+
useAPI.mockReturnValue({
|
|
86
|
+
response: {
|
|
87
|
+
id: 1,
|
|
88
|
+
scanned_at: '2026-02-22T10:00:00Z',
|
|
89
|
+
scanner: 'trivy',
|
|
90
|
+
source: 'rex',
|
|
91
|
+
total: 2,
|
|
92
|
+
findings: [
|
|
93
|
+
{
|
|
94
|
+
id: 'CVE-1',
|
|
95
|
+
severity: 'HIGH',
|
|
96
|
+
name: 'openssl',
|
|
97
|
+
version: '1.1',
|
|
98
|
+
title: 'OpenSSL issue',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'CVE-2',
|
|
102
|
+
severity: 'LOW',
|
|
103
|
+
name: 'curl',
|
|
104
|
+
version: '8.0',
|
|
105
|
+
title: 'Curl issue',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
status: 'RESOLVED',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const wrapper = mount(
|
|
113
|
+
<CveFindingsModal
|
|
114
|
+
isOpen
|
|
115
|
+
onClose={() => {}}
|
|
116
|
+
hostId={1}
|
|
117
|
+
scanId={1}
|
|
118
|
+
initialFilter="all"
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
wrapper.find('SearchInput').prop('onChange')('openssl');
|
|
124
|
+
});
|
|
125
|
+
wrapper.update();
|
|
126
|
+
|
|
127
|
+
expect(wrapper.text()).toContain('CVE-1');
|
|
128
|
+
expect(wrapper.text()).not.toContain('CVE-2');
|
|
129
|
+
});
|
|
78
130
|
});
|
|
@@ -16,6 +16,9 @@ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => (
|
|
|
16
16
|
) => <span>{date || defaultValue}</span>);
|
|
17
17
|
|
|
18
18
|
jest.mock('../CveFindingsModal', () => () => <div data-test="modal" />);
|
|
19
|
+
jest.mock('../CveCompareModal', () => ({ isOpen, scanIds }) =>
|
|
20
|
+
isOpen ? <div data-test="compare-modal">{scanIds.join(',')}</div> : null
|
|
21
|
+
);
|
|
19
22
|
|
|
20
23
|
const { useAPI } = require('foremanReact/common/hooks/API/APIHooks');
|
|
21
24
|
|
|
@@ -24,13 +27,14 @@ describe('CveScansTab', () => {
|
|
|
24
27
|
useAPI.mockReset();
|
|
25
28
|
});
|
|
26
29
|
|
|
27
|
-
it('renders
|
|
30
|
+
it('renders overview and reports sub-tabs', () => {
|
|
28
31
|
const scansResponse = {
|
|
29
32
|
results: [
|
|
30
33
|
{
|
|
31
34
|
id: 1,
|
|
32
|
-
|
|
35
|
+
scanned_at: '2026-02-20',
|
|
33
36
|
scanner: 'trivy',
|
|
37
|
+
source: 'rex',
|
|
34
38
|
total: 10,
|
|
35
39
|
critical: 1,
|
|
36
40
|
high: 2,
|
|
@@ -39,8 +43,9 @@ describe('CveScansTab', () => {
|
|
|
39
43
|
},
|
|
40
44
|
{
|
|
41
45
|
id: 2,
|
|
42
|
-
|
|
46
|
+
scanned_at: '2026-02-21',
|
|
43
47
|
scanner: 'grype',
|
|
48
|
+
source: 'external',
|
|
44
49
|
total: 5,
|
|
45
50
|
critical: 0,
|
|
46
51
|
high: 1,
|
|
@@ -55,9 +60,158 @@ describe('CveScansTab', () => {
|
|
|
55
60
|
|
|
56
61
|
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
57
62
|
|
|
58
|
-
expect(wrapper.text()).toContain('
|
|
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');
|
|
67
|
+
});
|
|
68
|
+
|
|
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' });
|
|
99
|
+
|
|
100
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
101
|
+
|
|
102
|
+
wrapper
|
|
103
|
+
.find('button')
|
|
104
|
+
.filterWhere(node => node.text() === 'Compare 2 reports')
|
|
105
|
+
.first()
|
|
106
|
+
.simulate('click');
|
|
107
|
+
wrapper.update();
|
|
108
|
+
|
|
109
|
+
wrapper.find('button.cve-trend-bar-button').at(0).simulate('click');
|
|
110
|
+
wrapper.find('button.cve-trend-bar-button').at(1).simulate('click');
|
|
111
|
+
wrapper.update();
|
|
112
|
+
|
|
113
|
+
expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
|
|
114
|
+
});
|
|
115
|
+
|
|
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' });
|
|
146
|
+
|
|
147
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
148
|
+
|
|
149
|
+
wrapper
|
|
150
|
+
.find('[role="tab"]')
|
|
151
|
+
.filterWhere(node => node.text() === 'Reports')
|
|
152
|
+
.first()
|
|
153
|
+
.simulate('click');
|
|
154
|
+
wrapper.update();
|
|
155
|
+
|
|
156
|
+
expect(wrapper.text()).toContain('Scanned at');
|
|
59
157
|
expect(wrapper.text()).toContain('trivy');
|
|
60
|
-
expect(wrapper.text()).toContain('grype');
|
|
158
|
+
expect(wrapper.text()).toContain('grype / external');
|
|
159
|
+
expect(wrapper.text()).toContain('Export CSV');
|
|
160
|
+
expect(wrapper.text()).toContain('Compare selected');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
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' });
|
|
193
|
+
|
|
194
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
195
|
+
|
|
196
|
+
wrapper
|
|
197
|
+
.find('[role="tab"]')
|
|
198
|
+
.filterWhere(node => node.text() === 'Reports')
|
|
199
|
+
.first()
|
|
200
|
+
.simulate('click');
|
|
201
|
+
wrapper.update();
|
|
202
|
+
|
|
203
|
+
wrapper.find('input#cve-scan-select-1').simulate('change');
|
|
204
|
+
wrapper.find('input#cve-scan-select-2').simulate('change');
|
|
205
|
+
wrapper.update();
|
|
206
|
+
|
|
207
|
+
wrapper
|
|
208
|
+
.find('button')
|
|
209
|
+
.filterWhere(node => node.text() === 'Compare selected')
|
|
210
|
+
.first()
|
|
211
|
+
.simulate('click');
|
|
212
|
+
wrapper.update();
|
|
213
|
+
|
|
214
|
+
expect(wrapper.find('[data-test="compare-modal"]').text()).toBe('1,2');
|
|
61
215
|
});
|
|
62
216
|
|
|
63
217
|
it('renders empty state with no scans', () => {
|
|
@@ -67,6 +221,32 @@ describe('CveScansTab', () => {
|
|
|
67
221
|
expect(wrapper.text()).toContain('No CVE reports for this host');
|
|
68
222
|
});
|
|
69
223
|
|
|
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' });
|
|
243
|
+
|
|
244
|
+
const wrapper = mount(<CveScansTab response={{ id: 1 }} />);
|
|
245
|
+
expect(wrapper.text()).toContain('Trend');
|
|
246
|
+
expect(wrapper.text()).toContain('Latest total');
|
|
247
|
+
expect(wrapper.text()).toContain('0');
|
|
248
|
+
});
|
|
249
|
+
|
|
70
250
|
it('does not render when host id is missing', () => {
|
|
71
251
|
useAPI.mockReturnValue({ response: { results: [] }, status: 'RESOLVED' });
|
|
72
252
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* eslint-disable import/no-unresolved */
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import CveTrendChart from '../CveTrendChart';
|
|
5
|
+
|
|
6
|
+
describe('CveTrendChart', () => {
|
|
7
|
+
it('renders the last scan summary and legend', () => {
|
|
8
|
+
const wrapper = mount(
|
|
9
|
+
<CveTrendChart
|
|
10
|
+
scans={[
|
|
11
|
+
{
|
|
12
|
+
id: 12,
|
|
13
|
+
scanned_at: '2026-05-14T10:00:00Z',
|
|
14
|
+
total: 18,
|
|
15
|
+
critical: 2,
|
|
16
|
+
high: 4,
|
|
17
|
+
medium: 5,
|
|
18
|
+
low: 7,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 11,
|
|
22
|
+
scanned_at: '2026-05-10T10:00:00Z',
|
|
23
|
+
total: 15,
|
|
24
|
+
critical: 1,
|
|
25
|
+
high: 3,
|
|
26
|
+
medium: 5,
|
|
27
|
+
low: 6,
|
|
28
|
+
},
|
|
29
|
+
]}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(wrapper.text()).toContain('Trend');
|
|
34
|
+
expect(wrapper.text()).toContain('Last 2 scans');
|
|
35
|
+
expect(wrapper.text()).toContain('Latest total');
|
|
36
|
+
expect(wrapper.text()).toContain('18');
|
|
37
|
+
expect(wrapper.text()).toContain('Critical');
|
|
38
|
+
expect(wrapper.text()).toContain('Low');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('opens the selected scan when a bar is clicked', () => {
|
|
42
|
+
const onOpen = jest.fn();
|
|
43
|
+
const wrapper = mount(
|
|
44
|
+
<CveTrendChart
|
|
45
|
+
scans={[
|
|
46
|
+
{
|
|
47
|
+
id: 2,
|
|
48
|
+
scanned_at: '2026-05-14T10:00:00Z',
|
|
49
|
+
total: 12,
|
|
50
|
+
critical: 2,
|
|
51
|
+
high: 3,
|
|
52
|
+
medium: 3,
|
|
53
|
+
low: 4,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 1,
|
|
57
|
+
scanned_at: '2026-05-10T10:00:00Z',
|
|
58
|
+
total: 8,
|
|
59
|
+
critical: 1,
|
|
60
|
+
high: 1,
|
|
61
|
+
medium: 2,
|
|
62
|
+
low: 4,
|
|
63
|
+
},
|
|
64
|
+
]}
|
|
65
|
+
onOpen={onOpen}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
wrapper.find('button.cve-trend-bar-button').at(0).simulate('click');
|
|
70
|
+
|
|
71
|
+
expect(onOpen).toHaveBeenCalledWith(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders compare mode controls and uses bar clicks for selection', () => {
|
|
75
|
+
const onToggleSelection = jest.fn();
|
|
76
|
+
const onToggleCompareMode = jest.fn();
|
|
77
|
+
const onOpen = jest.fn();
|
|
78
|
+
const wrapper = mount(
|
|
79
|
+
<CveTrendChart
|
|
80
|
+
scans={[
|
|
81
|
+
{
|
|
82
|
+
id: 2,
|
|
83
|
+
scanned_at: '2026-05-14T10:00:00Z',
|
|
84
|
+
total: 12,
|
|
85
|
+
critical: 2,
|
|
86
|
+
high: 3,
|
|
87
|
+
medium: 3,
|
|
88
|
+
low: 4,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 1,
|
|
92
|
+
scanned_at: '2026-05-10T10:00:00Z',
|
|
93
|
+
total: 8,
|
|
94
|
+
critical: 1,
|
|
95
|
+
high: 1,
|
|
96
|
+
medium: 2,
|
|
97
|
+
low: 4,
|
|
98
|
+
},
|
|
99
|
+
]}
|
|
100
|
+
compareMode
|
|
101
|
+
selectedScanIds={[2]}
|
|
102
|
+
onOpen={onOpen}
|
|
103
|
+
onToggleSelection={onToggleSelection}
|
|
104
|
+
onToggleCompareMode={onToggleCompareMode}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(wrapper.text()).toContain('Cancel compare');
|
|
109
|
+
expect(wrapper.text()).toContain('1/2 selected');
|
|
110
|
+
|
|
111
|
+
wrapper.find('button.cve-trend-bar-button').at(1).simulate('click');
|
|
112
|
+
wrapper
|
|
113
|
+
.find('button')
|
|
114
|
+
.filterWhere(node => node.text() === 'Cancel compare')
|
|
115
|
+
.first()
|
|
116
|
+
.simulate('click');
|
|
117
|
+
|
|
118
|
+
expect(onToggleSelection).toHaveBeenCalledWith(2);
|
|
119
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
120
|
+
expect(onToggleCompareMode).toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
severityRank,
|
|
4
4
|
riskLevelFromWorst,
|
|
5
5
|
formatDateTime,
|
|
6
|
+
formatScanOrigin,
|
|
7
|
+
formatScannedAt,
|
|
6
8
|
compareStrings,
|
|
9
|
+
visibleScanSource,
|
|
7
10
|
} from '../cve_helpers';
|
|
8
11
|
|
|
9
12
|
describe('cve_helpers', () => {
|
|
@@ -33,10 +36,25 @@ describe('cve_helpers', () => {
|
|
|
33
36
|
expect(formatDateTime('not-a-date')).toBe('not-a-date');
|
|
34
37
|
});
|
|
35
38
|
|
|
39
|
+
it('formats scan origin without rex source noise', () => {
|
|
40
|
+
expect(formatScanOrigin('trivy', 'rex')).toBe('trivy');
|
|
41
|
+
expect(formatScanOrigin('grype', 'external')).toBe('grype / external');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('formats scanned_at with unknown fallback', () => {
|
|
45
|
+
expect(formatScannedAt('2026-02-22T10:05:00Z')).toContain('2026-02-22');
|
|
46
|
+
expect(formatScannedAt('')).toBe('Unknown time');
|
|
47
|
+
});
|
|
48
|
+
|
|
36
49
|
it('compares strings safely', () => {
|
|
37
50
|
expect(compareStrings('a', 'b')).toBeLessThan(0);
|
|
38
51
|
expect(compareStrings('b', 'a')).toBeGreaterThan(0);
|
|
39
52
|
expect(compareStrings('a', 'a')).toBe(0);
|
|
40
53
|
expect(compareStrings(null, 'a')).toBeLessThan(0);
|
|
41
54
|
});
|
|
55
|
+
|
|
56
|
+
it('hides rex source and keeps external source visible', () => {
|
|
57
|
+
expect(visibleScanSource('rex')).toBe('');
|
|
58
|
+
expect(visibleScanSource('external')).toBe('external');
|
|
59
|
+
});
|
|
42
60
|
});
|