From 1271a6e125d07e63c76130e0a6d3af9f4a8d0036 Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Tue, 12 May 2026 16:44:02 +0200 Subject: [PATCH 1/8] improve test for report/tls --- .../__tests__/TlsCertificatesTab.test.jsx | 22 +-- .../__tests__/TlsCertificatesTable.test.jsx | 130 ++++++++++++++++++ 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx index 7004b64371..c934d849e4 100644 --- a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx @@ -4,7 +4,7 @@ */ import {describe, test, expect, testing} from '@gsa/testing'; -import {rendererWith, fireEvent} from 'web/testing'; +import {rendererWith, fireEvent, screen, within} from 'web/testing'; import Filter from 'gmp/models/filter'; import {createSession} from 'gmp/testing'; import {getMockReport} from 'web/pages/reports/__fixtures__/MockReport'; @@ -31,7 +31,7 @@ describe('Report TLS Certificates Tab tests', () => { gmp: createGmp(), }); - const {baseElement} = render( + render( { />, ); - const links = baseElement.querySelectorAll('a'); - const header = baseElement.querySelectorAll('th'); - const rows = baseElement.querySelectorAll('tr'); + const table = screen.getByRole('table'); + const header = within(table).getAllByRole('columnheader'); + const rows = within(table).getAllByRole('row'); + const links = within(table).getAllByRole('link'); // Headings expect(header[0]).toHaveTextContent('DN'); @@ -108,7 +109,8 @@ describe('Report TLS Certificates Tab tests', () => { expect(rows[3]).toHaveTextContent('8445'); // Filter - expect(baseElement).toHaveTextContent( + expect(screen.getByTestId('entities-table')).toBeInTheDocument(); + expect(screen.getByText(/Applied filter:/)).toHaveTextContent( '(Applied filter: apply_overrides=0 levels=hml rows=3 min_qod=70 first=1 sort-reverse=severity)', ); }); @@ -125,7 +127,7 @@ describe('Report TLS Certificates Tab tests', () => { gmp: createGmp(), }); - const {baseElement} = render( + render( { />, ); - const icons = baseElement.querySelectorAll('svg'); + const downloadIcons = screen.getAllByTestId('download-icon'); - fireEvent.click(icons[11]); + fireEvent.click(downloadIcons[0]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( tlsCertificates.entities[0], ); - fireEvent.click(icons[12]); + fireEvent.click(downloadIcons[1]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( tlsCertificates.entities[1], ); diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx new file mode 100644 index 0000000000..5979b8c460 --- /dev/null +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx @@ -0,0 +1,130 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, fireEvent, screen, within} from 'web/testing'; +import Filter from 'gmp/models/filter'; +import {createSession} from 'gmp/testing'; +import {getMockReport} from 'web/pages/reports/__fixtures__/MockReport'; +import TLSCertificatesTable from 'web/pages/reports/details/TlsCertificatesTable'; + +const filter = Filter.fromString('rows=3 first=1'); + +const createGmp = () => ({ + session: createSession({timezone: 'CET'}), +}); + +describe('TLSCertificatesTable', () => { + test('should render table with expected columns and row values', () => { + const {tlsCertificates} = getMockReport(); + + const {render} = rendererWith({ + router: true, + gmp: createGmp(), + }); + + render( + , + ); + + const table = screen.getByRole('table'); + const columnHeaders = within(table).getAllByRole('columnheader'); + + expect(columnHeaders.some(th => /DN/i.exec(th.textContent))).toBe(true); + expect(columnHeaders.some(th => /Serial/i.exec(th.textContent))).toBe(true); + expect(columnHeaders.some(th => /Activates/i.exec(th.textContent))).toBe( + true, + ); + expect(columnHeaders.some(th => /Expires/i.exec(th.textContent))).toBe( + true, + ); + expect(columnHeaders.some(th => /IP/i.exec(th.textContent))).toBe(true); + expect(columnHeaders.some(th => /Hostname/i.exec(th.textContent))).toBe( + true, + ); + expect(columnHeaders.some(th => /Port/i.exec(th.textContent))).toBe(true); + expect(columnHeaders.some(th => /Actions/i.exec(th.textContent))).toBe( + true, + ); + + expect(screen.getAllByText('CN=LoremIpsumSubject1 C=Dolor').length).toBe(2); + expect(screen.getAllByText('00B49C541FF5A8E1D9').length).toBe(2); + expect(screen.getAllByText('foo.bar').length).toBe(2); + expect(screen.getByText('4021')).toBeInTheDocument(); + + const [hostLink] = screen.getAllByRole('link', {name: '192.168.9.90'}); + expect(hostLink).toHaveAttribute( + 'href', + '/hosts?filter=name%3D192.168.9.90', + ); + expect(hostLink).toHaveAttribute( + 'title', + 'Show all Hosts with IP 192.168.9.90', + ); + }); + + test('should call download click handler', () => { + const {tlsCertificates} = getMockReport(); + const onTlsCertificateDownloadClick = testing.fn(); + + const {render} = rendererWith({ + router: true, + gmp: createGmp(), + }); + + render( + , + ); + + const downloadIcons = screen.getAllByTestId('download-icon'); + fireEvent.click(downloadIcons[0]); + + expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( + tlsCertificates.entities[0], + ); + }); + + test('should render table without actions column when disabled', () => { + const {tlsCertificates} = getMockReport(); + + const {render} = rendererWith({ + router: true, + gmp: createGmp(), + }); + + render( + , + ); + + const table = screen.getByRole('table'); + const columnHeaders = within(table).getAllByRole('columnheader'); + + expect(columnHeaders).toHaveLength(7); + expect(columnHeaders.some(th => /Actions/i.exec(th.textContent))).toBe( + false, + ); + expect(screen.queryByTestId('download-icon')).not.toBeInTheDocument(); + }); +}); From 384398530b82b9e185f75e0240909d20338299db Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Tue, 12 May 2026 17:01:45 +0200 Subject: [PATCH 2/8] refactor tlsCertificateTable --- .../reports/details/TlsCertificatesTable.jsx | 217 ++++++++++-------- 1 file changed, 126 insertions(+), 91 deletions(-) diff --git a/src/web/pages/reports/details/TlsCertificatesTable.jsx b/src/web/pages/reports/details/TlsCertificatesTable.jsx index b1b21b6c94..cc03a379ae 100644 --- a/src/web/pages/reports/details/TlsCertificatesTable.jsx +++ b/src/web/pages/reports/details/TlsCertificatesTable.jsx @@ -20,6 +20,105 @@ import TlsCertificateDetails from 'web/pages/tlscertificates/Details'; import PropTypes from 'web/utils/PropTypes'; import {formattedUserSettingShortDate} from 'web/utils/user-setting-time-date-formatters'; +const Div = styled.div` + word-break: break-all; +`; + +const StyledSpan = styled.span` + word-break: break-all; +`; + +const getColumns = ({ + actions = true, + links = true, + onTlsCertificateDownloadClick, + onToggleDetailsClick, +}) => + [ + { + key: 'dn', + title: _('Subject DN'), + width: actions ? '35%' : '40%', + sortBy: 'dn', + render: entity => ( + + +
{entity.subjectDn}
+
+
+ ), + }, + { + key: 'serial', + title: _('Serial'), + width: '10%', + sortBy: 'serial', + render: entity => entity.serial, + }, + { + key: 'notvalidbefore', + title: _('Activates'), + width: '10%', + sortBy: 'notvalidbefore', + render: entity => ( + + ), + }, + { + key: 'notvalidafter', + title: _('Expires'), + width: '10%', + sortBy: 'notvalidafter', + render: entity => ( + + ), + }, + { + key: 'ip', + title: _('IP'), + width: '10%', + sortBy: 'ip', + render: entity => ( + + {entity.ip} + + ), + }, + { + key: 'hostname', + title: _('Hostname'), + width: '15%', + sortBy: 'hostname', + render: entity => entity.hostname, + }, + { + key: 'port', + title: _('Port'), + width: '5%', + sortBy: 'port', + render: entity => entity.port, + }, + { + key: 'actions', + title: _('Actions'), + width: '5%', + align: 'center', + render: entity => ( + + ), + hidden: !actions, + }, + ].filter(column => !column.hidden); + const Header = ({ actions = true, currentSortDir, @@ -27,52 +126,23 @@ const Header = ({ sort = true, onSortChange, }) => { - const sortProps = { - currentSortDir, - currentSortBy, - sort, - onSortChange, - }; + const columns = getColumns({actions}); + return ( - - - - - - - - {actions && ( - - {_('Actions')} - - )} + {columns.map(column => ( + + ))} ); @@ -86,14 +156,6 @@ Header.propTypes = { onSortChange: PropTypes.func, }; -const Div = styled.div` - word-break: break-all; -`; - -const StyledSpan = styled.span` - word-break: break-all; -`; - const Row = ({ actions = true, entity, @@ -101,50 +163,23 @@ const Row = ({ onTlsCertificateDownloadClick, onToggleDetailsClick, }) => { - const {serial, activationTime, expirationTime, hostname, ip, port} = entity; + const columns = getColumns({ + actions, + links, + onTlsCertificateDownloadClick, + onToggleDetailsClick, + }); + return ( - - - -
{entity.subjectDn}
-
-
-
- {serial} - - - - - - - - ( + - {ip} - - - {hostname} - {port} - {actions && ( - - + {column.render(entity)} - )} + ))}
); }; From 74f33046ce08a30d991a1f40f88cff7800fa22a3 Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Tue, 12 May 2026 17:13:29 +0200 Subject: [PATCH 3/8] refactor: convert to typescript report/tlscertificates --- ...ificatesTab.jsx => TlsCertificatesTab.tsx} | 32 +++++---- ...atesTable.jsx => TlsCertificatesTable.tsx} | 72 ++++++++++--------- ...b.test.jsx => TlsCertificatesTab.test.tsx} | 14 ++-- ...test.jsx => TlsCertificatesTable.test.tsx} | 20 ++++-- 4 files changed, 77 insertions(+), 61 deletions(-) rename src/web/pages/reports/details/{TlsCertificatesTab.jsx => TlsCertificatesTab.tsx} (73%) rename src/web/pages/reports/details/{TlsCertificatesTable.jsx => TlsCertificatesTable.tsx} (74%) rename src/web/pages/reports/details/__tests__/{TlsCertificatesTab.test.jsx => TlsCertificatesTab.test.tsx} (92%) rename src/web/pages/reports/details/__tests__/{TlsCertificatesTable.test.jsx => TlsCertificatesTable.test.tsx} (83%) diff --git a/src/web/pages/reports/details/TlsCertificatesTab.jsx b/src/web/pages/reports/details/TlsCertificatesTab.tsx similarity index 73% rename from src/web/pages/reports/details/TlsCertificatesTab.jsx rename to src/web/pages/reports/details/TlsCertificatesTab.tsx index 4552b83ec5..71a291a88c 100644 --- a/src/web/pages/reports/details/TlsCertificatesTab.jsx +++ b/src/web/pages/reports/details/TlsCertificatesTab.tsx @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; +import type CollectionCounts from 'gmp/collection/collection-counts'; +import type Filter from 'gmp/models/filter'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; import ReportEntitiesContainer from 'web/pages/reports/details/ReportEntitiesContainer'; import TLSCertificatesTable from 'web/pages/reports/details/TlsCertificatesTable'; -import PropTypes from 'web/utils/PropTypes'; import { makeCompareDate, makeCompareIp, @@ -14,6 +15,17 @@ import { makeCompareString, } from 'web/utils/Sort'; +interface TLSCertificatesTabProps { + counts?: CollectionCounts; + filter: Filter; + isUpdating?: boolean; + sortField: string; + sortReverse: boolean; + tlsCertificates?: ReportTLSCertificate[]; + onSortChange: (sortBy: string) => void; + onTlsCertificateDownloadClick: (entity: ReportTLSCertificate) => void; +} + const tlsCertificatesSortFunctions = { dn: makeCompareString('subjectDn'), serial: makeCompareString('serial'), @@ -34,8 +46,8 @@ const TLSCertificatesTab = ({ onSortChange, onTlsCertificateDownloadClick, -}) => ( - ( + counts={counts} entities={tlsCertificates} filter={filter} @@ -54,6 +66,7 @@ const TLSCertificatesTab = ({ onPreviousClick, }) => ( ); -TLSCertificatesTab.propTypes = { - counts: PropTypes.object, - filter: PropTypes.filter.isRequired, - isUpdating: PropTypes.bool, - sortField: PropTypes.string.isRequired, - sortReverse: PropTypes.bool.isRequired, - tlsCertificates: PropTypes.array, - onSortChange: PropTypes.func.isRequired, - onTlsCertificateDownloadClick: PropTypes.func.isRequired, -}; - export default TLSCertificatesTab; diff --git a/src/web/pages/reports/details/TlsCertificatesTable.jsx b/src/web/pages/reports/details/TlsCertificatesTable.tsx similarity index 74% rename from src/web/pages/reports/details/TlsCertificatesTable.jsx rename to src/web/pages/reports/details/TlsCertificatesTable.tsx index cc03a379ae..6a8122de6e 100644 --- a/src/web/pages/reports/details/TlsCertificatesTable.jsx +++ b/src/web/pages/reports/details/TlsCertificatesTable.tsx @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; import styled from 'styled-components'; import {_, _l} from 'gmp/locale/lang'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; import DateTime from 'web/components/date/DateTime'; import {DownloadIcon} from 'web/components/icon'; import Link from 'web/components/link/Link'; @@ -17,8 +17,30 @@ import createEntitiesTable from 'web/entities/createEntitiesTable'; import RowDetailsToggle from 'web/entities/RowDetailsToggle'; import withRowDetails from 'web/entities/withRowDetails'; import TlsCertificateDetails from 'web/pages/tlscertificates/Details'; -import PropTypes from 'web/utils/PropTypes'; -import {formattedUserSettingShortDate} from 'web/utils/user-setting-time-date-formatters'; +import {type SortDirectionType} from 'web/utils/sort-direction'; + +interface HeaderProps { + actions?: boolean; + currentSortBy?: string; + currentSortDir?: SortDirectionType; + sort?: boolean; + onSortChange?: (sortBy: string) => void; +} + +interface RowProps { + actions?: boolean; + entity: any; + links?: boolean; + onTlsCertificateDownloadClick?: (entity: ReportTLSCertificate) => void; + onToggleDetailsClick?: (entity: any, id?: string) => void; +} + +interface ColumnsProps { + actions?: boolean; + links?: boolean; + onTlsCertificateDownloadClick?: (entity: ReportTLSCertificate) => void; + onToggleDetailsClick?: (entity: any, id?: string) => void; +} const Div = styled.div` word-break: break-all; @@ -33,14 +55,14 @@ const getColumns = ({ links = true, onTlsCertificateDownloadClick, onToggleDetailsClick, -}) => +}: ColumnsProps) => [ { key: 'dn', title: _('Subject DN'), width: actions ? '35%' : '40%', sortBy: 'dn', - render: entity => ( + render: (entity: ReportTLSCertificate) => (
{entity.subjectDn}
@@ -53,15 +75,15 @@ const getColumns = ({ title: _('Serial'), width: '10%', sortBy: 'serial', - render: entity => entity.serial, + render: (entity: ReportTLSCertificate) => entity.serial, }, { key: 'notvalidbefore', title: _('Activates'), width: '10%', sortBy: 'notvalidbefore', - render: entity => ( - + render: (entity: ReportTLSCertificate) => ( + ), }, { @@ -69,8 +91,8 @@ const getColumns = ({ title: _('Expires'), width: '10%', sortBy: 'notvalidafter', - render: entity => ( - + render: (entity: ReportTLSCertificate) => ( + ), }, { @@ -78,11 +100,11 @@ const getColumns = ({ title: _('IP'), width: '10%', sortBy: 'ip', - render: entity => ( + render: (entity: ReportTLSCertificate) => ( {entity.ip} @@ -94,21 +116,21 @@ const getColumns = ({ title: _('Hostname'), width: '15%', sortBy: 'hostname', - render: entity => entity.hostname, + render: (entity: ReportTLSCertificate) => entity.hostname, }, { key: 'port', title: _('Port'), width: '5%', sortBy: 'port', - render: entity => entity.port, + render: (entity: ReportTLSCertificate) => entity.port, }, { key: 'actions', title: _('Actions'), width: '5%', align: 'center', - render: entity => ( + render: (entity: ReportTLSCertificate) => ( { +}: HeaderProps) => { const columns = getColumns({actions}); return ( @@ -148,21 +170,13 @@ const Header = ({ ); }; -Header.propTypes = { - actions: PropTypes.bool, - currentSortBy: PropTypes.string, - currentSortDir: PropTypes.string, - sort: PropTypes.bool, - onSortChange: PropTypes.func, -}; - const Row = ({ actions = true, entity, links = true, onTlsCertificateDownloadClick, onToggleDetailsClick, -}) => { +}: RowProps) => { const columns = getColumns({ actions, links, @@ -184,14 +198,6 @@ const Row = ({ ); }; -Row.propTypes = { - actions: PropTypes.bool, - entity: PropTypes.object.isRequired, - links: PropTypes.bool, - onTlsCertificateDownloadClick: PropTypes.func, - onToggleDetailsClick: PropTypes.func.isRequired, -}; - export default createEntitiesTable({ header: Header, emptyTitle: _l('No TLS Certificates available'), diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx similarity index 92% rename from src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx rename to src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx index c934d849e4..04b339e9e7 100644 --- a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.jsx +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx @@ -21,6 +21,7 @@ const createGmp = () => ({ describe('Report TLS Certificates Tab tests', () => { test('should render Report TLS Certificates Tab', () => { const {tlsCertificates} = getMockReport(); + const reportTlsCertificates = tlsCertificates!; const onSortChange = testing.fn(); @@ -33,12 +34,12 @@ describe('Report TLS Certificates Tab tests', () => { render( , @@ -117,6 +118,7 @@ describe('Report TLS Certificates Tab tests', () => { test('should call click handler', () => { const {tlsCertificates} = getMockReport(); + const reportTlsCertificates = tlsCertificates!; const onSortChange = testing.fn(); @@ -129,12 +131,12 @@ describe('Report TLS Certificates Tab tests', () => { render( , @@ -144,12 +146,12 @@ describe('Report TLS Certificates Tab tests', () => { fireEvent.click(downloadIcons[0]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( - tlsCertificates.entities[0], + reportTlsCertificates.entities[0], ); fireEvent.click(downloadIcons[1]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( - tlsCertificates.entities[1], + reportTlsCertificates.entities[1], ); }); }); diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx similarity index 83% rename from src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx rename to src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx index 5979b8c460..36768b3970 100644 --- a/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.jsx +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx @@ -19,6 +19,7 @@ const createGmp = () => ({ describe('TLSCertificatesTable', () => { test('should render table with expected columns and row values', () => { const {tlsCertificates} = getMockReport(); + const reportTlsCertificates = tlsCertificates!; const {render} = rendererWith({ router: true, @@ -27,8 +28,9 @@ describe('TLSCertificatesTable', () => { render( { test('should call download click handler', () => { const {tlsCertificates} = getMockReport(); + const reportTlsCertificates = tlsCertificates!; const onTlsCertificateDownloadClick = testing.fn(); const {render} = rendererWith({ @@ -82,8 +85,9 @@ describe('TLSCertificatesTable', () => { render( { fireEvent.click(downloadIcons[0]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( - tlsCertificates.entities[0], + reportTlsCertificates.entities[0], ); }); test('should render table without actions column when disabled', () => { const {tlsCertificates} = getMockReport(); + const reportTlsCertificates = tlsCertificates!; const {render} = rendererWith({ router: true, @@ -110,8 +115,9 @@ describe('TLSCertificatesTable', () => { render( Date: Wed, 13 May 2026 09:04:21 +0200 Subject: [PATCH 4/8] refactor: use new cmd get_report_tls_certificates for report/details tls certificates - use new command - use tanstack query hooks for fetching Create report-tls-certificates.test.ts --- .../__tests__/report-tls-certificates.test.ts | 380 ++++++++++++++++++ src/gmp/commands/report-tls-certificates.ts | 95 +++++ src/gmp/gmp.ts | 3 + .../use-query/report-tls-certificates.ts | 35 ++ src/web/pages/reports/DetailsContent.tsx | 13 +- src/web/pages/reports/DetailsPage.tsx | 4 +- .../reports/details/TlsCertificatesTab.tsx | 166 ++++---- .../reports/details/TlsCertificatesTable.tsx | 12 +- .../__tests__/TlsCertificatesTab.test.tsx | 84 ++-- .../__tests__/TlsCertificatesTable.test.tsx | 63 ++- 10 files changed, 728 insertions(+), 127 deletions(-) create mode 100644 src/gmp/commands/__tests__/report-tls-certificates.test.ts create mode 100644 src/gmp/commands/report-tls-certificates.ts create mode 100644 src/web/hooks/use-query/report-tls-certificates.ts diff --git a/src/gmp/commands/__tests__/report-tls-certificates.test.ts b/src/gmp/commands/__tests__/report-tls-certificates.test.ts new file mode 100644 index 0000000000..26b33a31a4 --- /dev/null +++ b/src/gmp/commands/__tests__/report-tls-certificates.test.ts @@ -0,0 +1,380 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; +import ReportTlsCertificatesCommand from 'gmp/commands/report-tls-certificates'; +import {createResponse, createHttp} from 'gmp/commands/testing'; + +describe('ReportTlsCertificatesCommand tests', () => { + test('should return TLS certificates', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: { + tls_certificate: [ + { + name: 'fingerprint-1', + serial: '123456', + subject_dn: 'CN=example.com', + issuer_dn: 'CN=Issuer', + activation_time: '2024-01-01T00:00:00Z', + expiration_time: '2025-12-31T23:59:59Z', + md5_fingerprint: 'md5-1', + sha256_fingerprint: 'sha256-1', + valid: 1, + certificate: { + __text: 'cert-data-1', + _format: 'PEM', + }, + host: { + ip: '192.168.1.1', + hostname: 'host1.example.com', + }, + ports: { + port: [443, 8443], + }, + }, + { + name: 'fingerprint-2', + serial: '654321', + subject_dn: 'CN=other.example.com', + issuer_dn: 'CN=Other Issuer', + activation_time: '2024-06-01T00:00:00Z', + expiration_time: '2026-06-01T00:00:00Z', + md5_fingerprint: 'md5-2', + sha256_fingerprint: 'sha256-2', + valid: 0, + host: { + ip: '10.0.0.1', + hostname: 'host2.example.com', + }, + ports: { + port: 80, + }, + }, + ], + }, + ssl_certs: { + count: 5, + }, + filters: { + term: 'first=1 rows=100 sort=dn', + filter: { + _id: '', + }, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + {column: 'sort', relation: '=', value: 'dn'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r1'}); + + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_report_tls_certificates', + details: 1, + report_id: 'r1', + }, + }); + + const {data} = resp; + + // First cert has 2 ports → 2 entries, second cert has 1 port → 1 entry + expect(data).toHaveLength(3); + + // First entry from cert 1, port 443 + expect(data[0].fingerprint).toEqual('fingerprint-1'); + expect(data[0].serial).toEqual('123456'); + expect(data[0].subjectDn).toEqual('CN=example.com'); + expect(data[0].issuerDn).toEqual('CN=Issuer'); + expect(data[0].md5Fingerprint).toEqual('md5-1'); + expect(data[0].sha256Fingerprint).toEqual('sha256-1'); + expect(data[0].valid).toEqual(true); + expect(data[0].data).toEqual('cert-data-1'); + expect(data[0].ip).toEqual('192.168.1.1'); + expect(data[0].hostname).toEqual('host1.example.com'); + expect(data[0].port).toEqual('443'); + expect(data[0].ports).toEqual(['443']); + + // Second entry from cert 1, port 8443 + expect(data[1].fingerprint).toEqual('fingerprint-1'); + expect(data[1].port).toEqual('8443'); + expect(data[1].ports).toEqual(['8443']); + expect(data[1].ip).toEqual('192.168.1.1'); + + // Third entry from cert 2, port 80 + expect(data[2].fingerprint).toEqual('fingerprint-2'); + expect(data[2].serial).toEqual('654321'); + expect(data[2].subjectDn).toEqual('CN=other.example.com'); + expect(data[2].valid).toEqual(false); + expect(data[2].ip).toEqual('10.0.0.1'); + expect(data[2].hostname).toEqual('host2.example.com'); + expect(data[2].port).toEqual('80'); + expect(data[2].ports).toEqual(['80']); + + // Check counts + const {counts} = resp.meta; + expect(counts.all).toEqual(5); + expect(counts.filtered).toEqual(3); + expect(counts.first).toEqual(1); + expect(counts.length).toEqual(3); + expect(counts.rows).toEqual(3); + }); + + test('should handle single TLS certificate element', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: { + tls_certificate: { + name: 'single-fingerprint', + serial: '111', + subject_dn: 'CN=single.example.com', + host: { + ip: '10.0.0.2', + hostname: 'single.example.com', + }, + ports: { + port: 443, + }, + }, + }, + ssl_certs: { + count: 1, + }, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r2'}); + + const {data} = resp; + expect(data).toHaveLength(1); + expect(data[0].fingerprint).toEqual('single-fingerprint'); + expect(data[0].serial).toEqual('111'); + expect(data[0].ip).toEqual('10.0.0.2'); + expect(data[0].port).toEqual('443'); + }); + + test('should handle empty TLS certificates', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: {}, + ssl_certs: { + count: 0, + }, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r3'}); + + const {data} = resp; + expect(data).toHaveLength(0); + + const {counts} = resp.meta; + expect(counts.all).toEqual(0); + expect(counts.filtered).toEqual(0); + }); + + test('should throw error for invalid response', async () => { + const response = createResponse({}); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + + await expect(cmd.get({report_id: 'r4'})).rejects.toThrow( + 'Invalid response: get_report_tls_certificates not found in response', + ); + }); + + test('should use ssl_certs count as all count', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: { + tls_certificate: [ + { + name: 'fp-1', + host: {ip: '1.1.1.1'}, + ports: {port: 443}, + }, + ], + }, + ssl_certs: { + count: 10, + }, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r5'}); + + const {counts} = resp.meta; + expect(counts.all).toEqual(10); + expect(counts.filtered).toEqual(1); + }); + + test('should fallback to filtered count when ssl_certs is missing', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: { + tls_certificate: [ + { + name: 'fp-1', + host: {ip: '1.1.1.1'}, + ports: {port: 443}, + }, + { + name: 'fp-2', + host: {ip: '2.2.2.2'}, + ports: {port: 8443}, + }, + ], + }, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r6'}); + + const {counts} = resp.meta; + // Without ssl_certs, all should equal filtered count + expect(counts.all).toEqual(2); + expect(counts.filtered).toEqual(2); + }); + + test('should pass filter parameter', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: {}, + ssl_certs: {count: 0}, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + await cmd.get({report_id: 'r7', filter: 'rows=50 first=1'}); + + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_report_tls_certificates', + details: 1, + report_id: 'r7', + filter: 'rows=50 first=1', + }, + }); + }); + + test('should handle activation_time "undefined" and "unlimited"', async () => { + const response = createResponse({ + get_report_tls_certificates: { + get_report_tls_certificates_response: { + tls_certificates: { + tls_certificate: [ + { + name: 'fp-undef', + activation_time: 'undefined', + expiration_time: 'unlimited', + host: {ip: '1.1.1.1'}, + ports: {port: 443}, + }, + ], + }, + ssl_certs: {count: 1}, + filters: { + term: 'first=1 rows=100', + filter: {_id: ''}, + keywords: { + keyword: [ + {column: 'first', relation: '=', value: '1'}, + {column: 'rows', relation: '=', value: '100'}, + ], + }, + }, + }, + }, + }); + + const fakeHttp = createHttp(response); + const cmd = new ReportTlsCertificatesCommand(fakeHttp); + const resp = await cmd.get({report_id: 'r8'}); + + const {data} = resp; + expect(data[0].activationTime).toBeUndefined(); + expect(data[0].expirationTime).toBeUndefined(); + }); +}); diff --git a/src/gmp/commands/report-tls-certificates.ts b/src/gmp/commands/report-tls-certificates.ts new file mode 100644 index 0000000000..b544b070cf --- /dev/null +++ b/src/gmp/commands/report-tls-certificates.ts @@ -0,0 +1,95 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import CollectionCounts from 'gmp/collection/collection-counts'; +import {parseFilter} from 'gmp/collection/parser'; +import type {EntitiesMeta} from 'gmp/commands/entities'; +import HttpCommand, { + type HttpCommandInputParams, + type HttpCommandOptions, +} from 'gmp/commands/http'; +import type Http from 'gmp/http/http'; +import type Response from 'gmp/http/response'; +import type {XmlResponseData} from 'gmp/http/transform/fast-xml'; +import type {FilterModelElement} from 'gmp/models/filter'; +import ReportTLSCertificate, { + type ReportTLSCertificateElement, +} from 'gmp/models/report/tls-certificate'; +import {map} from 'gmp/utils/array'; + +interface TlsCertificatesElement { + tls_certificate?: ReportTLSCertificateElement | ReportTLSCertificateElement[]; +} + +interface SslCertsElement { + count?: number; +} + +interface ReportTlsCertificatesResponseData extends XmlResponseData { + get_report_tls_certificates?: { + get_report_tls_certificates_response: { + ssl_certs?: SslCertsElement; + tls_certificates?: TlsCertificatesElement; + filters?: FilterModelElement; + [key: string]: unknown; + }; + }; +} + +class ReportTlsCertificatesCommand extends HttpCommand { + constructor(http: Http) { + super(http, {cmd: 'get_report_tls_certificates'}); + } + + async get( + params: HttpCommandInputParams = {}, + options?: HttpCommandOptions, + ): Promise> { + const response = await this.httpGetWithTransform( + {details: 1, ...params}, + options, + ); + + const root = response.data as ReportTlsCertificatesResponseData; + + if (!root.get_report_tls_certificates) { + throw new Error( + 'Invalid response: get_report_tls_certificates not found in response', + ); + } + + const data = + root.get_report_tls_certificates.get_report_tls_certificates_response; + const certs = map(data.tls_certificates?.tls_certificate, tlsCert => + ReportTLSCertificate.fromElement(tlsCert), + ); + + // Keep payload-compatible behavior: split one certificate into one entry per port. + const certsPerPort: ReportTLSCertificate[] = []; + certs.forEach(cert => { + cert.ports.forEach(port => { + certsPerPort.push(cert.copy({port, ports: [port]})); + }); + }); + + const filteredCount = certsPerPort.length; + const counts = new CollectionCounts({ + all: data.ssl_certs?.count ?? filteredCount, + filtered: filteredCount, + first: 1, + length: filteredCount, + rows: filteredCount, + }); + + const filter = parseFilter(data); + + return response.set(certsPerPort, { + filter, + counts, + }); + } +} + +export default ReportTlsCertificatesCommand; diff --git a/src/gmp/gmp.ts b/src/gmp/gmp.ts index 140639ac8f..84b0afd1d3 100644 --- a/src/gmp/gmp.ts +++ b/src/gmp/gmp.ts @@ -62,6 +62,7 @@ import ReportConfigsCommand from 'gmp/commands/report-configs'; import ReportFormatCommand from 'gmp/commands/report-format'; import ReportFormatsCommand from 'gmp/commands/report-formats'; import ReportPortsCommand from 'gmp/commands/report-ports'; +import ReportTlsCertificatesCommand from 'gmp/commands/report-tls-certificates'; import ReportsCommand from 'gmp/commands/reports'; import ResourceNamesCommand from 'gmp/commands/resource-names'; import {ResultCommand} from 'gmp/commands/result'; @@ -148,6 +149,7 @@ class Gmp { public readonly reportformats: ReportFormatsCommand; public readonly reports: ReportsCommand; public readonly reportports: ReportPortsCommand; + public readonly reporttlscertificates: ReportTlsCertificatesCommand; public readonly result: ResultCommand; public readonly results: ResultsCommand; public readonly resourcenames: ResourceNamesCommand; @@ -237,6 +239,7 @@ class Gmp { this.reportformats = new ReportFormatsCommand(this.http); this.reports = new ReportsCommand(this.http); this.reportports = new ReportPortsCommand(this.http); + this.reporttlscertificates = new ReportTlsCertificatesCommand(this.http); this.result = new ResultCommand(this.http); this.results = new ResultsCommand(this.http); this.resourcenames = new ResourceNamesCommand(this.http); diff --git a/src/web/hooks/use-query/report-tls-certificates.ts b/src/web/hooks/use-query/report-tls-certificates.ts new file mode 100644 index 0000000000..e1a3d0c0a1 --- /dev/null +++ b/src/web/hooks/use-query/report-tls-certificates.ts @@ -0,0 +1,35 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type Filter from 'gmp/models/filter'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetReportTlsCertificatesParams { + reportId: string; + filter?: Filter; +} + +export const useGetReportTlsCertificates = ({ + reportId, + filter = undefined, +}: UseGetReportTlsCertificatesParams) => { + const gmp = useGmp(); + + return useGetEntities({ + gmpMethod: ({filter: reportFilter}) => + gmp.reporttlscertificates.get({ + report_id: reportId, + filter: reportFilter, + }), + queryId: `get_report_tls_certificates_${reportId}`, + filter, + enabled: Boolean(reportId), + keepPreviousData: true, + }); +}; + +export default useGetReportTlsCertificates; diff --git a/src/web/pages/reports/DetailsContent.tsx b/src/web/pages/reports/DetailsContent.tsx index 838cbf15f8..52c7a23b01 100644 --- a/src/web/pages/reports/DetailsContent.tsx +++ b/src/web/pages/reports/DetailsContent.tsx @@ -10,6 +10,7 @@ import type Filter from 'gmp/models/filter'; import type Report from 'gmp/models/report'; import type ReportReport from 'gmp/models/report/report'; import type ReportTask from 'gmp/models/report/task'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; import {TASK_STATUS} from 'gmp/models/task'; import {isDefined} from 'gmp/utils/identity'; import StatusBar from 'web/components/bar/StatusBar'; @@ -115,7 +116,7 @@ interface PageContentProps { onSortChange: (type: string, sortField: string) => void; onTagSuccess: () => void; onTargetEditClick: () => void; - onTlsCertificateDownloadClick: () => void; + onTlsCertificateDownloadClick: (entity: ReportTLSCertificate) => void; } const renderWithThreshold = ( @@ -218,7 +219,6 @@ const PageContent = ({ hosts, operatingsystems, results, - tlsCertificates, timestamp, scan_run_status, } = report ?? {}; @@ -452,13 +452,8 @@ const PageContent = ({ activeFilter, thresholdConfig, onSortChange('tlscerts', sortField)} + reportFilter={activeFilter} + reportId={reportId} onTlsCertificateDownloadClick={onTlsCertificateDownloadClick} />, ), diff --git a/src/web/pages/reports/DetailsPage.tsx b/src/web/pages/reports/DetailsPage.tsx index c41107bd12..fa1872b93e 100644 --- a/src/web/pages/reports/DetailsPage.tsx +++ b/src/web/pages/reports/DetailsPage.tsx @@ -534,9 +534,7 @@ const ReportDetailsPage = () => { const response = await loadTarget(); if (response) void edit(response.data); }} - onTlsCertificateDownloadClick={ - handleTlsCertificateDownload as unknown as () => void - } + onTlsCertificateDownloadClick={handleTlsCertificateDownload} /> )} diff --git a/src/web/pages/reports/details/TlsCertificatesTab.tsx b/src/web/pages/reports/details/TlsCertificatesTab.tsx index 71a291a88c..516366e400 100644 --- a/src/web/pages/reports/details/TlsCertificatesTab.tsx +++ b/src/web/pages/reports/details/TlsCertificatesTab.tsx @@ -3,86 +3,110 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type CollectionCounts from 'gmp/collection/collection-counts'; -import type Filter from 'gmp/models/filter'; +import {useEffect, useMemo, useState} from 'react'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import Filter from 'gmp/models/filter'; import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; -import ReportEntitiesContainer from 'web/pages/reports/details/ReportEntitiesContainer'; +import ErrorPanel from 'web/components/error/ErrorPanel'; +import Loading from 'web/components/loading/Loading'; +import useGetReportTlsCertificates from 'web/hooks/use-query/report-tls-certificates'; +import useFilterSortBy from 'web/hooks/useFilterSortBy'; +import usePagination from 'web/hooks/usePagination'; +import useTranslation from 'web/hooks/useTranslation'; import TLSCertificatesTable from 'web/pages/reports/details/TlsCertificatesTable'; -import { - makeCompareDate, - makeCompareIp, - makeComparePort, - makeCompareString, -} from 'web/utils/Sort'; interface TLSCertificatesTabProps { - counts?: CollectionCounts; - filter: Filter; - isUpdating?: boolean; - sortField: string; - sortReverse: boolean; - tlsCertificates?: ReportTLSCertificate[]; - onSortChange: (sortBy: string) => void; + reportId: string; + reportFilter: Filter; onTlsCertificateDownloadClick: (entity: ReportTLSCertificate) => void; } -const tlsCertificatesSortFunctions = { - dn: makeCompareString('subjectDn'), - serial: makeCompareString('serial'), - notvalidbefore: makeCompareDate('activationTime'), - notvalidafter: makeCompareDate('expirationTime'), - ip: makeCompareIp('ip'), - hostname: makeCompareString('hostname'), - port: makeComparePort('port'), -}; - const TLSCertificatesTab = ({ - counts, - filter, - isUpdating, - sortField, - sortReverse, - tlsCertificates, - - onSortChange, + reportId, + reportFilter, onTlsCertificateDownloadClick, -}: TLSCertificatesTabProps) => ( - - counts={counts} - entities={tlsCertificates} - filter={filter} - sortField={sortField} - sortFunctions={tlsCertificatesSortFunctions} - sortReverse={sortReverse} - > - {({ - entities, - entitiesCounts, - sortBy, - sortDir, - onFirstClick, - onLastClick, - onNextClick, - onPreviousClick, - }) => ( - { + const [_] = useTranslation(); + const reportFilterString = reportFilter.toFilterString(); + + const baseFilter = useMemo(() => { + return Filter.fromString(reportFilterString); + }, [reportFilterString]); + + const [tlsCertificatesFilter, setTlsCertificatesFilter] = + useState(baseFilter); + + useEffect(() => { + setTlsCertificatesFilter(baseFilter); + }, [baseFilter]); + + const {data, isLoading, isFetching, isError, error} = + useGetReportTlsCertificates({ + reportId, + filter: tlsCertificatesFilter, + }); + + const updateFilter = (newFilter: Filter) => { + setTlsCertificatesFilter(newFilter); + }; + + const [sortBy, sortDir, handleSortChange] = useFilterSortBy( + tlsCertificatesFilter, + updateFilter, + ); + + const [ + handleFirstClick, + handleLastClick, + handleNextClick, + handlePreviousClick, + ] = usePagination( + tlsCertificatesFilter, + data?.entitiesCounts ?? new CollectionCounts(), + updateFilter, + ); + + if (isError) { + return ( + - )} -
-); + ); + } + + if (isLoading && !data) { + return ; + } + + const { + entities: tlsCertificates = [], + entitiesCounts: tlsCertificatesCounts, + } = data || {}; + + return ( + + ); +}; export default TLSCertificatesTab; diff --git a/src/web/pages/reports/details/TlsCertificatesTable.tsx b/src/web/pages/reports/details/TlsCertificatesTable.tsx index 6a8122de6e..948377602b 100644 --- a/src/web/pages/reports/details/TlsCertificatesTable.tsx +++ b/src/web/pages/reports/details/TlsCertificatesTable.tsx @@ -29,17 +29,17 @@ interface HeaderProps { interface RowProps { actions?: boolean; - entity: any; + entity: ReportTLSCertificate; links?: boolean; onTlsCertificateDownloadClick?: (entity: ReportTLSCertificate) => void; - onToggleDetailsClick?: (entity: any, id?: string) => void; + onToggleDetailsClick?: (entity: ReportTLSCertificate, id?: string) => void; } interface ColumnsProps { actions?: boolean; links?: boolean; onTlsCertificateDownloadClick?: (entity: ReportTLSCertificate) => void; - onToggleDetailsClick?: (entity: any, id?: string) => void; + onToggleDetailsClick?: (entity: ReportTLSCertificate, id?: string) => void; } const Div = styled.div` @@ -64,7 +64,11 @@ const getColumns = ({ sortBy: 'dn', render: (entity: ReportTLSCertificate) => ( - +
{entity.subjectDn}
diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx index 04b339e9e7..694726b0e6 100644 --- a/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTab.test.tsx @@ -5,6 +5,7 @@ import {describe, test, expect, testing} from '@gsa/testing'; import {rendererWith, fireEvent, screen, within} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; import Filter from 'gmp/models/filter'; import {createSession} from 'gmp/testing'; import {getMockReport} from 'web/pages/reports/__fixtures__/MockReport'; @@ -13,39 +14,59 @@ import TLSCertificatesTab from 'web/pages/reports/details/TlsCertificatesTab'; const filter = Filter.fromString( 'apply_overrides=0 levels=hml rows=3 min_qod=70 first=1 sort-reverse=severity', ); - -const createGmp = () => ({ - session: createSession({timezone: 'CET'}), +const tlsCertificates = getMockReport().tlsCertificates?.entities ?? []; + +const createGmp = ({ + getReportTlsCertificates = testing.fn().mockResolvedValue({ + data: tlsCertificates, + meta: { + filter, + counts: new CollectionCounts({ + first: 1, + all: tlsCertificates.length, + filtered: tlsCertificates.length, + length: tlsCertificates.length, + rows: tlsCertificates.length, + }), + }, + }), +} = {}) => ({ + reporttlscertificates: { + get: getReportTlsCertificates, + }, + settings: { + reloadInterval: 5000, + reloadIntervalActive: 2000, + reloadIntervalInactive: 10000, + }, + session: createSession({ + timezone: 'CET', + token: 'test-token', + username: 'admin', + }), }); describe('Report TLS Certificates Tab tests', () => { - test('should render Report TLS Certificates Tab', () => { - const {tlsCertificates} = getMockReport(); - const reportTlsCertificates = tlsCertificates!; - - const onSortChange = testing.fn(); + test('should render Report TLS Certificates Tab', async () => { + const reportId = 'report-id-1234'; const onTlsCertificateDownloadClick = testing.fn(); + const gmp = createGmp(); const {render} = rendererWith({ router: true, - gmp: createGmp(), + gmp, }); render( , ); - const table = screen.getByRole('table'); + const table = await screen.findByRole('table'); const header = within(table).getAllByRole('columnheader'); const rows = within(table).getAllByRole('row'); const links = within(table).getAllByRole('link'); @@ -114,44 +135,39 @@ describe('Report TLS Certificates Tab tests', () => { expect(screen.getByText(/Applied filter:/)).toHaveTextContent( '(Applied filter: apply_overrides=0 levels=hml rows=3 min_qod=70 first=1 sort-reverse=severity)', ); - }); - test('should call click handler', () => { - const {tlsCertificates} = getMockReport(); - const reportTlsCertificates = tlsCertificates!; - - const onSortChange = testing.fn(); + expect(gmp.reporttlscertificates.get).toHaveBeenCalledWith( + expect.objectContaining({report_id: reportId}), + ); + }); + test('should call click handler', async () => { const onTlsCertificateDownloadClick = testing.fn(); + const gmp = createGmp(); const {render} = rendererWith({ router: true, - gmp: createGmp(), + gmp, }); render( , ); - const downloadIcons = screen.getAllByTestId('download-icon'); + const downloadIcons = await screen.findAllByTestId('download-icon'); fireEvent.click(downloadIcons[0]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( - reportTlsCertificates.entities[0], + tlsCertificates[0], ); fireEvent.click(downloadIcons[1]); expect(onTlsCertificateDownloadClick).toHaveBeenCalledWith( - reportTlsCertificates.entities[1], + tlsCertificates[1], ); }); }); diff --git a/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx index 36768b3970..9bd858ff71 100644 --- a/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx +++ b/src/web/pages/reports/details/__tests__/TlsCertificatesTable.test.tsx @@ -17,9 +17,49 @@ const createGmp = () => ({ }); describe('TLSCertificatesTable', () => { + test('should show row details when Subject DN is clicked', () => { + const reportTlsCertificates = getMockReport().tlsCertificates; + + if (!reportTlsCertificates) { + throw new Error( + 'Mock report TLS certificates are required for this test', + ); + } + + const {render} = rendererWith({ + router: true, + gmp: createGmp(), + }); + + render( + , + ); + + const toggles = screen.getAllByTestId('row-details-toggle'); + fireEvent.click(toggles[0]); + + expect( + screen.getByTestId( + `tls-certificate-details-${reportTlsCertificates.entities[0].id}`, + ), + ).toBeInTheDocument(); + }); + test('should render table with expected columns and row values', () => { - const {tlsCertificates} = getMockReport(); - const reportTlsCertificates = tlsCertificates!; + const reportTlsCertificates = getMockReport().tlsCertificates; + + if (!reportTlsCertificates) { + throw new Error( + 'Mock report TLS certificates are required for this test', + ); + } const {render} = rendererWith({ router: true, @@ -74,8 +114,14 @@ describe('TLSCertificatesTable', () => { }); test('should call download click handler', () => { - const {tlsCertificates} = getMockReport(); - const reportTlsCertificates = tlsCertificates!; + const reportTlsCertificates = getMockReport().tlsCertificates; + + if (!reportTlsCertificates) { + throw new Error( + 'Mock report TLS certificates are required for this test', + ); + } + const onTlsCertificateDownloadClick = testing.fn(); const {render} = rendererWith({ @@ -104,8 +150,13 @@ describe('TLSCertificatesTable', () => { }); test('should render table without actions column when disabled', () => { - const {tlsCertificates} = getMockReport(); - const reportTlsCertificates = tlsCertificates!; + const reportTlsCertificates = getMockReport().tlsCertificates; + + if (!reportTlsCertificates) { + throw new Error( + 'Mock report TLS certificates are required for this test', + ); + } const {render} = rendererWith({ router: true, From 0be31e766929e2cdd97d289b8dfb83054804adf9 Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Wed, 13 May 2026 12:33:54 +0200 Subject: [PATCH 5/8] refactor: reports audit with get tls query and typescript --- .../reports/AuditReportDetailsContent.jsx | 12 +- .../pages/reports/AuditReportDetailsPage.jsx | 653 ------------------ .../pages/reports/AuditReportDetailsPage.tsx | 571 +++++++++++++++ src/web/pages/reports/DetailsPage.tsx | 8 +- .../AuditReportDetailsContent.test.jsx | 494 +++++++++++++ .../__tests__/AuditReportDetailsPage.test.tsx | 486 +++++++++++++ .../reports/__tests__/DetailsPage.test.tsx | 197 +++++- 7 files changed, 1756 insertions(+), 665 deletions(-) delete mode 100644 src/web/pages/reports/AuditReportDetailsPage.jsx create mode 100644 src/web/pages/reports/AuditReportDetailsPage.tsx create mode 100644 src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx create mode 100644 src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx diff --git a/src/web/pages/reports/AuditReportDetailsContent.jsx b/src/web/pages/reports/AuditReportDetailsContent.jsx index dbeb91651d..344481228c 100644 --- a/src/web/pages/reports/AuditReportDetailsContent.jsx +++ b/src/web/pages/reports/AuditReportDetailsContent.jsx @@ -94,7 +94,6 @@ const AuditReportDetailsContent = ({ hosts = {}, operatingSystems = {}, results = {}, - tlsCertificates = {}, timestamp, scan_run_status, } = report || {}; @@ -325,15 +324,8 @@ const AuditReportDetailsContent = ({ /> ) : ( - onSortChange('tlscerts', sortField) - } + reportId={reportId} + reportFilter={reportFilter} onTlsCertificateDownloadClick={ onTlsCertificateDownloadClick } diff --git a/src/web/pages/reports/AuditReportDetailsPage.jsx b/src/web/pages/reports/AuditReportDetailsPage.jsx deleted file mode 100644 index f2717d70f5..0000000000 --- a/src/web/pages/reports/AuditReportDetailsPage.jsx +++ /dev/null @@ -1,653 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React, {useEffect, useState} from 'react'; -import {useDispatch, useSelector, shallowEqual} from 'react-redux'; -import {useParams} from 'react-router'; -import logger from 'gmp/log'; -import Filter, { - ALL_FILTER, - RESET_FILTER, - RESULTS_FILTER_FILTER, -} from 'gmp/models/filter'; -import {isActive} from 'gmp/models/task'; -import {first} from 'gmp/utils/array'; -import {isDefined, hasValue} from 'gmp/utils/identity'; -import withDownload from 'web/components/form/withDownload'; -import PageTitle from 'web/components/layout/PageTitle'; -import Reload, { - NO_RELOAD, - USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, -} from 'web/components/loading/Reload'; -import withDialogNotification from 'web/components/notification/withDialogNotification'; -import FilterProvider from 'web/entities/FilterProvider'; -import useGmp from 'web/hooks/useGmp'; -import useTranslation from 'web/hooks/useTranslation'; -import useUserName from 'web/hooks/useUserName'; -import Page from 'web/pages/reports/AuditReportDetailsContent'; -import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; -import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; -import TargetComponent from 'web/pages/targets/TargetComponent'; -import { - loadAllEntities as loadFilters, - selector as filterSelector, -} from 'web/store/entities/filters'; -import {loadAuditReportWithThreshold} from 'web/store/entities/report/actions'; -import {auditReportSelector} from 'web/store/entities/report/selectors'; -import { - loadAllEntities as loadReportConfigs, - selector as reportConfigsSelector, -} from 'web/store/entities/reportconfigs'; -import { - loadAllEntities as loadReportFormats, - selector as reportFormatsSelector, -} from 'web/store/entities/reportformats'; -import {pageFilter as setPageFilter} from 'web/store/pages/actions'; -import getPage from 'web/store/pages/selectors'; -import { - loadReportComposerDefaults, - saveReportComposerDefaults, -} from 'web/store/usersettings/actions'; -import {getUserSettingsDefaultFilter} from 'web/store/usersettings/defaultfilters/selectors'; -import {loadUserSettingDefaults} from 'web/store/usersettings/defaults/actions'; -import {getUserSettingsDefaults} from 'web/store/usersettings/defaults/selectors'; -import {getReportComposerDefaults} from 'web/store/usersettings/selectors'; -import {create_pem_certificate} from 'web/utils/Cert'; -import compose from 'web/utils/Compose'; -import PropTypes from 'web/utils/PropTypes'; -import {generateFilename} from 'web/utils/Render'; - -const log = logger.getLogger('web.pages.auditreport./DetailsPage'); - -const DEFAULT_FILTER = Filter.fromString( - 'levels=hmlg rows=100 min_qod=70 first=1 compliance_levels=yniu sort=compliant', -); - -export const AUDIT_REPORT_RESET_FILTER = RESET_FILTER.copy() - .setSortOrder('sort') - .setSortBy('compliant'); - -const REPORT_FORMATS_FILTER = Filter.fromString('active=1 and trust=1 rows=-1'); - -const getReportPageName = id => `report-${id}`; - -const getTarget = (entity = {}) => { - const {report = {}} = entity; - const {task = {}} = report; - return task.target; -}; - -const AuditReportDetailsPage = props => { - const [showFilterDialog, setShowFilterDialog] = useState(false); - const [showDownloadReportDialog, setShowDownloadReportDialog] = - useState(false); - const [sorting, setSorting] = useState({ - results: { - sortField: 'compliant', - sortReverse: true, - }, - hosts: { - sortField: 'compliant', - sortReverse: true, - }, - os: { - sortField: 'compliant', - sortReverse: true, - }, - tlscerts: { - sortField: 'dn', - sortReverse: false, - }, - errors: { - sortField: 'error', - sortReverse: false, - }, - }); - - const [entity, setEntity] = useState(); - const [resultsCounts, setResultsCounts] = useState(); - const [hostsCounts, setHostsCounts] = useState(); - const [operatingSystemsCounts, setOperatingSystemsCounts] = useState(); - const [tlsCertificatesCounts, setTlsCertificatesCounts] = useState(); - const [reportFormatId, setReportFormatId] = useState(); - const [errorsCounts, setErrorsCounts] = useState(); - const [reportFilter, setReportFilter] = useState(); - const [isUpdating, setIsUpdating] = useState(false); - // storeAsDefault is set in SaveDialogContent - // eslint-disable-next-line no-unused-vars - const [storeAsDefault, setStoreAsDefault] = useState(); - - const [_] = useTranslation(); - const gmp = useGmp(); - const dispatch = useDispatch(); - const params = useParams('/audit/:id'); - const {id: reportId} = params; - - const pSelector = useSelector(getPage, shallowEqual); - const pageFilter = pSelector?.getFilter(getReportPageName(reportId)); - - const [selectedEntity, reportError, isLoading] = useSelector(state => { - const reportSel = auditReportSelector(state); - return [ - reportSel?.getEntity(reportId, pageFilter), - reportSel?.getEntityError(reportId, pageFilter), - reportSel?.isLoadingEntity(reportId, pageFilter), - ]; - }, shallowEqual); - - const userDefaultsSelector = useSelector( - getUserSettingsDefaults, - shallowEqual, - ); - const reportExportFileName = userDefaultsSelector?.getValueByName( - 'reportexportfilename', - ); - - const reportFormatsSel = useSelector(reportFormatsSelector); - const reportConfigsSel = useSelector(reportConfigsSelector); - const reportFormats = reportFormatsSel?.getAllEntities(REPORT_FORMATS_FILTER); - const reportConfigs = reportConfigsSel?.getAllEntities(ALL_FILTER); - const reportComposerDefaults = useSelector(getReportComposerDefaults); - const userDefaultFilterSel = useSelector( - rootState => getUserSettingsDefaultFilter(rootState, 'result'), - shallowEqual, - ); - const resultDefaultFilter = userDefaultFilterSel?.getFilter(); - const username = useUserName(); - - useEffect(() => { - dispatch(loadUserSettingDefaults(gmp)()); - dispatch(loadFilters(gmp)(RESULTS_FILTER_FILTER)); - dispatch(loadReportFormats(gmp)(REPORT_FORMATS_FILTER)); - dispatch(loadReportConfigs(gmp)(ALL_FILTER)); - dispatch(loadReportComposerDefaults(gmp)()); - - if (isDefined(selectedEntity)) { - setEntity(entity); - updateReportCounts(selectedEntity); - setReportFilter(props.reportFilter); - setIsUpdating(false); - } else { - setIsUpdating(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (isDefined(selectedEntity)) { - // update only if a new report is available to avoid having no report - // when the filter changes - setEntity(selectedEntity); - updateReportCounts(selectedEntity); - setReportFilter(props.reportFilter); - setIsUpdating(false); - } else { - // report is not in the store and is currently loaded - setIsUpdating(true); - } - }, [selectedEntity, props.reportFilter]); - - useEffect(() => { - if ( - !isDefined(reportFormatId) && - isDefined(reportFormats) && - reportFormats.length > 0 - ) { - // set initial report format id if available - const initialReportFormatId = first(reportFormats).id; - if (isDefined(initialReportFormatId)) { - // ensure the report format id is only set if we really have one - // if no report format id is available we would create an infinite - // render loop here - setReportFormatId({initialReportFormatId}); - } else { - // if there is no report format at all, throw a proper error message - // instead of just showing x is undefined JS stacktrace - const noReportFormatError = _( - 'The report cannot be displayed because' + - ' no report format is available.' + - ' This could be due to a missing gvmd data feed. Please update' + - ' the gvmd data feed, check the "feed import owner" setting, the' + - ' feed status page, or contact your system administrator.', - ); - throw new Error(noReportFormatError); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportFormats, reportFormatId]); - - useEffect(() => { - load(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportId]); - - const updateReportCounts = reportEntity => { - const {report = {}} = reportEntity; - const { - results = {}, - hosts = {}, - operatingSystems = {}, - tlsCertificates = {}, - errors = {}, - } = report; - - if (isDefined(results.counts)) { - setResultsCounts(results.counts); - } - if (isDefined(hosts.counts)) { - setHostsCounts(hosts.counts); - } - if (isDefined(operatingSystems.counts)) { - setOperatingSystemsCounts(operatingSystems.counts); - } - if (isDefined(tlsCertificates.counts)) { - setTlsCertificatesCounts(tlsCertificates.counts); - } - if (isDefined(errors.counts)) { - setErrorsCounts(errors.counts); - } - }; - - const load = newFilter => { - log.debug('Loading report', { - newFilter, - }); - const {reportFilter: filter} = props; - - setIsUpdating(!isDefined(filter) || !filter.equals(newFilter)); // show update indicator if filter has changed - - props - .reload(newFilter) - .then(() => { - setIsUpdating(false); - }) - .catch(() => { - setIsUpdating(false); - }); - }; - - const reload = () => { - // reload data from backend - load(props.reportFilter); - }; - - const handleChanged = () => { - reload(); - }; - - const handleError = error => { - const {showError} = props; - log.error(error); - showError(error); - }; - - const handleFilterChange = filter => { - load(filter); - }; - - const handleFilterRemoveClick = () => { - handleFilterChange(AUDIT_REPORT_RESET_FILTER); - }; - - const handleFilterResetClick = () => { - if (hasValue(resultDefaultFilter)) { - handleFilterChange(resultDefaultFilter); - } else { - handleFilterChange(DEFAULT_FILTER); - } - }; - - const handleAddToAssets = () => { - const {showSuccessMessage, reportFilter: filter} = props; - - gmp.auditreport.addAssets(selectedEntity, {filter}).then(() => { - showSuccessMessage( - _( - 'Report content added to Assets with QoD>=70% and Overrides enabled.', - ), - ); - reload(); - }, handleError); - }; - - const handleRemoveFromAssets = () => { - const {showSuccessMessage, reportFilter: filter} = props; - - gmp.auditreport.removeAssets(selectedEntity, {filter}).then(() => { - showSuccessMessage(_('Report content removed from Assets.')); - reload(); - }, handleError); - }; - - const handleFilterEditClick = () => { - setShowFilterDialog(true); - }; - - const handleFilterDialogClose = () => { - setShowFilterDialog(false); - }; - - const handleOpenDownloadReportDialog = () => { - setShowDownloadReportDialog(true); - }; - - const handleCloseDownloadReportDialog = () => { - setShowDownloadReportDialog(false); - }; - - const handleReportDownload = state => { - const {reportFilter: filter, onDownload} = props; - - const { - includeNotes, - includeOverrides, - - reportFormatId, - - storeAsDefault, - } = state; - - const newFilter = filter.copy(); - newFilter.set('notes', includeNotes); - newFilter.set('overrides', includeOverrides); - - if (storeAsDefault) { - const defaults = { - ...reportComposerDefaults, - defaultReportFormatId: reportFormatId, - includeNotes, - includeOverrides, - }; - dispatch(saveReportComposerDefaults(gmp)(defaults)); - } - - const report_format = reportFormats - ? reportFormats.find(format => reportFormatId === format.id) - : undefined; - - const extension = isDefined(report_format) - ? report_format.extension - : 'unknown'; // unknown should never happen but we should be save here - - return gmp.auditreport - .download(selectedEntity, { - reportFormatId, - filter: newFilter, - }) - .then(response => { - setShowDownloadReportDialog(false); - const {data} = response; - const filename = generateFilename({ - creationTime: selectedEntity.creationTime, - extension, - fileNameFormat: reportExportFileName, - id: selectedEntity.id, - modificationTime: selectedEntity.modificationTime, - reportFormat: report_format?.name, - resourceName: selectedEntity.task.name, - resourceType: 'report', - username, - }); - - onDownload({filename, data}); - }, handleError); - }; - - const handleTlsCertificateDownload = cert => { - const {onDownload} = props; - - const {data, serial} = cert; - - onDownload({ - filename: 'tls-cert-' + serial + '.pem', - mimetype: 'application/x-x509-ca-cert', - data: create_pem_certificate(data), - }); - }; - - const handleFilterCreated = filter => { - load(filter); - dispatch(loadFilters(gmp)(RESULTS_FILTER_FILTER)); - }; - - const handleFilterDecreaseMinQoD = () => { - const {reportFilter: filter} = props; - - if (filter.has('min_qod')) { - const lfilter = filter.copy(); - lfilter.set('min_qod', 30); - load(lfilter); - } - }; - - const handleSortChange = (name, sortField) => { - const prev = sorting[name]; - - const sortReverse = - sortField === prev.sortField ? !prev.sortReverse : false; - - const newSort = { - ...sorting, - [name]: { - sortField, - sortReverse, - }, - }; - setSorting(newSort); - }; - - const loadTarget = () => { - const target = getTarget(selectedEntity); - return gmp.target.get({id: target.id}); - }; - - const {showError, showErrorMessage, showSuccessMessage} = props; - - const report = isDefined(entity) ? entity.report : undefined; - - const threshold = gmp.settings.reportResultsThreshold; - - const showThresholdMessage = - isDefined(report) && report.results.counts.filtered > threshold; - - const [filters, isLoadingFilters] = useSelector(state => { - const filterSel = filterSelector(state); - return [ - filterSel?.getAllEntities(RESULTS_FILTER_FILTER), - filterSel?.isLoadingAllEntities(RESULTS_FILTER_FILTER), - ]; - }); - - return ( - - - - {({edit}) => ( - - loadTarget().then(response => edit(response.data)) - } - onTlsCertificateDownloadClick={handleTlsCertificateDownload} - /> - )} - - {showFilterDialog && ( - - )} - {showDownloadReportDialog && ( - - )} - - ); -}; - -AuditReportDetailsPage.propTypes = { - location: PropTypes.object.isRequired, - reload: PropTypes.func.isRequired, - reportFilter: PropTypes.filter, - showError: PropTypes.func.isRequired, - showErrorMessage: PropTypes.func.isRequired, - showSuccessMessage: PropTypes.func.isRequired, - target: PropTypes.model, - username: PropTypes.string, - onDownload: PropTypes.func.isRequired, -}; - -const reloadInterval = report => - isDefined(report) && isActive(report.report.scan_run_status) - ? USE_DEFAULT_RELOAD_INTERVAL_ACTIVE - : NO_RELOAD; // report doesn't change anymore. no need to reload - -const load = - ({ - defaultFilter, - reportId, - - dispatch, - gmp, - params, - pageFilter, - reportFilter, - }) => - filter => { - if (!hasValue(filter)) { - // use loaded filter after initial loading - filter = reportFilter; - } - - if (!hasValue(filter)) { - // use filter from store - filter = pageFilter; - } - - if (!hasValue(filter)) { - // use filter from user setting - filter = defaultFilter; - } - - if (!hasValue(filter)) { - // use fallback filter - filter = DEFAULT_FILTER; - } - dispatch(setPageFilter(getReportPageName(params.id), filter)); - return dispatch(loadAuditReportWithThreshold(gmp)(reportId, {filter})); - }; - -const ReportDetailsWrapper = props => { - const dispatch = useDispatch(); - const gmp = useGmp(); - const params = useParams(); - - const {id: reportId} = params; - const reportSel = useSelector(auditReportSelector, shallowEqual); - const pSelector = useSelector(getPage, shallowEqual); - - const pageFilter = pSelector.getFilter(getReportPageName(reportId)); - const entity = reportSel.getEntity(reportId, pageFilter); - const reportFilter = entity?.report?.filter; - - return ( - - {({filter}) => ( - reloadInterval(entity)} - > - {({reload}) => ( - - )} - - )} - - ); -}; - -export default compose( - withDialogNotification, - withDownload, -)(ReportDetailsWrapper); diff --git a/src/web/pages/reports/AuditReportDetailsPage.tsx b/src/web/pages/reports/AuditReportDetailsPage.tsx new file mode 100644 index 0000000000..eb92e409a7 --- /dev/null +++ b/src/web/pages/reports/AuditReportDetailsPage.tsx @@ -0,0 +1,571 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useCallback, useEffect, useState} from 'react'; +import {useQueryClient} from '@tanstack/react-query'; +import {useParams} from 'react-router'; +import logger from 'gmp/log'; +import Filter, {RESET_FILTER} from 'gmp/models/filter'; +import type AuditReport from 'gmp/models/audit-report'; +import {isActive} from 'gmp/models/task'; +import {isDefined} from 'gmp/utils/identity'; +import Download from 'web/components/form/Download'; +import useDownload from 'web/components/form/useDownload'; +import PageTitle from 'web/components/layout/PageTitle'; +import DialogNotification from 'web/components/notification/DialogNotification'; +import useDialogNotification from 'web/components/notification/useDialogNotification'; +import { + useGetReportConfigs, + useGetReportExportFileName, + useGetReportFormats, + useGetResultsFilters, +} from 'web/hooks/use-query/reports'; +import useGetReportTlsCertificates from 'web/hooks/use-query/report-tls-certificates'; +import useGmp from 'web/hooks/useGmp'; +import usePageFilter from 'web/hooks/usePageFilter'; +import useTranslation from 'web/hooks/useTranslation'; +import useUserName from 'web/hooks/useUserName'; +import Page from 'web/pages/reports/AuditReportDetailsContent'; +import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; +import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; +import TargetComponent from 'web/pages/targets/TargetComponent'; +import {create_pem_certificate} from 'web/utils/Cert'; +import {generateFilename} from 'web/utils/Render'; +import useGetEntity from 'web/queries/useGetEntity'; + +interface SortState { + sortField: string; + sortReverse: boolean; +} + +interface SortingState { + results: SortState; + hosts: SortState; + os: SortState; + tlscerts: SortState; + errors: SortState; +} + +interface ReportComposerDefaults { + defaultReportFormatId?: string; + includeNotes?: boolean; + includeOverrides?: boolean; +} + +interface DownloadReportState { + includeNotes: boolean; + includeOverrides: boolean; + reportFormatId: string; + storeAsDefault: boolean; +} + +interface ReportTargetRef { + id: string; +} + +const log = logger.getLogger('web.pages.reports.AuditReportDetailsPage'); + +const DEFAULT_FILTER = Filter.fromString( + 'levels=hmlg rows=100 min_qod=70 first=1 compliance_levels=yniu sort=compliant', +); + +export const AUDIT_REPORT_RESET_FILTER = RESET_FILTER.copy() + .setSortOrder('sort') + .setSortBy('compliant'); + +const hasTargetId = (value: unknown): value is ReportTargetRef => { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof (value as {id?: unknown}).id === 'string' + ); +}; + +const getTarget = (entity?: AuditReport) => { + const report = entity?.report; + const task = report?.task as {target?: unknown} | undefined; + const target = task?.target; + return hasTargetId(target) ? target : undefined; +}; + +const getReportFilter = (entity?: AuditReport) => { + return entity?.report?.filter; +}; + +const initialSorting: SortingState = { + results: {sortField: 'compliant', sortReverse: true}, + hosts: {sortField: 'compliant', sortReverse: true}, + os: {sortField: 'compliant', sortReverse: true}, + tlscerts: {sortField: 'dn', sortReverse: false}, + errors: {sortField: 'error', sortReverse: false}, +}; + +const useGetAuditReport = ({ + id, + filter, + refetchInterval, +}: { + id: string; + filter?: Filter; + refetchInterval?: number | false | ((data?: AuditReport) => number | false); +}) => { + const gmp = useGmp(); + const threshold = gmp.settings.reportResultsThreshold; + const filterString = filter?.toFilterString(); + + return useGetEntity({ + gmpMethod: async ({id}) => { + const lightResponse = await gmp.auditreport.get( + {id}, + {filter: filterString, details: false}, + ); + const lightReport = lightResponse.data; + + const needsFullReport = + isDefined(lightReport?.report?.results) && + lightReport.report.results.counts.filtered < threshold; + + if (needsFullReport) { + return gmp.auditreport.get( + {id}, + {filter: filterString, details: true}, + ); + } + + return lightResponse; + }, + queryId: 'get_audit_report', + queryKeyParts: [filterString], + id, + refetchInterval, + }); +}; + +const AuditReportDetailsPage = () => { + const [_] = useTranslation(); + const {id: reportId = ''} = useParams<{id: string}>(); + const gmp = useGmp(); + const queryClient = useQueryClient(); + const username = useUserName(); + + const { + dialogState, + closeDialog, + showError, + showErrorMessage, + showSuccessMessage, + } = useDialogNotification(); + const [downloadRef, handleDownload] = useDownload(); + + const [showFilterDialog, setShowFilterDialog] = useState(false); + const [showDownloadReportDialog, setShowDownloadReportDialog] = + useState(false); + const [sorting, setSorting] = useState(initialSorting); + const [reportComposerDefaults, setReportComposerDefaults] = + useState({}); + + // Filter management + const [pageFilter, , {changeFilter}] = usePageFilter( + `report-${reportId}`, + 'result', + {fallbackFilter: DEFAULT_FILTER}, + ); + + // Report entity + const getRefetchInterval = useCallback( + (entity?: AuditReport) => { + if (!isDefined(entity) || !isDefined(entity.report)) { + return false as const; + } + return isActive(entity.report.scan_run_status) + ? gmp.settings.reloadIntervalActive + : false; + }, + [gmp.settings.reloadIntervalActive], + ); + + const { + data: entity, + error: queryError, + isError, + isLoading, + isFetching, + } = useGetAuditReport({ + id: reportId, + filter: pageFilter, + refetchInterval: getRefetchInterval, + }); + + const reportError = isError ? queryError : undefined; + + const reportFilter = getReportFilter(entity); + const {data: reportTlsCertificatesData} = useGetReportTlsCertificates({ + reportId, + filter: reportFilter, + }); + + // Filters list for Powerfilter dropdown + const {data: filtersData, isLoading: isLoadingFilters} = + useGetResultsFilters(); + const filters = filtersData?.entities ?? []; + + // Report formats for download dialog + const {data: reportFormatsData} = useGetReportFormats(); + const reportFormats = reportFormatsData?.entities; + + // Report configs for download dialog + const {data: reportConfigsData} = useGetReportConfigs(); + const reportConfigs = reportConfigsData?.entities ?? []; + + // User settings: report export filename + const {data: reportExportFileName} = useGetReportExportFileName(); + + // Report composer defaults + useEffect(() => { + const loadReportComposerDefaults = async () => { + try { + const response = await gmp.user.getReportComposerDefaults(); + setReportComposerDefaults(response.data); + } catch (error) { + log.error('Error loading report composer defaults', error); + } + }; + + void loadReportComposerDefaults(); + }, [gmp]); + + // Set initial report format ID from available formats + useEffect(() => { + if (isDefined(reportFormats) && reportFormats.length > 0) { + const reportFormatId = reportFormats[0]?.id; + if (!isDefined(reportFormatId)) { + const noReportFormatError = _( + 'The report cannot be displayed because' + + ' no report format is available.' + + ' This could be due to a missing gvmd data feed. Please update' + + ' the gvmd data feed, check the "feed import owner" setting, the' + + ' feed status page, or contact your system administrator.', + ); + throw new Error(noReportFormatError); + } + } + }, [reportFormats, _]); + + // Derive counts from report entity + const report = entity?.report; + + const resultsCounts = report?.results?.counts; + const hostsCounts = report?.hosts?.counts; + const operatingSystemsCounts = report?.operatingSystems?.counts; + const tlsCertificatesCounts = + reportTlsCertificatesData?.entitiesCounts ?? report?.tlsCertificates?.counts; + const errorsCounts = report?.errors?.counts; + + const threshold = gmp.settings.reportResultsThreshold; + const showThresholdMessage = + isDefined(report) && + isDefined(resultsCounts) && + resultsCounts.filtered > threshold; + + // Handlers + const handleFilterChange = useCallback( + (filter: Filter) => { + changeFilter(filter); + }, + [changeFilter], + ); + + const handleFilterRemoveClick = useCallback(() => { + handleFilterChange(AUDIT_REPORT_RESET_FILTER); + }, [handleFilterChange]); + + const handleFilterResetClick = useCallback(() => { + handleFilterChange(DEFAULT_FILTER); + }, [handleFilterChange]); + + const handleAddToAssets = useCallback(async () => { + if (!entity?.id) return; + try { + await gmp.auditreport.addAssets({ + id: entity.id, + filter: reportFilter?.toFilterString(), + }); + showSuccessMessage( + _( + 'Report content added to Assets with QoD>=70% and Overrides enabled.', + ), + ); + await queryClient.invalidateQueries({queryKey: ['get_audit_report']}); + } catch (error) { + log.error(error); + showError(error as Error); + } + }, [ + entity, + gmp, + reportFilter, + showSuccessMessage, + showError, + queryClient, + _, + ]); + + const handleRemoveFromAssets = useCallback(async () => { + if (!entity?.id) return; + try { + await gmp.auditreport.removeAssets({ + id: entity.id, + filter: reportFilter?.toFilterString(), + }); + showSuccessMessage(_('Report content removed from Assets.')); + await queryClient.invalidateQueries({queryKey: ['get_audit_report']}); + } catch (error) { + log.error(error); + showError(error as Error); + } + }, [ + entity, + gmp, + reportFilter, + showSuccessMessage, + showError, + queryClient, + _, + ]); + + const handleFilterEditClick = useCallback(() => { + setShowFilterDialog(true); + }, []); + + const handleFilterDialogClose = useCallback(() => { + setShowFilterDialog(false); + }, []); + + const handleOpenDownloadReportDialog = useCallback(() => { + setShowDownloadReportDialog(true); + }, []); + + const handleCloseDownloadReportDialog = useCallback(() => { + setShowDownloadReportDialog(false); + }, []); + + const handleReportDownload = useCallback( + async (values: Record) => { + const state = values as unknown as DownloadReportState; + if (!entity || !reportFilter) return; + const {includeNotes, includeOverrides, reportFormatId, storeAsDefault} = + state; + + const newFilter = reportFilter.copy(); + newFilter.set('notes', includeNotes); + newFilter.set('overrides', includeOverrides); + + if (storeAsDefault) { + const defaults = { + ...reportComposerDefaults, + defaultReportFormatId: reportFormatId, + includeNotes, + includeOverrides, + }; + try { + await gmp.user.saveReportComposerDefaults(defaults); + setReportComposerDefaults(defaults); + } catch (error) { + log.error('Error saving report composer defaults', error); + } + } + + const reportFormat = reportFormats?.find( + format => reportFormatId === format.id, + ); + + const extension = isDefined(reportFormat) + ? reportFormat.extension + : 'unknown'; + + try { + const response = await gmp.auditreport.download( + {id: entity.id as string}, + { + reportFormatId, + filter: newFilter, + }, + ); + setShowDownloadReportDialog(false); + const {data} = response; + const filename = generateFilename({ + creationTime: entity.creationTime, + extension, + fileNameFormat: reportExportFileName, + id: entity.id as string, + modificationTime: entity.modificationTime, + reportFormat: reportFormat?.name, + resourceName: entity.task?.name, + resourceType: 'report', + username, + }); + + handleDownload({filename, data}); + } catch (error) { + log.error(error); + showError(error as Error); + } + }, + [ + entity, + gmp, + handleDownload, + reportComposerDefaults, + reportExportFileName, + reportFilter, + reportFormats, + showError, + username, + ], + ); + + const handleTlsCertificateDownload = useCallback( + (cert: {data: string; serial: string}) => { + handleDownload({ + filename: 'tls-cert-' + cert.serial + '.pem', + mimetype: 'application/x-x509-ca-cert', + data: create_pem_certificate(cert.data), + }); + }, + [handleDownload], + ); + + const handleFilterCreated = useCallback( + (filter: Filter) => { + handleFilterChange(filter); + void queryClient.invalidateQueries({queryKey: ['get_filters']}); + }, + [handleFilterChange, queryClient], + ); + + const handleFilterDecreaseMinQoD = useCallback(() => { + if (!reportFilter) return; + + if (reportFilter.has('min_qod')) { + const levelFilter = reportFilter.copy(); + levelFilter.set('min_qod', 30); + handleFilterChange(levelFilter); + } + }, [reportFilter, handleFilterChange]); + + const handleSortChange = useCallback( + (name: string, sortField: string) => { + const prev = sorting[name as keyof SortingState]; + const sortReverse = + sortField === prev.sortField ? !prev.sortReverse : false; + + setSorting(prevSorting => ({ + ...prevSorting, + [name]: {sortField, sortReverse}, + })); + }, + [sorting], + ); + + const handleChanged = useCallback(() => { + void queryClient.invalidateQueries({queryKey: ['get_audit_report']}); + }, [queryClient]); + + const handleError = useCallback( + (error: Error) => { + log.error(error); + showError(error); + }, + [showError], + ); + + const loadTarget = useCallback(() => { + if (!entity) return Promise.resolve(); + const target = getTarget(entity); + if (!isDefined(target)) return Promise.resolve(); + return gmp.target.get({id: target.id}); + }, [entity, gmp]); + + return ( + <> + + + + + {({edit}) => ( + void} + showErrorMessage={showErrorMessage} + showSuccessMessage={showSuccessMessage} + sorting={sorting} + task={isDefined(report) ? report.task : undefined} + tlsCertificatesCounts={tlsCertificatesCounts} + onAddToAssetsClick={handleAddToAssets} + onError={handleError} + onFilterChanged={handleFilterChange} + onFilterCreated={handleFilterCreated} + onFilterDecreaseMinQoDClick={handleFilterDecreaseMinQoD} + onFilterEditClick={handleFilterEditClick} + onFilterRemoveClick={handleFilterRemoveClick} + onFilterResetClick={handleFilterResetClick} + onRemoveFromAssetsClick={handleRemoveFromAssets} + onReportDownloadClick={handleOpenDownloadReportDialog} + onSortChange={handleSortChange} + onTagSuccess={handleChanged} + onTargetEditClick={async () => { + const response = await loadTarget(); + if (response) edit(response.data); + }} + onTlsCertificateDownloadClick={handleTlsCertificateDownload} + /> + )} + + {showFilterDialog && reportFilter && ( + + )} + {showDownloadReportDialog && reportFilter && ( + + )} + + ); +}; + +export default AuditReportDetailsPage; diff --git a/src/web/pages/reports/DetailsPage.tsx b/src/web/pages/reports/DetailsPage.tsx index fa1872b93e..458fc44cbc 100644 --- a/src/web/pages/reports/DetailsPage.tsx +++ b/src/web/pages/reports/DetailsPage.tsx @@ -24,6 +24,7 @@ import { useGetResultsFilters, } from 'web/hooks/use-query/reports'; import useGmp from 'web/hooks/useGmp'; +import useGetReportTlsCertificates from 'web/hooks/use-query/report-tls-certificates'; import usePageFilter from 'web/hooks/usePageFilter'; import useTranslation from 'web/hooks/useTranslation'; import useUserName from 'web/hooks/useUserName'; @@ -170,6 +171,10 @@ const ReportDetailsPage = () => { const reportError = isError ? queryError : undefined; const reportFilter = getReportFilter(entity); + const {data: reportTlsCertificatesData} = useGetReportTlsCertificates({ + reportId, + filter: reportFilter, + }); // Filters list for Powerfilter dropdown const {data: filtersData, isLoading: isLoadingFilters} = @@ -228,7 +233,8 @@ const ReportDetailsPage = () => { const operatingSystemsCounts = report?.operatingsystems?.counts; const cvesCounts = report?.cves?.counts; const closedCvesCounts = report?.closedCves?.counts; - const tlsCertificatesCounts = report?.tlsCertificates?.counts; + const tlsCertificatesCounts = + reportTlsCertificatesData?.entitiesCounts ?? report?.tlsCertificates?.counts; const errorsCounts = report?.errors?.counts; const threshold = gmp.settings.reportResultsThreshold; diff --git a/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx b/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx new file mode 100644 index 0000000000..2e87a845c8 --- /dev/null +++ b/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx @@ -0,0 +1,494 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import React from 'react'; +import {describe, test, expect, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen, within} from 'web/testing'; +import Filter from 'gmp/models/filter'; +import {createSession} from 'gmp/testing'; +import {getMockAuditReport} from 'web/pages/reports/__fixtures__/MockAuditReport'; +import AuditReportDetailsContent from 'web/pages/reports/AuditReportDetailsContent.jsx'; + +const filter = Filter.fromString( + 'apply_overrides=0 compliance_levels=ynui rows=10 min_qod=70 first=1 sort=compliant', +); + +const resetFilter = Filter.fromString( + 'first=1 compliance_levels=ynui sort=compliant', +); + +const sorting = { + errors: {sortField: 'error', sortReverse: true}, + hosts: {sortField: 'compliant', sortReverse: true}, + os: {sortField: 'compliant', sortReverse: true}, + results: {sortField: 'compliant', sortReverse: true}, + tlscerts: {sortField: 'dn', sortReverse: true}, +}; + +const createGmp = ({reportResultsThreshold = 10} = {}) => ({ + settings: { + manualUrl: 'test/', + reportResultsThreshold, + }, + results: { + get: testing.fn(), + }, + session: createSession({timezone: 'CET'}), + user: { + currentSettings: testing.fn().mockResolvedValue({foo: 'bar'}), + getReportComposerDefaults: testing.fn().mockResolvedValue({foo: 'bar'}), + }, +}); + +const makeCallbacks = () => ({ + onAddToAssetsClick: testing.fn(), + onError: testing.fn(), + onFilterChanged: testing.fn(), + onFilterCreated: testing.fn(), + onFilterDecreaseMinQoDClick: testing.fn(), + onFilterEditClick: testing.fn(), + onFilterRemoveClick: testing.fn(), + onFilterResetClick: testing.fn(), + onRemoveFromAssetsClick: testing.fn(), + onReportDownloadClick: testing.fn(), + onSortChange: testing.fn(), + onTagSuccess: testing.fn(), + onTargetEditClick: testing.fn(), + onTlsCertificateDownloadClick: testing.fn(), + showError: testing.fn(), + showErrorMessage: testing.fn(), + showSuccessMessage: testing.fn(), +}); + +const renderContent = ( + overrideProps = {}, + {reportResultsThreshold = 10} = {}, +) => { + const cbs = makeCallbacks(); + const {entity} = getMockAuditReport(); + const gmp = createGmp({reportResultsThreshold}); + const {render} = rendererWith({gmp, capabilities: true, router: true}); + + render( + , + ); + + return cbs; +}; + +describe('AuditReportDetailsContent tests', () => { + describe('Loading state', () => { + test('should show Loading text and spinner when isLoading and no entity', () => { + const cbs = makeCallbacks(); + const gmp = createGmp(); + const {render} = rendererWith({gmp, capabilities: true, router: true}); + + render( + , + ); + + expect(screen.getByText('Audit Report:')).toBeInTheDocument(); + expect(screen.getByText('Loading')).toBeInTheDocument(); + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + + test('should render Loading spinner inside section when no entity and not loading', () => { + const cbs = makeCallbacks(); + const gmp = createGmp(); + const {render} = rendererWith({gmp, capabilities: true, router: true}); + + render( + , + ); + + expect(screen.getByTestId('loading')).toBeInTheDocument(); + expect(screen.queryByText('Loading')).not.toBeInTheDocument(); + }); + }); + + describe('Error state', () => { + test('should render ErrorPanel when reportError is defined and no entity', () => { + const cbs = makeCallbacks(); + const gmp = createGmp(); + const {render} = rendererWith({gmp, capabilities: true, router: true}); + + render( + , + ); + + expect( + screen.getByText(/Error while loading Report report-1234/), + ).toBeInTheDocument(); + }); + }); + + describe('Full render with entity', () => { + test('should render all 7 tabs and summary content by default', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + expect(within(tablist).getAllByRole('tab')).toHaveLength(7); + expect( + within(tablist).getByRole('tab', {name: /^information/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^results/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^hosts/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^operating systems/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^tls certificates/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^error messages/i}), + ).toBeInTheDocument(); + expect( + within(tablist).getByRole('tab', {name: /^user tags/i}), + ).toBeInTheDocument(); + + // Summary tab active by default + expect(screen.getByRole('row', {name: /^Task Name/})).toHaveTextContent( + 'foo', + ); + }); + + test('should render entity info (Created, Modified, Owner)', () => { + renderContent(); + + const entityInfo = within(screen.getByTestId('entity-info')); + expect( + entityInfo.getByRole('row', {name: /^Created:/}), + ).toHaveTextContent('Sun, Jun 2, 2019'); + expect( + entityInfo.getByRole('row', {name: /^Modified:/}), + ).toHaveTextContent('Mon, Jun 3, 2019'); + expect(entityInfo.getByRole('row', {name: /^Owner:/})).toHaveTextContent( + 'admin', + ); + }); + + test('should render toolbar icons with audit-specific help link', () => { + renderContent(); + + const helpIcon = screen.getByTitle('Help: Audit Reports'); + expect(helpIcon.closest('a')).toHaveAttribute( + 'href', + 'test/en/compliance-and-special-scans.html#using-and-managing-audit-reports', + ); + expect(screen.getByTestId('list-link-icon')).toHaveAttribute( + 'href', + '/auditreports', + ); + expect(screen.getByTitle(/^Add to Assets/)).toBeInTheDocument(); + expect(screen.getByTitle(/^Remove from Assets/)).toBeInTheDocument(); + expect( + screen.getByTitle(/^Download filtered Report/), + ).toBeInTheDocument(); + }); + + test('should render report heading with timestamp and status', () => { + renderContent(); + + expect(screen.getByText('Audit Report:')).toBeInTheDocument(); + expect( + screen.getByRole('heading', {name: /Audit Report:/}), + ).toBeInTheDocument(); + }); + + test('should show Import Task status when task is an import task', () => { + const importTask = {isImport: () => true, progress: 0}; + renderContent({task: importTask}); + + // With import task, status becomes TASK_STATUS.import – the status bar + // shows "Import Task" rather than "Done" + const statusBarTitles = screen + .getAllByTestId('progressbar-box') + .map(bar => bar.getAttribute('title')); + + expect(statusBarTitles).toContain('Import Task'); + }); + }); + + describe('Tab navigation', () => { + test('should switch to Results tab', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click(within(tablist).getByRole('tab', {name: /^results/i})); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + + test('should switch to Hosts tab and render host data', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click(within(tablist).getByRole('tab', {name: /^hosts/i})); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: /IP/i}), + ).toBeInTheDocument(); + }); + + test('should switch to Operating Systems tab', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^operating systems/i}), + ); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + + test('should switch to TLS Certificates tab', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^tls certificates/i}), + ); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + + test('should switch to Error Messages tab', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^error messages/i}), + ); + + expect( + screen.getByRole('columnheader', {name: /Error Message/i}), + ).toBeInTheDocument(); + }); + + test('should switch to User Tags tab', () => { + renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click(within(tablist).getByRole('tab', {name: /^user tags/i})); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + }); + + describe('Threshold message', () => { + test('should show threshold panel in Hosts tab when results exceed threshold', () => { + renderContent({}, {reportResultsThreshold: 1}); + + const tablist = screen.getByRole('tablist'); + fireEvent.click(within(tablist).getByRole('tab', {name: /^hosts/i})); + + expect( + screen.getByText(/The Hosts cannot be displayed in order to maintain/), + ).toBeInTheDocument(); + }); + + test('should show threshold panel in Operating Systems tab when results exceed threshold', () => { + renderContent({}, {reportResultsThreshold: 1}); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^operating systems/i}), + ); + + expect( + screen.getByText( + /The Operating Systems cannot be displayed in order to maintain/, + ), + ).toBeInTheDocument(); + }); + + test('should show threshold panel in TLS Certificates tab when results exceed threshold', () => { + renderContent({}, {reportResultsThreshold: 1}); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^tls certificates/i}), + ); + + expect( + screen.getByText( + /The TLS Certificates cannot be displayed in order to maintain/, + ), + ).toBeInTheDocument(); + }); + }); + + describe('Sorting', () => { + test('should call onSortChange when Error Messages column header is clicked', () => { + const cbs = renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click( + within(tablist).getByRole('tab', {name: /^error messages/i}), + ); + + fireEvent.click(screen.getByTestId('table-header-sort-by-error')); + + expect(cbs.onSortChange).toHaveBeenCalledWith('errors', 'error'); + }); + + test('should call onSortChange when Hosts IP column header is clicked', () => { + const cbs = renderContent(); + + const tablist = screen.getByRole('tablist'); + fireEvent.click(within(tablist).getByRole('tab', {name: /^hosts/i})); + + fireEvent.click(screen.getByTestId('table-header-sort-by-ip')); + + expect(cbs.onSortChange).toHaveBeenCalledWith('hosts', 'ip'); + }); + }); + + describe('Click handlers', () => { + test('should call onAddToAssetsClick when Add to Assets is clicked', () => { + const cbs = renderContent(); + + fireEvent.click(screen.getByTitle(/^Add to Assets/)); + + expect(cbs.onAddToAssetsClick).toHaveBeenCalled(); + }); + + test('should call onRemoveFromAssetsClick when Remove from Assets is clicked', () => { + const cbs = renderContent(); + + fireEvent.click(screen.getByTitle(/^Remove from Assets/)); + + expect(cbs.onRemoveFromAssetsClick).toHaveBeenCalled(); + }); + + test('should call onReportDownloadClick when Download button is clicked', () => { + const cbs = renderContent(); + + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + expect(cbs.onReportDownloadClick).toHaveBeenCalled(); + }); + + test('should call onFilterEditClick when Edit Filter button is clicked', () => { + const cbs = renderContent(); + + fireEvent.click(screen.getByTitle('Edit Filter')); + + expect(cbs.onFilterEditClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx b/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx new file mode 100644 index 0000000000..ecc8efb7db --- /dev/null +++ b/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx @@ -0,0 +1,486 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import {rendererWith, fireEvent, screen, within, waitFor} from 'web/testing'; +import {Route, Routes} from 'react-router'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/user'; +import Filter from 'gmp/models/filter'; +import {createSession} from 'gmp/testing'; +import {currentSettingsDefaultResponse} from 'web/pages/__fixtures__/current-settings'; +import {getMockAuditReport} from 'web/pages/reports/__fixtures__/MockAuditReport'; +import AuditReportDetailsPage from 'web/pages/reports/AuditReportDetailsPage'; + +interface CollectionResponse { + data: unknown[]; + meta: { + filter: Filter; + counts: CollectionCounts; + }; +} + +const manualUrl = 'test/'; + +const {entity} = getMockAuditReport(); +const reportId = entity.report?.id ?? ''; + +const emptyCollectionResponse: CollectionResponse = { + data: [], + meta: { + filter: Filter.fromString(''), + counts: new CollectionCounts(), + }, +}; + +const createGetSettingMock = () => + testing.fn().mockImplementation((settingId: string) => { + if (settingId === ROWS_PER_PAGE_SETTING_ID) { + return Promise.resolve({ + data: {id: settingId, name: 'Rows Per Page', value: '50'}, + }); + } + return Promise.resolve({ + data: {id: settingId, name: 'Default Filter', value: 'default-filter-id'}, + }); + }); + +const createGmp = () => ({ + settings: { + manualUrl, + reportResultsThreshold: 100, + reloadInterval: 15000, + reloadIntervalActive: 3000, + }, + session: createSession({ + token: 'test-token', + username: 'admin', + timezone: 'Europe/Berlin', + }), + user: { + currentSettings: testing.fn().mockResolvedValue({ + data: { + ...currentSettingsDefaultResponse.data, + reportexportfilename: { + id: 'report-export-filename', + name: 'Report Export File Name', + value: '%T-%U', + }, + }, + }), + getReportComposerDefaults: testing.fn().mockResolvedValue({data: {}}), + getSetting: createGetSettingMock(), + saveReportComposerDefaults: testing.fn().mockResolvedValue({}), + }, + filter: { + get: testing.fn().mockResolvedValue({data: Filter.fromString('rows=100')}), + }, + filters: { + get: testing.fn().mockResolvedValue(emptyCollectionResponse), + }, + reportconfigs: { + get: testing.fn().mockResolvedValue(emptyCollectionResponse), + }, + reportformats: { + get: testing.fn().mockResolvedValue(emptyCollectionResponse), + }, + auditreport: { + get: testing.fn().mockResolvedValue({data: entity}), + addAssets: testing.fn().mockResolvedValue({}), + removeAssets: testing.fn().mockResolvedValue({}), + download: testing.fn().mockResolvedValue({data: 'report-blob-data'}), + }, + reporttlscertificates: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: { + filter: Filter.fromString(''), + counts: new CollectionCounts(), + }, + }), + }, + target: { + get: testing.fn().mockResolvedValue({data: {}}), + }, + results: { + get: testing.fn().mockResolvedValue({data: []}), + }, +}); + +const setupRenderer = (gmp = createGmp()) => { + const {render, store} = rendererWith({ + gmp, + capabilities: true, + router: true, + store: true, + route: `/audit-report/${reportId}`, + }); + + return {render, store}; +}; + +const renderPage = (render: ReturnType['render']) => + render( + + } path="/audit-report/:id" /> + , + ); + +describe('AuditReportDetailsPage', () => { + describe('Loading state', () => { + test('should render Loading spinner while report is being fetched', () => { + const gmp = createGmp(); + gmp.auditreport.get = testing.fn().mockReturnValue(new Promise(() => {})); + + const {render} = setupRenderer(gmp); + renderPage(render); + + screen.getByTestId('loading'); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + }); + + test('should show Loading text in header while report is being fetched', async () => { + const gmp = createGmp(); + gmp.auditreport.get = testing.fn().mockReturnValue(new Promise(() => {})); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByText('Loading'); + screen.getByText('Audit Report:'); + screen.getByTestId('loading'); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + }); + }); + + describe('Full render with entity', () => { + test('should render audit report heading once entity is loaded', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('heading', {name: /Audit Report:/}); + expect( + screen.getByRole('heading', {name: /Audit Report:/}), + ).toHaveTextContent( + 'Audit Report:Mon, Jun 3, 2019 1:00 PM Central European Summer TimeDone', + ); + }); + + test('should render all 7 tabs once entity is loaded', async () => { + const {render} = setupRenderer(); + renderPage(render); + + const tablist = await screen.findByRole('tablist'); + expect(within(tablist).getAllByRole('tab')).toHaveLength(7); + }); + + test('should render toolbar help and list links', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByTitle('Help: Audit Reports'); + expect( + screen.getByTitle('Help: Audit Reports').closest('a'), + ).toHaveAttribute( + 'href', + 'test/en/compliance-and-special-scans.html#using-and-managing-audit-reports', + ); + }); + + test('should render Information tab content by default', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('row', {name: /^Task Name/}); + expect(screen.getByRole('row', {name: /^Task Name/})).toHaveTextContent( + 'foo', + ); + }); + }); + + describe('Download Report Dialog', () => { + test('should open Download Report dialog when download button is clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + }); + + test('should close Download Report dialog when cancelled', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + expect( + screen.queryByRole('heading', { + name: /Compose Content for Compliance Report/, + }), + ).not.toBeInTheDocument(); + }); + }); + + describe('Filter Dialog', () => { + test('should open Filter dialog when filter edit is triggered', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByTitle('Edit Filter'); + fireEvent.click(screen.getByTitle('Edit Filter')); + + await screen.findByRole('heading', {name: /Update Filter/}); + }); + + test('should close Filter dialog when cancelled', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByTitle('Edit Filter'); + fireEvent.click(screen.getByTitle('Edit Filter')); + + await screen.findByRole('heading', {name: /Update Filter/}); + + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + expect( + screen.queryByRole('heading', {name: /Update Filter/}), + ).not.toBeInTheDocument(); + }); + }); + + describe('Error state', () => { + test('should render error panel when report load fails', async () => { + const gmp = createGmp(); + const loadError = new Error('Connection refused'); + gmp.auditreport.get = testing.fn().mockRejectedValue(loadError); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByText(/Error while loading Report/); + }); + }); + + describe('Tab navigation', () => { + test('should switch to Hosts tab when clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^Hosts/}); + fireEvent.click(screen.getByRole('tab', {name: /^Hosts/})); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + + test('should switch to Error Messages tab when clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^Error Messages/}); + fireEvent.click(screen.getByRole('tab', {name: /^Error Messages/})); + + expect( + screen.queryByRole('row', {name: /^Task Name/}), + ).not.toBeInTheDocument(); + }); + }); + + describe('Asset management', () => { + test('should call addAssets and show success message when Add to Assets is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Add to Assets/); + fireEvent.click(screen.getByTitle(/^Add to Assets/)); + + await screen.findByText(/Report content added to Assets/); + expect(gmp.auditreport.addAssets).toHaveBeenCalled(); + }); + + test('should call removeAssets and show success message when Remove from Assets is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Remove from Assets/); + fireEvent.click(screen.getByTitle(/^Remove from Assets/)); + + await screen.findByText(/Report content removed from Assets/); + expect(gmp.auditreport.removeAssets).toHaveBeenCalled(); + }); + + test('should show error dialog when addAssets fails', async () => { + const gmp = createGmp(); + gmp.auditreport.addAssets = testing + .fn() + .mockRejectedValue(new Error('Permission denied')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Add to Assets/); + fireEvent.click(screen.getByTitle(/^Add to Assets/)); + + await screen.findByText(/Permission denied/); + }); + + test('should show error dialog when removeAssets fails', async () => { + const gmp = createGmp(); + gmp.auditreport.removeAssets = testing + .fn() + .mockRejectedValue(new Error('Server error')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Remove from Assets/); + fireEvent.click(screen.getByTitle(/^Remove from Assets/)); + + await screen.findByText(/Server error/); + }); + }); + + describe('Report download flow', () => { + test('should call auditreport download when download dialog OK is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect(gmp.auditreport.download).toHaveBeenCalled(); + }); + }); + + test('should close download dialog after successful download', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect( + screen.queryByRole('heading', { + name: /Compose Content for Compliance Report/, + }), + ).not.toBeInTheDocument(); + }); + }); + + test('should show error when download fails', async () => { + const gmp = createGmp(); + gmp.auditreport.download = testing + .fn() + .mockRejectedValue(new Error('Download failed')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await screen.findByText(/Download failed/); + }); + }); + + describe('Sort change', () => { + test('should update column sort when Error Messages column header is clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + // Navigate to Error Messages tab + await screen.findByRole('tab', {name: /^Error Messages/}); + fireEvent.click(screen.getByRole('tab', {name: /^Error Messages/})); + + // Click on the "Error Message" column header to sort + const errorMessageHeader = await screen.findByRole('columnheader', { + name: /Error Message/, + }); + fireEvent.click(errorMessageHeader); + + // The column header should still be in the document after the sort + expect( + screen.getByRole('columnheader', {name: /Error Message/}), + ).toBeInTheDocument(); + }); + + test('should toggle sort direction when same column header is clicked twice', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^Error Messages/}); + fireEvent.click(screen.getByRole('tab', {name: /^Error Messages/})); + + const errorMessageHeader = await screen.findByRole('columnheader', { + name: /Error Message/, + }); + + // Click once to sort ascending + fireEvent.click(errorMessageHeader); + // Click again to toggle to descending + fireEvent.click(errorMessageHeader); + + expect( + screen.getByRole('columnheader', {name: /Error Message/}), + ).toBeInTheDocument(); + }); + }); + + describe('TLS Certificates tab', () => { + test('should show TLS Certificates tab content when clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^TLS Certificates/}); + fireEvent.click(screen.getByRole('tab', {name: /^TLS Certificates/})); + + // Tab should be selected + expect( + screen.getByRole('tab', {name: /^TLS Certificates/}), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/web/pages/reports/__tests__/DetailsPage.test.tsx b/src/web/pages/reports/__tests__/DetailsPage.test.tsx index bfe1810f90..64fd9ceb7b 100644 --- a/src/web/pages/reports/__tests__/DetailsPage.test.tsx +++ b/src/web/pages/reports/__tests__/DetailsPage.test.tsx @@ -4,7 +4,7 @@ */ import {describe, test, expect, testing} from '@gsa/testing'; -import {rendererWith, fireEvent, screen, within} from 'web/testing'; +import {rendererWith, fireEvent, screen, within, waitFor} from 'web/testing'; import {Route, Routes} from 'react-router'; import CollectionCounts from 'gmp/collection/collection-counts'; import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/user'; @@ -87,6 +87,18 @@ const createGmp = () => ({ }, report: { get: testing.fn().mockResolvedValue({data: entity}), + addAssets: testing.fn().mockResolvedValue({}), + removeAssets: testing.fn().mockResolvedValue({}), + download: testing.fn().mockResolvedValue({data: 'report-blob-data'}), + }, + reporttlscertificates: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: { + filter: Filter.fromString(''), + counts: new CollectionCounts(), + }, + }), }, target: { get: testing.fn().mockResolvedValue({data: {}}), @@ -297,4 +309,187 @@ describe('DetailsPage', () => { ).not.toBeInTheDocument(); }); }); + + describe('Asset management', () => { + test('should call addAssets and show success message when Add to Assets is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Add to Assets/); + fireEvent.click(screen.getByTitle(/^Add to Assets/)); + + await screen.findByText(/Report content added to Assets/); + expect(gmp.report.addAssets).toHaveBeenCalled(); + }); + + test('should call removeAssets and show success message when Remove from Assets is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Remove from Assets/); + fireEvent.click(screen.getByTitle(/^Remove from Assets/)); + + await screen.findByText(/Report content removed from Assets/); + expect(gmp.report.removeAssets).toHaveBeenCalled(); + }); + + test('should show error dialog when addAssets fails', async () => { + const gmp = createGmp(); + gmp.report.addAssets = testing + .fn() + .mockRejectedValue(new Error('Permission denied')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Add to Assets/); + fireEvent.click(screen.getByTitle(/^Add to Assets/)); + + await screen.findByText(/Permission denied/); + }); + + test('should show error dialog when removeAssets fails', async () => { + const gmp = createGmp(); + gmp.report.removeAssets = testing + .fn() + .mockRejectedValue(new Error('Server error')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Remove from Assets/); + fireEvent.click(screen.getByTitle(/^Remove from Assets/)); + + await screen.findByText(/Server error/); + }); + }); + + describe('Report download flow', () => { + test('should call report download when download dialog OK is clicked', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect(gmp.report.download).toHaveBeenCalled(); + }); + }); + + test('should close download dialog after successful download', async () => { + const gmp = createGmp(); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect( + screen.queryByRole('heading', { + name: /Compose Content for Scan Report/, + }), + ).not.toBeInTheDocument(); + }); + }); + + test('should show error when download fails', async () => { + const gmp = createGmp(); + gmp.report.download = testing + .fn() + .mockRejectedValue(new Error('Download failed')); + + const {render} = setupRenderer(gmp); + renderPage(render); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await screen.findByText(/Download failed/); + }); + }); + + describe('Sort change', () => { + test('should update column sort when Error Messages column header is clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + // Navigate to Error Messages tab + await screen.findByRole('tab', {name: /^Error Messages/}); + fireEvent.click(screen.getByRole('tab', {name: /^Error Messages/})); + + // Click on the "Error Message" column header to sort + const errorMessageHeader = await screen.findByRole('columnheader', { + name: /Error Message/, + }); + fireEvent.click(errorMessageHeader); + + // The column header should still be in the document after the sort + expect( + screen.getByRole('columnheader', {name: /Error Message/}), + ).toBeInTheDocument(); + }); + + test('should toggle sort direction when same column header is clicked twice', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^Error Messages/}); + fireEvent.click(screen.getByRole('tab', {name: /^Error Messages/})); + + const errorMessageHeader = await screen.findByRole('columnheader', { + name: /Error Message/, + }); + + // Click once to sort ascending + fireEvent.click(errorMessageHeader); + // Click again to toggle to descending + fireEvent.click(errorMessageHeader); + + expect( + screen.getByRole('columnheader', {name: /Error Message/}), + ).toBeInTheDocument(); + }); + }); + + describe('TLS Certificates tab', () => { + test('should show TLS Certificates tab content when clicked', async () => { + const {render} = setupRenderer(); + renderPage(render); + + await screen.findByRole('tab', {name: /^TLS Certificates/}); + fireEvent.click(screen.getByRole('tab', {name: /^TLS Certificates/})); + + // Tab should be selected + expect( + screen.getByRole('tab', {name: /^TLS Certificates/}), + ).toBeInTheDocument(); + }); + }); }); From 862d740f1cf4573de9fa0c9c556f303cc5e9233c Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Thu, 14 May 2026 10:58:24 +0200 Subject: [PATCH 6/8] refactor: reports audit to typescript --- .../reports/AuditReportDetailsContent.jsx | 408 --------------- .../reports/AuditReportDetailsContent.tsx | 463 ++++++++++++++++++ .../pages/reports/AuditReportDetailsPage.tsx | 60 ++- src/web/pages/reports/DetailsPage.tsx | 9 +- ...jsx => AuditReportDetailsContent.test.tsx} | 19 +- .../reports/details/TlsCertificatesTable.tsx | 24 +- 6 files changed, 536 insertions(+), 447 deletions(-) delete mode 100644 src/web/pages/reports/AuditReportDetailsContent.jsx create mode 100644 src/web/pages/reports/AuditReportDetailsContent.tsx rename src/web/pages/reports/__tests__/{AuditReportDetailsContent.test.jsx => AuditReportDetailsContent.test.tsx} (96%) diff --git a/src/web/pages/reports/AuditReportDetailsContent.jsx b/src/web/pages/reports/AuditReportDetailsContent.jsx deleted file mode 100644 index 344481228c..0000000000 --- a/src/web/pages/reports/AuditReportDetailsContent.jsx +++ /dev/null @@ -1,408 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React from 'react'; -import styled from 'styled-components'; -import {TASK_STATUS} from 'gmp/models/task'; -import {isDefined} from 'gmp/utils/identity'; -import StatusBar from 'web/components/bar/StatusBar'; -import ToolBar from 'web/components/bar/Toolbar'; -import DateTime from 'web/components/date/DateTime'; -import ErrorPanel from 'web/components/error/ErrorPanel'; -import {ReportIcon} from 'web/components/icon'; -import Divider from 'web/components/layout/Divider'; -import Layout from 'web/components/layout/Layout'; -import Loading from 'web/components/loading/Loading'; -import Powerfilter from 'web/components/powerfilter/PowerFilter'; -import SectionHeader from 'web/components/section/Header'; -import Section from 'web/components/section/Section'; -import Tab from 'web/components/tab/Tab'; -import TabLayout from 'web/components/tab/TabLayout'; -import TabList from 'web/components/tab/TabList'; -import TabPanel from 'web/components/tab/TabPanel'; -import TabPanels from 'web/components/tab/TabPanels'; -import Tabs from 'web/components/tab/Tabs'; -import TabsContainer from 'web/components/tab/TabsContainer'; -import EntityInfo from 'web/entity/EntityInfo'; -import EntityTags from 'web/entity/Tags'; -import useGmp from 'web/hooks/useGmp'; -import useTranslation from 'web/hooks/useTranslation'; -import AuditThresholdPanel from 'web/pages/reports/details/AuditThresholdPanel'; -import ErrorsTab from 'web/pages/reports/details/ErrorsTab'; -import HostsTab from 'web/pages/reports/details/HostsTab'; -import OperatingSystemsTab from 'web/pages/reports/details/OperatingSystemsTab'; -import ResultsTab from 'web/pages/reports/details/ResultsTab'; -import Summary from 'web/pages/reports/details/Summary'; -import TabTitle from 'web/pages/reports/details/TabTitle'; -import TLSCertificatesTab from 'web/pages/reports/details/TlsCertificatesTab'; -import ToolBarIcons from 'web/pages/reports/details/ToolbarIcons'; -import PropTypes from 'web/utils/PropTypes'; - -const Span = styled.span` - margin-top: 2px; -`; - -const AuditReportDetailsContent = ({ - entity, - errorsCounts, - filters, - hostsCounts, - isLoading = true, - isLoadingFilters = true, - isUpdating = false, - operatingSystemsCounts, - pageFilter, - reportError, - reportFilter, - reportId, - resetFilter, - resultsCounts, - sorting, - showError, - showErrorMessage, - showSuccessMessage, - task, - tlsCertificatesCounts, - onAddToAssetsClick, - onTlsCertificateDownloadClick, - onError, - onFilterChanged, - onFilterCreated, - onFilterDecreaseMinQoDClick, - onFilterEditClick, - onFilterRemoveClick, - onFilterResetClick, - onRemoveFromAssetsClick, - onReportDownloadClick, - onSortChange, - onTagSuccess, - onTargetEditClick, -}) => { - const hasReport = isDefined(entity); - - const report = hasReport ? entity.report : undefined; - - const userTags = hasReport ? report.userTags : undefined; - const userTagsCount = isDefined(userTags) ? userTags.length : 0; - const gmp = useGmp(); - const [_] = useTranslation(); - - const { - errors = {}, - hosts = {}, - operatingSystems = {}, - results = {}, - timestamp, - scan_run_status, - } = report || {}; - - if (!hasReport && isDefined(reportError)) { - return ( - - ); - } - - const threshold = gmp.settings.reportResultsThreshold; - - const showThresholdMessage = - !isLoading && hasReport && results.counts.filtered > threshold; - - const isImport = isDefined(task) && task.isImport(); - const status = isImport ? TASK_STATUS.import : scan_run_status; - const progress = isDefined(task) ? task.progress : 0; - - const showIsLoading = isLoading && !hasReport; - - const showInitialLoading = - isLoading && - !isDefined(reportError) && - !showThresholdMessage && - (!isDefined(results.entities) || results.entities.length === 0); - - const header_title = ( - - {_('Audit Report:')} - {showIsLoading ? ( - {_('Loading')} - ) : ( - - - - - - - )} - - ); - - const header = ( - } title={header_title}> - {hasReport && } - - ); - - return ( - - - - - - - - -
- {showIsLoading ? ( - - ) : ( - - - - {_('Information')} - - - - - - - - - - - - - - - - - - - - - - {hasReport ? ( - - - - - - - - onSortChange('results', sortField) - } - onTargetEditClick={onTargetEditClick} - /> - - - {showInitialLoading ? ( - - ) : showThresholdMessage ? ( - - ) : ( - - onSortChange('hosts', sortField) - } - /> - )} - - - {showInitialLoading ? ( - - ) : showThresholdMessage ? ( - - ) : ( - - onSortChange('os', sortField) - } - /> - )} - - - {showInitialLoading ? ( - - ) : showThresholdMessage ? ( - - ) : ( - - )} - - - - onSortChange('errors', sortField) - } - /> - - - - - - - ) : ( - - )} - - )} -
-
- ); -}; - -AuditReportDetailsContent.propTypes = { - applicationsCounts: PropTypes.counts, - closedCvesCounts: PropTypes.counts, - cvesCounts: PropTypes.counts, - entity: PropTypes.model, - errorsCounts: PropTypes.counts, - filters: PropTypes.array, - hostsCounts: PropTypes.counts, - isLoading: PropTypes.bool, - isLoadingFilters: PropTypes.bool, - isUpdating: PropTypes.bool, - operatingSystemsCounts: PropTypes.counts, - pageFilter: PropTypes.filter, - portsCounts: PropTypes.counts, - reportError: PropTypes.error, - reportFilter: PropTypes.filter, - reportId: PropTypes.id.isRequired, - resetFilter: PropTypes.filter, - resultsCounts: PropTypes.counts, - showError: PropTypes.func.isRequired, - showErrorMessage: PropTypes.func.isRequired, - showSuccessMessage: PropTypes.func.isRequired, - sorting: PropTypes.object, - task: PropTypes.model, - tlsCertificatesCounts: PropTypes.counts, - onAddToAssetsClick: PropTypes.func.isRequired, - onError: PropTypes.func.isRequired, - onFilterChanged: PropTypes.func.isRequired, - onFilterCreated: PropTypes.func.isRequired, - onFilterDecreaseMinQoDClick: PropTypes.func.isRequired, - onFilterEditClick: PropTypes.func.isRequired, - onFilterRemoveClick: PropTypes.func.isRequired, - onFilterResetClick: PropTypes.func.isRequired, - onRemoveFromAssetsClick: PropTypes.func.isRequired, - onReportDownloadClick: PropTypes.func.isRequired, - onSortChange: PropTypes.func.isRequired, - onTagSuccess: PropTypes.func.isRequired, - onTargetEditClick: PropTypes.func.isRequired, - onTlsCertificateDownloadClick: PropTypes.func.isRequired, -}; - -export default AuditReportDetailsContent; diff --git a/src/web/pages/reports/AuditReportDetailsContent.tsx b/src/web/pages/reports/AuditReportDetailsContent.tsx new file mode 100644 index 0000000000..5394d83645 --- /dev/null +++ b/src/web/pages/reports/AuditReportDetailsContent.tsx @@ -0,0 +1,463 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import React from 'react'; +import styled from 'styled-components'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import type AuditReport from 'gmp/models/audit-report'; +import Filter from 'gmp/models/filter'; +import type ReportReport from 'gmp/models/report/report'; +import type ReportTask from 'gmp/models/report/task'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; +import {TASK_STATUS, type TaskStatus} from 'gmp/models/task'; +import {isDefined} from 'gmp/utils/identity'; +import StatusBar from 'web/components/bar/StatusBar'; +import ToolBar from 'web/components/bar/Toolbar'; +import DateTime from 'web/components/date/DateTime'; +import ErrorPanel from 'web/components/error/ErrorPanel'; +import {ReportIcon} from 'web/components/icon'; +import Divider from 'web/components/layout/Divider'; +import Layout from 'web/components/layout/Layout'; +import Loading from 'web/components/loading/Loading'; +import Powerfilter from 'web/components/powerfilter/PowerFilter'; +import SectionHeader from 'web/components/section/Header'; +import Section from 'web/components/section/Section'; +import Tab from 'web/components/tab/Tab'; +import TabLayout from 'web/components/tab/TabLayout'; +import TabList from 'web/components/tab/TabList'; +import TabPanel from 'web/components/tab/TabPanel'; +import TabPanels from 'web/components/tab/TabPanels'; +import Tabs from 'web/components/tab/Tabs'; +import TabsContainer from 'web/components/tab/TabsContainer'; +import EntityInfo from 'web/entity/EntityInfo'; +import EntityTags from 'web/entity/Tags'; +import useGmp from 'web/hooks/useGmp'; +import useTranslation from 'web/hooks/useTranslation'; +import AuditThresholdPanel from 'web/pages/reports/details/AuditThresholdPanel'; +import ErrorsTab from 'web/pages/reports/details/ErrorsTab'; +import HostsTab from 'web/pages/reports/details/HostsTab'; +import OperatingSystemsTab from 'web/pages/reports/details/OperatingSystemsTab'; +import ResultsTab from 'web/pages/reports/details/ResultsTab'; +import Summary from 'web/pages/reports/details/Summary'; +import TabTitle from 'web/pages/reports/details/TabTitle'; +import TLSCertificatesTab from 'web/pages/reports/details/TlsCertificatesTab'; +import ToolBarIcons from 'web/pages/reports/details/ToolbarIcons'; + +interface SortingEntry { + sortField: string; + sortReverse: boolean; +} + +interface SortingData { + results: SortingEntry; + hosts: SortingEntry; + os: SortingEntry; + tlscerts: SortingEntry; + errors: SortingEntry; +} + +interface CountsLike { + all: number; + filtered: number; +} + +type MaybeCounts = CollectionCounts | CountsLike; + +interface AuditReportDetailsContentProps { + entity?: AuditReport; + errorsCounts?: MaybeCounts; + filters?: Filter[]; + hostsCounts?: MaybeCounts; + isLoading?: boolean; + isLoadingFilters?: boolean; + isUpdating?: boolean; + operatingSystemsCounts?: MaybeCounts; + pageFilter?: Filter; + reportError?: Error; + reportFilter?: Filter; + reportId: string; + resetFilter?: Filter; + resultsCounts?: MaybeCounts; + showError: (...args: unknown[]) => void; + showErrorMessage: (message: string) => void; + showSuccessMessage: (message: string) => void; + sorting: SortingData; + task?: ReportTask; + tlsCertificatesCounts?: MaybeCounts; + onAddToAssetsClick: () => void; + onError: (error: Error) => void; + onFilterChanged: (filter: Filter) => void; + onFilterCreated: (filter: Filter) => void; + onFilterDecreaseMinQoDClick: () => void; + onFilterEditClick: () => void; + onFilterRemoveClick: () => void; + onFilterResetClick: () => void; + onRemoveFromAssetsClick: () => void; + onReportDownloadClick: () => void; + onSortChange: (type: string, sortField: string) => void; + onTagSuccess: () => void; + onTargetEditClick: () => void; + onTlsCertificateDownloadClick: (entity: ReportTLSCertificate) => void; +} + +const Span = styled.span` + margin-top: 2px; +`; + +const renderWithThreshold = ( + entityType: string, + config: { + showInitialLoading: boolean; + showThresholdMessage: boolean; + isUpdating: boolean; + threshold: number; + reportFilter: Filter; + onFilterChanged: (filter: Filter) => void; + onFilterEditClick: () => void; + }, + content: React.ReactNode, +): React.ReactNode => { + if (config.showInitialLoading) { + return ; + } + if (config.showThresholdMessage) { + return ( + + ); + } + return content; +}; + +const AuditReportDetailsContent = ({ + entity, + errorsCounts, + filters, + hostsCounts, + isLoading = true, + isLoadingFilters = true, + isUpdating = false, + operatingSystemsCounts, + pageFilter, + reportError, + reportFilter, + reportId, + resetFilter, + resultsCounts, + sorting, + showError, + showErrorMessage, + showSuccessMessage, + task, + tlsCertificatesCounts, + onAddToAssetsClick, + onTlsCertificateDownloadClick, + onError, + onFilterChanged, + onFilterCreated, + onFilterDecreaseMinQoDClick, + onFilterEditClick, + onFilterRemoveClick, + onFilterResetClick, + onRemoveFromAssetsClick, + onReportDownloadClick, + onSortChange, + onTagSuccess, + onTargetEditClick, +}: AuditReportDetailsContentProps) => { + const hasReport = isDefined(entity); + + const report = hasReport ? entity.report : undefined; + const hasReportData = hasReport && isDefined(report); + + const userTags = hasReport ? report?.userTags : undefined; + const userTagsCount = isDefined(userTags) ? userTags.length : 0; + const gmp = useGmp(); + const [_] = useTranslation(); + + const errors = report?.errors; + const hosts = report?.hosts; + const operatingSystems = report?.operatingSystems; + const results = report?.results; + const timestamp = report?.timestamp; + const scan_run_status = report?.scan_run_status; + + if (!hasReport && isDefined(reportError)) { + return ( + + ); + } + + const threshold = gmp.settings.reportResultsThreshold; + + const showThresholdMessage = + !isLoading && + hasReportData && + isDefined(results?.counts) && + results.counts.filtered > threshold; + + const isImport = isDefined(task) && task.isImport(); + const status: TaskStatus = isImport + ? TASK_STATUS.import + : ((scan_run_status as TaskStatus) ?? TASK_STATUS.unknown); + const progress = task?.progress ?? 0; + + const effectiveReportFilter = reportFilter ?? pageFilter ?? new Filter(); + + const showIsLoading = isLoading && !hasReport; + + const resultEntities = results?.entities; + const reportResultsCounts = + resultsCounts instanceof CollectionCounts + ? resultsCounts + : isDefined(resultsCounts) + ? new CollectionCounts(resultsCounts) + : undefined; + + const showInitialLoading = + isLoading && + !isDefined(reportError) && + !showThresholdMessage && + (!isDefined(resultEntities) || resultEntities.length === 0); + + const thresholdConfig = { + showInitialLoading, + showThresholdMessage, + isUpdating, + threshold, + reportFilter: effectiveReportFilter, + onFilterChanged, + onFilterEditClick, + }; + + const tabDefs: Array<{ + key: string; + title: React.ReactNode; + panel: React.ReactNode; + }> = [ + { + key: 'information', + title: _('Information'), + panel: ( + + ), + }, + { + key: 'results', + title: , + panel: ( + + ), + }, + { + key: 'hosts', + title: , + panel: renderWithThreshold( + _('Hosts'), + thresholdConfig, + onSortChange('hosts', sortField)} + />, + ), + }, + { + key: 'os', + title: ( + + ), + panel: renderWithThreshold( + _('Operating Systems'), + thresholdConfig, + onSortChange('os', sortField)} + />, + ), + }, + { + key: 'tlscerts', + title: ( + + ), + panel: renderWithThreshold( + _('TLS Certificates'), + thresholdConfig, + , + ), + }, + { + key: 'errors', + title: , + panel: ( + + onSortChange('errors', sortField) + } + /> + ), + }, + { + key: 'usertags', + title: , + panel: ( + + ), + }, + ]; + + const header_title = ( + + {_('Audit Report:')} + {showIsLoading ? ( + {_('Loading')} + ) : ( + + + + + + + )} + + ); + + const header = ( + } title={header_title}> + {hasReport && } + + ); + + return ( + + + + + + + + +
+ {showIsLoading ? ( + + ) : ( + + + + {tabDefs.map(tab => ( + {tab.title} + ))} + + + + {hasReportData ? ( + + + {tabDefs.map(tab => ( + {tab.panel} + ))} + + + ) : ( + + )} + + )} +
+
+ ); +}; + +export default AuditReportDetailsContent; diff --git a/src/web/pages/reports/AuditReportDetailsPage.tsx b/src/web/pages/reports/AuditReportDetailsPage.tsx index eb92e409a7..9a34ee1230 100644 --- a/src/web/pages/reports/AuditReportDetailsPage.tsx +++ b/src/web/pages/reports/AuditReportDetailsPage.tsx @@ -6,23 +6,26 @@ import {useCallback, useEffect, useState} from 'react'; import {useQueryClient} from '@tanstack/react-query'; import {useParams} from 'react-router'; +import type Response from 'gmp/http/response'; +import type {XmlMeta} from 'gmp/http/transform/fast-xml'; import logger from 'gmp/log'; -import Filter, {RESET_FILTER} from 'gmp/models/filter'; import type AuditReport from 'gmp/models/audit-report'; -import {isActive} from 'gmp/models/task'; +import Filter, {RESET_FILTER} from 'gmp/models/filter'; +import type ReportTLSCertificate from 'gmp/models/report/tls-certificate'; +import {isActive, type TaskStatus} from 'gmp/models/task'; import {isDefined} from 'gmp/utils/identity'; import Download from 'web/components/form/Download'; import useDownload from 'web/components/form/useDownload'; import PageTitle from 'web/components/layout/PageTitle'; import DialogNotification from 'web/components/notification/DialogNotification'; import useDialogNotification from 'web/components/notification/useDialogNotification'; +import useGetReportTlsCertificates from 'web/hooks/use-query/report-tls-certificates'; import { useGetReportConfigs, useGetReportExportFileName, useGetReportFormats, useGetResultsFilters, } from 'web/hooks/use-query/reports'; -import useGetReportTlsCertificates from 'web/hooks/use-query/report-tls-certificates'; import useGmp from 'web/hooks/useGmp'; import usePageFilter from 'web/hooks/usePageFilter'; import useTranslation from 'web/hooks/useTranslation'; @@ -31,9 +34,9 @@ import Page from 'web/pages/reports/AuditReportDetailsContent'; import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; +import useGetEntity from 'web/queries/useGetEntity'; import {create_pem_certificate} from 'web/utils/Cert'; import {generateFilename} from 'web/utils/Render'; -import useGetEntity from 'web/queries/useGetEntity'; interface SortState { sortField: string; @@ -65,6 +68,19 @@ interface ReportTargetRef { id: string; } +interface AuditReportCommand { + get: ( + params: {id: string}, + options?: {filter?: string; details?: boolean}, + ) => Promise>; + addAssets: (params: {id: string; filter?: string}) => Promise; + removeAssets: (params: {id: string; filter?: string}) => Promise; + download: ( + params: {id: string}, + options: {reportFormatId: string; filter: Filter}, + ) => Promise<{data: string | ArrayBuffer}>; +} + const log = logger.getLogger('web.pages.reports.AuditReportDetailsPage'); const DEFAULT_FILTER = Filter.fromString( @@ -113,12 +129,14 @@ const useGetAuditReport = ({ refetchInterval?: number | false | ((data?: AuditReport) => number | false); }) => { const gmp = useGmp(); + const auditreport = (gmp as unknown as {auditreport: AuditReportCommand}) + .auditreport; const threshold = gmp.settings.reportResultsThreshold; const filterString = filter?.toFilterString(); return useGetEntity({ gmpMethod: async ({id}) => { - const lightResponse = await gmp.auditreport.get( + const lightResponse = await auditreport.get( {id}, {filter: filterString, details: false}, ); @@ -129,10 +147,7 @@ const useGetAuditReport = ({ lightReport.report.results.counts.filtered < threshold; if (needsFullReport) { - return gmp.auditreport.get( - {id}, - {filter: filterString, details: true}, - ); + return auditreport.get({id}, {filter: filterString, details: true}); } return lightResponse; @@ -148,6 +163,8 @@ const AuditReportDetailsPage = () => { const [_] = useTranslation(); const {id: reportId = ''} = useParams<{id: string}>(); const gmp = useGmp(); + const auditreport = (gmp as unknown as {auditreport: AuditReportCommand}) + .auditreport; const queryClient = useQueryClient(); const username = useUserName(); @@ -180,7 +197,7 @@ const AuditReportDetailsPage = () => { if (!isDefined(entity) || !isDefined(entity.report)) { return false as const; } - return isActive(entity.report.scan_run_status) + return isActive(entity.report.scan_run_status as TaskStatus) ? gmp.settings.reloadIntervalActive : false; }, @@ -261,7 +278,8 @@ const AuditReportDetailsPage = () => { const hostsCounts = report?.hosts?.counts; const operatingSystemsCounts = report?.operatingSystems?.counts; const tlsCertificatesCounts = - reportTlsCertificatesData?.entitiesCounts ?? report?.tlsCertificates?.counts; + reportTlsCertificatesData?.entitiesCounts ?? + report?.tlsCertificates?.counts; const errorsCounts = report?.errors?.counts; const threshold = gmp.settings.reportResultsThreshold; @@ -289,7 +307,7 @@ const AuditReportDetailsPage = () => { const handleAddToAssets = useCallback(async () => { if (!entity?.id) return; try { - await gmp.auditreport.addAssets({ + await auditreport.addAssets({ id: entity.id, filter: reportFilter?.toFilterString(), }); @@ -305,7 +323,7 @@ const AuditReportDetailsPage = () => { } }, [ entity, - gmp, + auditreport, reportFilter, showSuccessMessage, showError, @@ -316,7 +334,7 @@ const AuditReportDetailsPage = () => { const handleRemoveFromAssets = useCallback(async () => { if (!entity?.id) return; try { - await gmp.auditreport.removeAssets({ + await auditreport.removeAssets({ id: entity.id, filter: reportFilter?.toFilterString(), }); @@ -328,7 +346,7 @@ const AuditReportDetailsPage = () => { } }, [ entity, - gmp, + auditreport, reportFilter, showSuccessMessage, showError, @@ -387,7 +405,7 @@ const AuditReportDetailsPage = () => { : 'unknown'; try { - const response = await gmp.auditreport.download( + const response = await auditreport.download( {id: entity.id as string}, { reportFormatId, @@ -416,7 +434,8 @@ const AuditReportDetailsPage = () => { }, [ entity, - gmp, + auditreport, + gmp.user, handleDownload, reportComposerDefaults, reportExportFileName, @@ -428,7 +447,8 @@ const AuditReportDetailsPage = () => { ); const handleTlsCertificateDownload = useCallback( - (cert: {data: string; serial: string}) => { + (cert: ReportTLSCertificate) => { + if (!cert.data || !cert.serial) return; handleDownload({ filename: 'tls-cert-' + cert.serial + '.pem', mimetype: 'application/x-x509-ca-cert', @@ -497,7 +517,6 @@ const AuditReportDetailsPage = () => { {({edit}) => ( { onTagSuccess={handleChanged} onTargetEditClick={async () => { const response = await loadTarget(); - if (response) edit(response.data); + if (response) void edit(response.data); }} onTlsCertificateDownloadClick={handleTlsCertificateDownload} /> @@ -541,7 +560,6 @@ const AuditReportDetailsPage = () => { {showFilterDialog && reportFilter && ( { const cvesCounts = report?.cves?.counts; const closedCvesCounts = report?.closedCves?.counts; const tlsCertificatesCounts = - reportTlsCertificatesData?.entitiesCounts ?? report?.tlsCertificates?.counts; + reportTlsCertificatesData?.entitiesCounts ?? + report?.tlsCertificates?.counts; const errorsCounts = report?.errors?.counts; const threshold = gmp.settings.reportResultsThreshold; @@ -408,7 +410,8 @@ const ReportDetailsPage = () => { ); const handleTlsCertificateDownload = useCallback( - (cert: {data: string; serial: string}) => { + (cert: ReportTLSCertificate) => { + if (!cert.data || !cert.serial) return; handleDownload({ filename: 'tls-cert-' + cert.serial + '.pem', mimetype: 'application/x-x509-ca-cert', diff --git a/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx b/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.tsx similarity index 96% rename from src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx rename to src/web/pages/reports/__tests__/AuditReportDetailsContent.test.tsx index 2e87a845c8..dc9dc63690 100644 --- a/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.jsx +++ b/src/web/pages/reports/__tests__/AuditReportDetailsContent.test.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; import {describe, test, expect, testing} from '@gsa/testing'; import {fireEvent, rendererWith, screen, within} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; import Filter from 'gmp/models/filter'; import {createSession} from 'gmp/testing'; import {getMockAuditReport} from 'web/pages/reports/__fixtures__/MockAuditReport'; -import AuditReportDetailsContent from 'web/pages/reports/AuditReportDetailsContent.jsx'; +import AuditReportDetailsContent from 'web/pages/reports/AuditReportDetailsContent'; const filter = Filter.fromString( 'apply_overrides=0 compliance_levels=ynui rows=10 min_qod=70 first=1 sort=compliant', @@ -70,26 +70,27 @@ const renderContent = ( const {entity} = getMockAuditReport(); const gmp = createGmp({reportResultsThreshold}); const {render} = rendererWith({gmp, capabilities: true, router: true}); + const reportId = entity.report?.id ?? '1234'; render( void; } -interface RowProps { +interface RowProps extends RowComponentProps { actions?: boolean; - entity: ReportTLSCertificate; links?: boolean; onTlsCertificateDownloadClick?: (entity: ReportTLSCertificate) => void; - onToggleDetailsClick?: (entity: ReportTLSCertificate, id?: string) => void; + onToggleDetailsClick?: (entity: Model, id?: string) => void; } interface ColumnsProps { @@ -181,11 +185,14 @@ const Row = ({ onTlsCertificateDownloadClick, onToggleDetailsClick, }: RowProps) => { + const tlsEntity = entity as unknown as ReportTLSCertificate; const columns = getColumns({ actions, links, onTlsCertificateDownloadClick, - onToggleDetailsClick, + onToggleDetailsClick: onToggleDetailsClick as + | ((entity: ReportTLSCertificate, id?: string) => void) + | undefined, }); return ( @@ -195,14 +202,19 @@ const Row = ({ key={column.key} align={column.align === 'center' ? ['center', 'center'] : undefined} > - {column.render(entity)} + {column.render(tlsEntity)} ))} ); }; -export default createEntitiesTable({ +export default createEntitiesTable< + Model, + FooterComponentProps, + HeaderProps, + RowProps +>({ header: Header, emptyTitle: _l('No TLS Certificates available'), row: Row, From 506c8b770ba833614c91f7e5d465bbf09d319850 Mon Sep 17 00:00:00 2001 From: daniele-mng Date: Thu, 14 May 2026 12:04:54 +0200 Subject: [PATCH 7/8] cleanup redudant code in pages reports --- .../reports/AuditReportDetailsContent.tsx | 17 ++-- src/web/pages/reports/DetailsContent.tsx | 2 - .../details/ContainerScanningResultsTab.tsx | 8 +- .../reports/details/ResultsTabContent.tsx | 20 ----- .../__tests__/ResultsTabContent.test.tsx | 84 +------------------ 5 files changed, 10 insertions(+), 121 deletions(-) diff --git a/src/web/pages/reports/AuditReportDetailsContent.tsx b/src/web/pages/reports/AuditReportDetailsContent.tsx index 5394d83645..abff84dfb1 100644 --- a/src/web/pages/reports/AuditReportDetailsContent.tsx +++ b/src/web/pages/reports/AuditReportDetailsContent.tsx @@ -58,34 +58,27 @@ interface SortingData { errors: SortingEntry; } -interface CountsLike { - all: number; - filtered: number; -} - -type MaybeCounts = CollectionCounts | CountsLike; - interface AuditReportDetailsContentProps { entity?: AuditReport; - errorsCounts?: MaybeCounts; + errorsCounts?: CollectionCounts; filters?: Filter[]; - hostsCounts?: MaybeCounts; + hostsCounts?: CollectionCounts; isLoading?: boolean; isLoadingFilters?: boolean; isUpdating?: boolean; - operatingSystemsCounts?: MaybeCounts; + operatingSystemsCounts?: CollectionCounts; pageFilter?: Filter; reportError?: Error; reportFilter?: Filter; reportId: string; resetFilter?: Filter; - resultsCounts?: MaybeCounts; + resultsCounts?: CollectionCounts; showError: (...args: unknown[]) => void; showErrorMessage: (message: string) => void; showSuccessMessage: (message: string) => void; sorting: SortingData; task?: ReportTask; - tlsCertificatesCounts?: MaybeCounts; + tlsCertificatesCounts?: CollectionCounts; onAddToAssetsClick: () => void; onError: (error: Error) => void; onFilterChanged: (filter: Filter) => void; diff --git a/src/web/pages/reports/DetailsContent.tsx b/src/web/pages/reports/DetailsContent.tsx index 52c7a23b01..4a62b436ee 100644 --- a/src/web/pages/reports/DetailsContent.tsx +++ b/src/web/pages/reports/DetailsContent.tsx @@ -310,12 +310,10 @@ const PageContent = ({ ; - } - if (isError) { return ( ; + } + if (!data) { return null; } diff --git a/src/web/pages/reports/details/ResultsTabContent.tsx b/src/web/pages/reports/details/ResultsTabContent.tsx index 7ac705dd5f..17eef46b38 100644 --- a/src/web/pages/reports/details/ResultsTabContent.tsx +++ b/src/web/pages/reports/details/ResultsTabContent.tsx @@ -5,24 +5,14 @@ import type CollectionCounts from 'gmp/collection/collection-counts'; import type Filter from 'gmp/models/filter'; -import type Result from 'gmp/models/result'; import type {TaskStatus} from 'gmp/models/task'; -import {isDefined} from 'gmp/utils/identity'; -import Loading from 'web/components/loading/Loading'; import ContainerScanningResultsTab from 'web/pages/reports/details/ContainerScanningResultsTab'; import EmptyReport from 'web/pages/reports/details/EmptyReport'; import EmptyResultsReport from 'web/pages/reports/details/EmptyResultsReport'; import ResultsTab from 'web/pages/reports/details/ResultsTab'; -interface ResultsData { - counts?: CollectionCounts; - entities?: Result[]; -} - interface ResultsTabContentProps { - results: ResultsData; isContainerScanning: boolean; - isLoading?: boolean; hasTarget: boolean; progress: number; reportFilter: Filter; @@ -38,9 +28,7 @@ interface ResultsTabContentProps { } const ResultsTabContent = ({ - results, isContainerScanning, - isLoading = false, hasTarget, progress, reportFilter, @@ -100,14 +88,6 @@ const ResultsTabContent = ({ ); } - // Loading state for regular scanning - if ( - isLoading && - (!isDefined(results.entities) || results.entities.length === 0) - ) { - return ; - } - // Default: regular scanning results table return ( { { }); test('should render EmptyReport when container scanning has no results at all', () => { - const results = { - entities: [], - counts: new CollectionCounts({ - filtered: 0, - all: 0, - first: 1, - rows: 10, - }), - }; - const reportResultsCounts = new CollectionCounts({ filtered: 0, all: 0, @@ -111,12 +99,10 @@ describe('ResultsTabContent', () => { { }); test('should render EmptyResultsReport when container scanning has results but filtered to 0', () => { - const results = { - entities: [], - counts: new CollectionCounts({ - filtered: 0, - all: 5, - first: 1, - rows: 10, - }), - }; - const reportResultsCounts = new CollectionCounts({ filtered: 0, all: 5, @@ -157,12 +133,10 @@ describe('ResultsTabContent', () => { { { expect(screen.getByTestId('loading')).toBeInTheDocument(); }); - test('should render Loading when isLoading is true and no entities', () => { - const results = { - entities: undefined, - counts: undefined, - }; - + test('should render Loading while ResultsTab fetches results', () => { const gmp = createGmp(); const {render} = rendererWith({gmp}); @@ -221,46 +188,9 @@ describe('ResultsTabContent', () => { , - ); - - expect(screen.getByTestId('loading')).toBeInTheDocument(); - }); - - test('should render Loading when isLoading is true and entities array is empty', () => { - const results = { - entities: [], - counts: new CollectionCounts({ - filtered: 0, - all: 0, - first: 1, - rows: 10, - }), - }; - - const gmp = createGmp(); - - const {render} = rendererWith({gmp}); - - render( - { describe('Filter callbacks', () => { test('should pass filter callbacks to EmptyResultsReport for container scanning', () => { - const results = { - entities: [], - counts: new CollectionCounts({ - filtered: 0, - all: 5, - first: 1, - rows: 10, - }), - }; - const reportResultsCounts = new CollectionCounts({ filtered: 0, all: 5, @@ -306,12 +226,10 @@ describe('ResultsTabContent', () => { Date: Fri, 15 May 2026 08:23:38 +0200 Subject: [PATCH 8/8] translatsions --- public/locales/gsa-de.json | 1 + public/locales/gsa-en.json | 1 + public/locales/gsa-zh_CN.json | 1 + public/locales/gsa-zh_TW.json | 1 + 4 files changed, 4 insertions(+) diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index 21cb776465..4399b62ed5 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -810,6 +810,7 @@ "Error while loading Ports for Report {{reportId}}": "Fehler beim Laden der Ports für Bericht {{reportId}}", "Error while loading Report {{reportId}}": "Fehler beim Laden des Berichts {{reportId}}", "Error while loading Results for Report {{reportId}}": "Fehler beim Laden von Ergebnissen des Berichts {{reportId}}", + "Error while loading TLS Certificates for Report {{reportId}}": "Fehler beim Laden der TLS-Zertifikate für Bericht {{reportId}}", "Error: Unknown Performance Report": "Fehler: Unbekannter Leistungsbericht", "ESXi": "ESXi", "ESXi authentication was successful": "ESXi-Authentifizierung war erfolgreich", diff --git a/public/locales/gsa-en.json b/public/locales/gsa-en.json index 85dc68b277..3c274baff3 100644 --- a/public/locales/gsa-en.json +++ b/public/locales/gsa-en.json @@ -810,6 +810,7 @@ "Error while loading Ports for Report {{reportId}}": "", "Error while loading Report {{reportId}}": "Error while loading Report {{reportId}}", "Error while loading Results for Report {{reportId}}": "Error while loading Results for Report {{reportId}}", + "Error while loading TLS Certificates for Report {{reportId}}": "", "Error: Unknown Performance Report": "Error: Unknown Performance Report", "ESXi": "ESXi", "ESXi authentication was successful": "ESXi authentication was successful", diff --git a/public/locales/gsa-zh_CN.json b/public/locales/gsa-zh_CN.json index 8cee8cbcd4..ffdb68ef1d 100644 --- a/public/locales/gsa-zh_CN.json +++ b/public/locales/gsa-zh_CN.json @@ -810,6 +810,7 @@ "Error while loading Ports for Report {{reportId}}": "", "Error while loading Report {{reportId}}": "加载报告 {{reportId}} 时出错", "Error while loading Results for Report {{reportId}}": "加载报告 {{reportId}} 的结果时出错", + "Error while loading TLS Certificates for Report {{reportId}}": "", "Error: Unknown Performance Report": "错误:未知的性能报告", "ESXi": "ESXi", "ESXi authentication was successful": "ESXi身份验证成功", diff --git a/public/locales/gsa-zh_TW.json b/public/locales/gsa-zh_TW.json index 3088580f01..194cbaf926 100644 --- a/public/locales/gsa-zh_TW.json +++ b/public/locales/gsa-zh_TW.json @@ -810,6 +810,7 @@ "Error while loading Ports for Report {{reportId}}": "", "Error while loading Report {{reportId}}": "", "Error while loading Results for Report {{reportId}}": "", + "Error while loading TLS Certificates for Report {{reportId}}": "", "Error: Unknown Performance Report": "", "ESXi": "", "ESXi authentication was successful": "編輯驗證",