diff --git a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js index 6bdb0508..5c980ca5 100644 --- a/src/__tests__/TagPage/VulnerabilitiesDetails.test.js +++ b/src/__tests__/TagPage/VulnerabilitiesDetails.test.js @@ -483,6 +483,12 @@ const mockCVEFixed = { } ] } + }, + pageNotFixed: { + ImageListWithCVEFixed: { + Page: { TotalCount: 0, ItemCount: 0 }, + Results: [] + } } }; @@ -504,15 +510,23 @@ afterEach(() => { describe('Vulnerabilties page', () => { it('renders the vulnerabilities if there are any', async () => { - jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); + let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + for (let i=0; i<21; i++) { + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); + } + getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } }); render(); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1)); - await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20)); + await waitFor(() => expect(screen.getAllByText(/Fixed in/)).toHaveLength(20)); }); it('sends filtered query if user types in the search bar', async () => { - jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); + let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + for (let i=0; i<21; i++) { + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); + } + getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } }); render(); const cveSearchInput = screen.getByPlaceholderText(/search/i); jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } }); @@ -522,7 +536,11 @@ describe('Vulnerabilties page', () => { }); it('should have a collapsable search bar', async () => { - jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); + let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) + for (let i=0; i<21; i++) { + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); + } + getCall.mockResolvedValue({ status: 200, data: { data: mockCVEList } }); render(); const cveSearchInput = screen.getByPlaceholderText(/search/i); const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0]; @@ -544,19 +562,14 @@ describe('Vulnerabilties page', () => { await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1)); }); - it('should open and close description dropdown for vulnerabilities', async () => { - jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); + it('should show description for vulnerabilities', async () => { + jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) + .mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); render(); - await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20)); - const openText = screen.getAllByText(/description/i); - await fireEvent.click(openText[0]); + await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20)); await waitFor(() => expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1) ); - await fireEvent.click(openText[0]); - await waitFor(() => - expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument() - ); }); it("should log an error when data can't be fetched", async () => { @@ -574,12 +587,12 @@ describe('Vulnerabilties page', () => { .mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } }); render(); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); - await fireEvent.click(screen.getAllByText(/fixed in/i)[0]); await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument()); - const loadMoreBtn = screen.getByText(/load more/i); - expect(loadMoreBtn).toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByText(/load more/i).length).toBeGreaterThan(0)); + const nrLoadButtons = screen.getAllByText(/load more/i).length + const loadMoreBtn = screen.getAllByText(/load more/i)[0]; await fireEvent.click(loadMoreBtn); - await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByText(/load more/i).length).toBe(nrLoadButtons-1)); expect(await screen.findByText('latest')).toBeInTheDocument(); }); @@ -587,10 +600,11 @@ describe('Vulnerabilties page', () => { const xlsxMock = jest.createMockFromModule('xlsx'); xlsxMock.writeFile = jest.fn(); - jest - .spyOn(api, 'get') - .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) - .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + for (let i=0; i<21; i++) { + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); + } + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); render(); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); const downloadBtn = await screen.findAllByTestId('DownloadIcon'); @@ -610,10 +624,11 @@ describe('Vulnerabilties page', () => { }); it('should expand/collapse the list of CVEs', async () => { - jest - .spyOn(api, 'get') - .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) - .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + let getCall = jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); + for (let i=0; i<21; i++) { + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageNotFixed } }); + } + getCall.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); render(); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20)); @@ -629,12 +644,11 @@ describe('Vulnerabilties page', () => { jest .spyOn(api, 'get') .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) - .mockRejectedValue({ status: 500, data: {} }); + .mockRejectedValueOnce({ status: 500, data: {} }); render(); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); - await fireEvent.click(screen.getAllByText(/fixed in/i)[0]); - await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument()); - await waitFor(() => expect(error).toBeCalledTimes(1)); + await waitFor(() => expect(screen.getAllByText(/not fixed/i).length).toBeGreaterThan(0)); + await waitFor(() => expect(error).toBeCalled()); }); }); diff --git a/src/api.js b/src/api.js index a8d9435a..0589e992 100644 --- a/src/api.js +++ b/src/api.js @@ -100,7 +100,7 @@ const endpoints = { if (!isEmpty(excludedTerm)) { query += `, excludedCVE: "${excludedTerm}"`; } - return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`; + return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`; }, allVulnerabilitiesForRepo: (name) => `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, diff --git a/src/components/Shared/VulnerabilityCard.jsx b/src/components/Shared/VulnerabilityCard.jsx index ea3a0a69..c201c789 100644 --- a/src/components/Shared/VulnerabilityCard.jsx +++ b/src/components/Shared/VulnerabilityCard.jsx @@ -29,18 +29,46 @@ const useStyles = makeStyles((theme) => ({ marginTop: '2rem', marginBottom: '2rem' }, + cardCollapsed: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + background: '#FFFFFF', + boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)', + border: '1px solid #E0E5EB', + borderRadius: '0.75rem', + flex: 'none', + alignSelf: 'stretch', + width: '100%' + }, content: { textAlign: 'left', color: '#606060', padding: '2% 3% 2% 3%', width: '100%' }, + contentCollapsed: { + textAlign: 'left', + color: '#606060', + padding: '1% 3% 1% 3%', + width: '100%', + '&:last-child': { + paddingBottom: '1%' + } + }, cveId: { color: theme.palette.primary.main, fontSize: '1rem', fontWeight: 400, textDecoration: 'underline' }, + cveIdCollapsed: { + color: theme.palette.primary.main, + fontSize: '0.75rem', + fontWeight: 500, + textDecoration: 'underline', + flexBasis: '19%' + }, cveSummary: { color: theme.palette.secondary.dark, fontSize: '0.75rem', @@ -48,6 +76,13 @@ const useStyles = makeStyles((theme) => ({ textOverflow: 'ellipsis', marginTop: '0.5rem' }, + cveSummaryCollapsed: { + color: theme.palette.secondary.dark, + fontSize: '0.75rem', + fontWeight: '600', + textOverflow: 'ellipsis', + flexBasis: '82%' + }, link: { color: '#52637A', fontSize: '1rem', @@ -72,14 +107,15 @@ const useStyles = makeStyles((theme) => ({ }, vulnerabilityCardDivider: { margin: '1rem 0' + }, + cveInfo: { + marginTop: '2%' } })); function VulnerabilitiyCard(props) { const classes = useStyles(); const { cve, name, platform, expand } = props; const [openCVE, setOpenCVE] = useState(expand); - const [openDesc, setOpenDesc] = useState(false); - const [openFixed, setOpenFixed] = useState(false); const [loadingFixed, setLoadingFixed] = useState(true); const [fixedInfo, setFixedInfo] = useState([]); const abortController = useMemo(() => new AbortController(), []); @@ -89,7 +125,7 @@ function VulnerabilitiyCard(props) { const [isEndOfList, setIsEndOfList] = useState(false); const getPaginatedResults = () => { - if (!openFixed || isEndOfList) { + if (isEndOfList) { return; } setLoadingFixed(true); @@ -125,7 +161,7 @@ function VulnerabilitiyCard(props) { return () => { abortController.abort(); }; - }, [openFixed, pageNumber]); + }, [pageNumber]); useEffect(() => { setOpenCVE(expand); @@ -172,59 +208,99 @@ function VulnerabilitiyCard(props) { }; return ( - - - + + + {!openCVE ? ( setOpenCVE(!openCVE)} /> ) : ( setOpenCVE(!openCVE)} /> )} - + {cve.id} - + {openCVE ? ( + + ) : ( + +
+ +
+ + {cve.title} + +
+ )}
{cve.title} - setOpenFixed(!openFixed)}> - {!openFixed ? ( - - ) : ( - - )} - Fixed in - - - - {loadingFixed ? ( - 'Loading...' - ) : ( - - {renderFixedVer()} - {renderLoadMore()} - - )} - - - setOpenDesc(!openDesc)}> - {!openDesc ? ( - - ) : ( - - )} - Description - - - - - {cve.description} + + External reference + + + {cve.reference} + + + Packages + + + + + Name - - + + Installed Version + + + Fixed Version + +
+ {cve.packageList.map((el) => ( + + + {el.packageName} + + + {el.packageInstalledVersion} + + + {el.packageFixedVersion} + + + ))} + + + Fixed in + + + {loadingFixed ? ( + 'Loading...' + ) : ( + + {renderFixedVer()} + {renderLoadMore()} + + )} + + + Description + + + + {cve.description} + +
diff --git a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx index 0edc14df..38c1a614 100644 --- a/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx +++ b/src/components/Tag/Tabs/VulnerabilitiesDetails.jsx @@ -481,8 +481,10 @@ function VulnerabilitiesDetails(props) { - {renderCVEs()} - {renderListBottom()} + + {renderCVEs()} + {renderListBottom()} + ); } diff --git a/src/utilities/objectModels.js b/src/utilities/objectModels.js index 4635fd60..6caa3f65 100644 --- a/src/utilities/objectModels.js +++ b/src/utilities/objectModels.js @@ -96,7 +96,13 @@ const mapCVEInfo = (cveInfo) => { id: cve.Id, severity: cve.Severity, title: cve.Title, - description: cve.Description + description: cve.Description, + reference: cve.Reference, + packageList: cve.PackageList?.map((pkg) => ({ + packageName: pkg.Name, + packageInstalledVersion: pkg.InstalledVersion, + packageFixedVersion: pkg.FixedVersion + })) }; }); return cveList; diff --git a/tests/tag.spec.js b/tests/tag.spec.js index 88c0e810..0f4fe41a 100644 --- a/tests/tag.spec.js +++ b/tests/tag.spec.js @@ -37,8 +37,8 @@ test.describe('Tag page test', () => { await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`); await page.getByRole('tab', { name: 'Vulnerabilities' }).click(); await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 }); - await expect(page.getByText('CVE-').nth(0)).toBeVisible({ timeout: 100000 }); - await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0); - await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE); + await expect(page.getByText(/CVE-/).nth(0)).toBeVisible({ timeout: 100000 }); + await expect(await page.getByText(/CVE-/).count()).toBeGreaterThan(0); + await expect(await page.getByText(/CVE-/).count()).toBeLessThanOrEqual(pageSizes.EXPLORE); }); });