diff --git a/app/components/ak-breadcrumbs/auto-trail/index.hbs b/app/components/ak-breadcrumbs/auto-trail/index.hbs index 26842e85e0..21e2f6902b 100644 --- a/app/components/ak-breadcrumbs/auto-trail/index.hbs +++ b/app/components/ak-breadcrumbs/auto-trail/index.hbs @@ -3,6 +3,7 @@ @alignItems='center' @tag='ul' data-test-ak-breadcrumbs-auto-trail-container + data-test-cy='ak-breadcrumbs-auto-trail-container' ...attributes > {{#each this.breadcrumbsService.breadcrumbItems as |item|}} diff --git a/app/components/ak-pagination/index.hbs b/app/components/ak-pagination/index.hbs index a78470546f..963a208888 100644 --- a/app/components/ak-pagination/index.hbs +++ b/app/components/ak-pagination/index.hbs @@ -15,9 +15,9 @@ @renderInPlace={{true}} @verticalPosition={{or @paginationSelectOptionsVertPosition 'above'}} @triggerClass={{this.classes.selectClass}} + aria-label='pagination item per page options' {{style width='50px'}} data-test-pagination-select - data-test-cy='paginationItemPerPageOptions' as |aks| > {{aks.label}} @@ -26,7 +26,11 @@ {{#if @totalItems}}
- + {{@startItemIdx}}-{{@endItemIdx}} of {{@totalItems}} @@ -49,6 +53,7 @@ @leftIconClass={{this.classes.prevButtonIconClass}} @color='neutral' @variant='outlined' + aria-label='pagination previous button' local-class='ak-pagination-prev-button' {{on 'click' (or @prevAction this.noop)}} data-test-pagination-prev-btn @@ -77,6 +82,7 @@ @color='neutral' @variant='outlined' @rightIconClass={{this.classes.nextButtonIconClass}} + aria-label='pagination next button' local-class='ak-pagination-next-button' {{on 'click' (or @nextAction this.noop)}} data-test-pagination-next-btn diff --git a/app/components/sbom/app-list/action/index.hbs b/app/components/sbom/app-list/action/index.hbs index e068f25b3e..0dba9e5b99 100644 --- a/app/components/sbom/app-list/action/index.hbs +++ b/app/components/sbom/app-list/action/index.hbs @@ -2,6 +2,7 @@ data-test-sbomApp-actionBtn {{on 'click' this.handleOpenMenu}} disabled={{this.hasNoSbomScan}} + data-test-cy='sbomApp-actionBtn' > @@ -22,6 +23,7 @@ @model={{it.model}} @onClick={{it.onClick}} @divider={{it.divider}} + aria-label='sbom app action menu item' as |li| > diff --git a/app/components/sbom/app-list/header/index.hbs b/app/components/sbom/app-list/header/index.hbs index c678a5cebf..2bfb549a20 100644 --- a/app/components/sbom/app-list/header/index.hbs +++ b/app/components/sbom/app-list/header/index.hbs @@ -27,6 +27,7 @@ {{on 'input' this.onSearchQueryChange}} local-class='search-package-name-input' data-test-sbomApp-searchInput + data-test-cy='sbomApp-searchInput' > <:leftAdornment> @@ -64,6 +65,7 @@ {{style width='auto'}} class='select-platform-class' data-test-select-platform-container + data-test-cy='sbom-platform-filterSelect' as |platformObject| > diff --git a/app/components/sbom/app-list/index.hbs b/app/components/sbom/app-list/index.hbs index 6c2cc46056..c49a165da7 100644 --- a/app/components/sbom/app-list/index.hbs +++ b/app/components/sbom/app-list/index.hbs @@ -34,6 +34,7 @@ @direction='column' @alignItems='center' @spacing='1' + data-test-cy='sbomApp-emptyTextContainer' > {{t 'sbomModule.sbomAppEmptyText.title'}} @@ -53,6 +54,7 @@ <:default> diff --git a/app/components/sbom/app-scan/list/index.hbs b/app/components/sbom/app-scan/list/index.hbs index 99aab37089..36b899460f 100644 --- a/app/components/sbom/app-scan/list/index.hbs +++ b/app/components/sbom/app-scan/list/index.hbs @@ -50,6 +50,7 @@ {{style cursor='pointer'}} @onClick={{this.handleSbomScanClick}} data-test-sbomScan-row + data-test-cy='sbomScan-row' as |r| > diff --git a/app/components/sbom/app-scan/list/view-report/index.hbs b/app/components/sbom/app-scan/list/view-report/index.hbs index d25f588e36..c085b61e45 100644 --- a/app/components/sbom/app-scan/list/view-report/index.hbs +++ b/app/components/sbom/app-scan/list/view-report/index.hbs @@ -2,6 +2,7 @@ data-test-sbomScan-viewReportBtn disabled={{this.disableViewReport}} {{on 'click' (fn @onViewReportClick @sbomFile)}} + data-test-cy='sbomScan-viewReportBtn' > \ No newline at end of file diff --git a/app/components/sbom/component-details/index.hbs b/app/components/sbom/component-details/index.hbs index c4836f48f8..3e2677d1f3 100644 --- a/app/components/sbom/component-details/index.hbs +++ b/app/components/sbom/component-details/index.hbs @@ -20,6 +20,7 @@ @route={{item.route}} @currentWhen={{item.activeRoutes}} data-test-sbomComponentDetails-tab='{{item.id}}' + data-test-cy='sbom-component-details-tab-"{{item.id}}"' > {{item.label}} diff --git a/app/components/sbom/component-details/summary/index.hbs b/app/components/sbom/component-details/summary/index.hbs index 3a1c45a541..667c52e5e8 100644 --- a/app/components/sbom/component-details/summary/index.hbs +++ b/app/components/sbom/component-details/summary/index.hbs @@ -3,6 +3,7 @@ {{cs.label}} diff --git a/app/components/sbom/component-status/index.hbs b/app/components/sbom/component-status/index.hbs index 66f3268354..56f1edd66c 100644 --- a/app/components/sbom/component-status/index.hbs +++ b/app/components/sbom/component-status/index.hbs @@ -2,6 +2,7 @@ {{#each this.componentStatus as |status|}} diff --git a/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs b/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs index f2051b18e5..b9d87201f6 100644 --- a/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs +++ b/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs @@ -12,6 +12,7 @@ local-class='cursor-pointer' {{on 'click' this.handleClick}} data-test-sbom-scanDetails-dependencyTypeHeader-icon + data-test-cy='sbom-dependency-type-filter-icon' /> @@ -55,6 +57,7 @@ diff --git a/app/components/sbom/scan-details/component-list/type-header/index.hbs b/app/components/sbom/scan-details/component-list/type-header/index.hbs index 2b166c408d..5d53c02f34 100644 --- a/app/components/sbom/scan-details/component-list/type-header/index.hbs +++ b/app/components/sbom/scan-details/component-list/type-header/index.hbs @@ -1,9 +1,16 @@ - + + @@ -50,11 +59,13 @@ class='py-1 pl-2' {{on 'click' (fn this.selectComponentType types.value)}} data-test-sbom-scanDetails-componentTypeHeader-option + data-test-cy='component-type-filter-option' > {{/if}} @@ -132,6 +133,7 @@ }}' @underline='hover' data-test-component-tree-nodeLabel + data-test-cy='component-tree-node-label' > {{node.label}} diff --git a/app/components/sbom/scan-details/file-scan-summary/index.hbs b/app/components/sbom/scan-details/file-scan-summary/index.hbs index b6b03edb5c..6c5e6a48db 100644 --- a/app/components/sbom/scan-details/file-scan-summary/index.hbs +++ b/app/components/sbom/scan-details/file-scan-summary/index.hbs @@ -3,6 +3,7 @@ @@ -93,6 +94,7 @@ {{on 'click' (perform this.handleGenerateReport @reportDetails.type)}} @loading={{this.handleGenerateReport.isRunning}} data-test-sbomReportList-reportGenerateBtn + data-test-cy='sbomReportList-reportGenerateBtn' > {{t 'generateReport'}} diff --git a/app/components/sbom/summary-header/index.hbs b/app/components/sbom/summary-header/index.hbs index 97ac731bee..cd2ba662ba 100644 --- a/app/components/sbom/summary-header/index.hbs +++ b/app/components/sbom/summary-header/index.hbs @@ -19,6 +19,7 @@ {{#if (has-block 'collapsibleContent')}} > + */ + getAppTableRows() { + return cy + .findAllByTestId(/^sbomApp-row-/, DEFAULT_ASSERT_OPTS) + .as('sbomAppRows'); + } + + /** + * @name checkAndWaitForAppLoadingView + * @description Checks and waits for the app loading view to be loaded + * @returns void + */ + checkAndWaitForAppLoadingView() { + cy.findByTestId('sbomApp-emptyLoadingView-loading').should('exist'); + + cy.findByTestId( + 'sbomApp-emptyLoadingView-loading', + DEFAULT_ASSERT_OPTS + ).should('not.exist'); + } + + /** + * @name openPlatformFilter + * @description Opens the platform filter dropdown + * @returns void + */ + openPlatformFilter() { + cy.findByTestId('sbom-platform-filterSelect') + .findByRole('combobox') + .should('exist') + .click({ force: true }); + } + + /** + * @name selectPlatform + * @description Selects the platform from the platform filter dropdown + * @param platform - The platform to select + * @returns void + */ + selectPlatform(platform: string) { + return cy + .findByRole('option', { name: platform }) + .should('be.visible') + .click({ force: true }); + } + + /** + * @name validateSbomAppRowsByPlatform + * @description Validates the app table rows by platform + * @param platform - The platform to validate + * @returns void + */ + validateSbomAppRowsByPlatform(platform: 'android' | 'apple') { + return this.getAppTableRows() + .should('have.length.greaterThan', 0) + .each((row) => { + cy.wrap(row).within(() => { + cy.findByTestId(`app-platform-${platform}`).should('exist'); + }); + }); + } + + /** + * @name downloadSBOMAppReport + * @description Clicks on the download button for the specified report type (pdf or cyclonedx json file) in the past SBOM analyses view to trigger the download of the report. + * @param reportType - The type of report to download ('pdf' or 'cyclonedx_json_file') + */ + downloadSBOMAppReport(reportType: 'pdf' | 'cyclonedx_json_file') { + cy.findAllByTestId(`sbomReportList-reportDownloadBtn-${reportType}`, { + timeout: 10000, + }) + .should('be.visible') + .click({ force: true }); + } + + // ===== SBOM Detail Page — Actions ===== + + /** + * @name navigateToSbomDetailPage + * @description Navigates to the SBOM detail page by clicking on the app with the specified package name in the app table. + * @param packageName - The package name of the app to navigate to + */ + navigateToSbomDetailPage(packageName: string) { + this.getAppTableRows() + .contains(new RegExp(packageName, 'i')) + .should('be.visible') + .click({ force: true }); + } + + /** + * @name validateOComponentDetailsOverviewCounts + * @description Validates overview counts using real values from sbomFileSummary API response + */ + validateOComponentDetailsOverviewCounts(sbomFileSummaryAlias: string) { + cy.get(sbomFileSummaryAlias) + .its('response.body') + .then((componentCount) => { + const { + component_count, + machine_learning_model_count, + framework_count, + library_count, + file_count, + } = componentCount; + + const fields = { + [cyTranslate('sbomModule.totalComponents')]: component_count, + [cyTranslate('sbomModule.mlModel')]: machine_learning_model_count, + [cyTranslate('library')]: library_count, + [cyTranslate('framework')]: framework_count, + [cyTranslate('file')]: file_count, + }; + + Object.keys(fields).forEach((key) => { + cy.findByTestId(`sbom-overview-item-${key}`) + .should('be.visible') + .should('contain', fields[key]); + }); + }); + } + + /** + * @name validateMetadataDynamic + * @description Validates metadata fields using real values from sbomFileComponents API response + */ + validateMetadataDynamic(sbomFileAlias: string = '@sbomFileComponents') { + cy.findByTestId('sbom-summary-header-collapsible-toggle-btn') + .should('be.visible') + .click(); + + cy.wait(sbomFileAlias, { timeout: 10000 }) + .its('response.body') + .as('sbomFileData'); + + cy.get('@sbomFileData').then( + (sbomFile) => { + cy.findAllByTestId(/^sbom-scan-details-file-summary-group-/, { + timeout: 7000, + }).should('have.length.greaterThan', 0); + + const metadataFields = { + [cyTranslate('status')]: sbomFile.status, + [cyTranslate('sbomModule.generatedDate')]: sbomFile.completed_at, + [cyTranslate('file')]: sbomFile.id, + }; + + Object.keys(metadataFields).forEach((label) => { + const expectedValue = metadataFields[label]; + + if (expectedValue) { + cy.findByTestId(`sbom-scan-details-file-summary-group-${label}`) + .should('be.visible') + .should('contain', expectedValue); + } + }); + } + ); + } + + /** + * @name openComponentTypeFilter + */ + openComponentTypeFilter() { + cy.findByTestId('sbom-component-type-filter-icon') + .should('be.visible') + .click({ force: true }); + + cy.findByTestId('sbom-component-type-filter-popover').should( + 'be.visible', + DEFAULT_ASSERT_OPTS + ); + } + + /** + * @name selectComponentType + */ + selectComponentType(type: string) { + cy.findByTestId(`sbom-component-type-filter-radio-${type}`) + .should('exist', DEFAULT_ASSERT_OPTS) + .click({ force: true }); + } + + /** + * @name validateFilteredSBOMComponentRows + * @description Validates filtered rows contain the selected component type. + * Gracefully handles empty results — some types may have no components. + */ + validateFilteredSBOMComponentRows(type: string) { + cy.get('body').then((body) => { + const rowCount = body.find('[data-test-cy="sbom-component-row"]').length; + + if (rowCount > 0) { + cy.findAllByTestId('sbom-component-row') + .should('have.length.greaterThan', 0) + .each((row) => { + cy.wrap(row).invoke('text').should('contain', type); + }); + } + }); + } + + /** + * @name clearComponentTypeFilterOnly + * @description Clears component type filter without selecting another type + */ + clearComponentTypeFilterOnly() { + cy.findByTestId('sbom-component-type-clear-filter') + .should('exist') + .click({ force: true }); + + cy.get('body').click(0, 0); + } + + /** + * @name openDependencyTypeFilter + * @description Opens the dependency type filter popover + */ + openDependencyTypeFilter() { + cy.findByTestId('sbom-dependency-type-filter-icon') + .should('be.visible') + .click({ force: true }); + + cy.findByTestId('sbom-dependency-type-filter-popover').should( + 'be.visible', + DEFAULT_ASSERT_OPTS + ); + } + + /** + * @name selectDependencyType + */ + selectDependencyType(type: string) { + cy.findByTestId(`sbom-dependency-type-filter-radio-${type}`) + .should('exist', DEFAULT_ASSERT_OPTS) + .click({ force: true }); + + cy.get('body').click(0, 0); + } + + /** + * @name clearDependencyTypeFilterOnly + * @description Clears dependency type filter without selecting another type + */ + clearDependencyTypeFilterOnly() { + cy.findByTestId('sbom-dependency-type-clear-filter') + .should('exist') + .click({ force: true }); + + cy.get('body').click(0, 0); + } +} diff --git a/cypress/support/Actions/common/UploadAppActions.ts b/cypress/support/Actions/common/UploadAppActions.ts index 9c3ed7adba..395cef3676 100644 --- a/cypress/support/Actions/common/UploadAppActions.ts +++ b/cypress/support/Actions/common/UploadAppActions.ts @@ -110,7 +110,7 @@ export default class UploadAppActions { cy.wrap(sbomProject).as('uploadedAppSBPrj'); if (sbomPrjFileID) { - const sbFileURL = `${API_ROUTES.sbom.route}/${sbomPrjFileID}`; + const sbFileURL = `${API_ROUTES.sbomFileBase.route}/${sbomPrjFileID}`; cy.intercept(sbFileURL).as('uploadedAppSBFileReq'); } diff --git a/cypress/support/Mirage/factories.config.ts b/cypress/support/Mirage/factories.config.ts index 7ae4cea762..c3d14660c0 100644 --- a/cypress/support/Mirage/factories.config.ts +++ b/cypress/support/Mirage/factories.config.ts @@ -51,6 +51,10 @@ import DeviceFactory, { DEVICE_FACTORY_DEF, } from 'irene/mirage/factories/device'; +import SbomScanSummaryFactory, { + SBOM_SCAN_SUMMARY_FACTORY_DEF, +} from 'irene/mirage/factories/sbom-scan-summary'; + // Extract factory method return values from a factory definition export type FlattenFactoryMethods = { [K in keyof T]: T[K] extends (n: number) => infer V ? V : T[K]; @@ -84,9 +88,14 @@ export interface MirageFactoryDefProps { 'available-manual-device': FlattenFactoryMethods; 'file-risk': FlattenFactoryMethods; + 'sbom-scan-summary': FlattenFactoryMethods< + typeof SBOM_SCAN_SUMMARY_FACTORY_DEF + >; + submission: FlattenFactoryMethods< typeof SUBMISSION_FACTORY_DEF & { file: number; + id: number; } >; @@ -109,6 +118,7 @@ export interface MirageFactoryDefProps { file: IncludeBaseFactoryProps< typeof FILE_FACTORY_DEF & { + submission: number; project: number; executable_name: string; analyses: Array; @@ -137,6 +147,7 @@ const MIRAGE_FACTORIES: Record< 'sbom-project': SbomProjectFactory, 'available-manual-device': DeviceFactory, 'file-risk': FileRiskFactory, + 'sbom-scan-summary': SbomScanSummaryFactory, }; export { MIRAGE_FACTORIES }; diff --git a/cypress/support/Websocket/index.ts b/cypress/support/Websocket/index.ts index 0fec14f191..cd06edc93b 100644 --- a/cypress/support/Websocket/index.ts +++ b/cypress/support/Websocket/index.ts @@ -22,4 +22,17 @@ export type WS_MODEL_UPDATED_PAYLOAD_MAP = { data: MirageFactoryDefProps['file-risk']; }; }; + submission: { + payload: { + model_name: 'submission'; + data: MirageFactoryDefProps['submission']; + }; + }; + + file: { + payload: { + model_name: 'file'; + data: MirageFactoryDefProps['file']; + }; + }; }; diff --git a/cypress/support/api.routes.ts b/cypress/support/api.routes.ts index f507b44e54..fc73075a78 100644 --- a/cypress/support/api.routes.ts +++ b/cypress/support/api.routes.ts @@ -33,6 +33,20 @@ export const API_ROUTES = { route: '/api/v2/sb_projects*', alias: 'sbomProjectList', }, + sbomFileSummary: { + route: '/api/v2/sb_projects/*/sb_files/*/summary*', + alias: 'sbomFileSummary', + }, + + sbomFileComponents: { + route: '/api/v2/sb_files/*/sb_file_components*', + alias: 'sbomFileComponents', + }, + + sbomComponent: { + route: '/api/v2/sb_file_component/*', + alias: 'sbomComponent', + }, organizationList: { route: '/api/organizations*', alias: 'availableOrgsList', @@ -69,7 +83,8 @@ export const API_ROUTES = { // Single Record routes file: { route: '/api/v3/files', alias: 'file' }, fileRisk: { route: '/api/v3/files/*/risk', alias: 'fileRisk' }, - sbom: { route: '/api/v2/sb_files', alias: 'sbomFile' }, + sbomFile: { route: '/api/v2/sb_files/*', alias: 'sbomFile' }, + sbomFileBase: { route: '/api/v2/sb_files', alias: 'sbomFileBase' }, unknownAnalysisStatus: { route: '/api/profiles/*/unknown_analysis_status*', alias: 'unknownAnalysisStatus', @@ -162,4 +177,14 @@ export const API_ROUTES = { route: '/api/v3/files/*/last_manual_dynamic_scan', alias: 'dynamicscan', }, + + // Download routes + sbomPdfDownload: { + route: '/api/v2/sb_reports/*/pdf/download_url', + alias: 'sbomPdfDownload', + }, + sbCycloneDxJsonDownload: { + route: '/api/v2/sb_reports/*/cyclonedx_json_file/download_url', + alias: 'sbCycloneDxJsonDownload', + }, } as const; diff --git a/cypress/tests/dynamic-scan.spec.ts b/cypress/tests/dynamic-scan.spec.ts index ae41a208ba..6276afa19d 100644 --- a/cypress/tests/dynamic-scan.spec.ts +++ b/cypress/tests/dynamic-scan.spec.ts @@ -352,8 +352,8 @@ describe('Dynamic Scan', () => { }); // Change items per page to 20 - cy.findByTestId( - 'paginationItemPerPageOptions', + cy.findByLabelText( + 'pagination item per page options', DEFAULT_ASSERT_OPTS ) .findByRole('combobox') diff --git a/cypress/tests/sbom.spec.ts b/cypress/tests/sbom.spec.ts new file mode 100644 index 0000000000..231d4eb9ab --- /dev/null +++ b/cypress/tests/sbom.spec.ts @@ -0,0 +1,523 @@ +import LoginActions from '../support/Actions/auth/LoginActions'; +import NetworkActions from '../support/Actions/common/NetworkActions'; +import { API_ROUTES } from '../support/api.routes'; +import { APPLICATION_ROUTES } from '../support/application.routes'; +import SbomPageActions from '../support/Actions/common/SbomPageActions'; +import UploadAppActions from '../support/Actions/common/UploadAppActions'; +import cyTranslate from '../support/translations'; +import { WS_MODEL_UPDATED_PAYLOAD_MAP } from '../support/Websocket'; +import { MirageFactoryDefProps } from '../support/Mirage'; + +const loginActions = new LoginActions(); +const networkActions = new NetworkActions(); +const sbomPageActions = new SbomPageActions(); + +const username = Cypress.env('TEST_USERNAME') || 'testuser'; +const password = Cypress.env('TEST_PASSWORD') || 'testpass'; + +const NETWORK_WAIT_OPTS = { + timeout: 60000, +}; + +const SBOM_APP_SEARCH_QUERY = 'com.appknox.dvia'; +const SBOM_MFVA_APP_SEARCH_QUERY = 'com.appknox.mfva'; + +Cypress.on('uncaught:exception', () => false); + +describe('SBOM Page', () => { + beforeEach(() => { + // Setup API intercepts + networkActions.hideNetworkLogsFor({ ...API_ROUTES.websockets }); + cy.intercept(API_ROUTES.check.route).as('checkUserRoute'); + cy.intercept(API_ROUTES.userInfo.route).as('userInfoRoute'); + cy.intercept(API_ROUTES.sbomProjectList.route).as('sbomProjectList'); + cy.intercept(API_ROUTES.submissionList.route).as('submissionList'); + + // Login and navigate + loginActions.loginWithCredAndSaveSession({ username, password }); + cy.visit(APPLICATION_ROUTES.sbom); + + // Wait for data + cy.wait('@submissionList', NETWORK_WAIT_OPTS); + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + // Verify page loaded + cy.url().should('contain', APPLICATION_ROUTES.sbom); + cy.findByText(cyTranslate('sbomModule.sbomAppTitle')).should('exist'); + cy.findByText(cyTranslate('sbomModule.sbomAppDescription')).should('exist'); + }); + + describe('Platform Dropdown Filtering', () => { + beforeEach(() => { + sbomPageActions.openPlatformFilter(); + }); + + it('shows only iOS apps when iOS selected', () => { + sbomPageActions.selectPlatform(cyTranslate('iOS')); + sbomPageActions.validateSbomAppRowsByPlatform('apple'); + }); + + it('shows only Android apps when Android selected', () => { + sbomPageActions.selectPlatform(cyTranslate('android')); + sbomPageActions.validateSbomAppRowsByPlatform('android'); + }); + }); + + describe('Search Functionality', () => { + beforeEach(() => { + // Assert search input exists, is visible, and has the correct placeholder + cy.findByTestId('sbomApp-searchInput') + .should('exist') + .and('be.visible') + .and('have.attr', 'placeholder', cyTranslate('searchQuery')) + .as('searchInput'); + }); + + it('should filter result by package name', () => { + cy.get('@searchInput').clear().type(SBOM_APP_SEARCH_QUERY); + + // Simulate loading state + sbomPageActions.checkAndWaitForAppLoadingView(); + + sbomPageActions + .getAppTableRows() + .should('have.length.greaterThan', 0) + .each((row) => { + cy.wrap(row).within(() => { + cy.findByText(new RegExp(SBOM_APP_SEARCH_QUERY, 'i')).should( + 'exist' + ); + }); + }); + }); + + it('should restore results when search is cleared', () => { + cy.get('@searchInput').type(SBOM_APP_SEARCH_QUERY); + + sbomPageActions.checkAndWaitForAppLoadingView(); + + // Get intercepted request length + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS) + .its('response.body.count') + .as('initialApiRequestCount'); + + sbomPageActions.getAppTableRows().its('length').as('initialAppTableRows'); + + // Clear search input + cy.get('@searchInput').clear(); + + sbomPageActions.checkAndWaitForAppLoadingView(); + + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS) + .its('response.body.count') + .as('newApiRequestCount'); + + sbomPageActions.getAppTableRows().its('length').as('newAppTableRows'); + + // Check if new app table rows are greater than initial app table rows + cy.get('@initialAppTableRows').then((initialAppTableRows) => { + cy.get('@newAppTableRows').should('be.gte', initialAppTableRows); + }); + + // Check network request count is greater than initial app table rows + cy.get('@initialApiRequestCount').then((initialApiRequestCount) => { + cy.get('@newApiRequestCount').should( + 'be.greaterThan', + initialApiRequestCount + ); + }); + }); + + it('should show empty state when search has no results', () => { + const INVALID_APP_SEARCH_QUERY = 'this-app-does-not-exist-123'; + + cy.get('@searchInput').type(INVALID_APP_SEARCH_QUERY); + + sbomPageActions.checkAndWaitForAppLoadingView(); + + cy.findByTestId('sbomApp-table').should('not.exist'); + + cy.findByTestId('sbomApp-emptyTextContainer') + .should('be.visible') + .within(() => { + cy.findByText( + cyTranslate('sbomModule.sbomAppEmptyText.title') + ).should('exist'); + + cy.findByText( + cyTranslate('sbomModule.sbomAppEmptyText.description') + ).should('exist'); + }); + }); + }); + + describe('SBOM Report Flow', () => { + beforeEach(() => { + cy.intercept('GET', API_ROUTES.uploadApp.route).as('uploadAppReq'); + cy.intercept('POST', API_ROUTES.uploadApp.route).as('uploadAppReqPOST'); + }); + + const uploadAppActions = new UploadAppActions(); + + it('opens Past SBOM Analyses and validates report generation flow', () => { + cy.intercept('GET', API_ROUTES.sbomPdfDownload.route).as('sbPdfDownload'); + + cy.intercept('GET', API_ROUTES.sbCycloneDxJsonDownload.route).as( + 'sbCycloneDxJsonDownload' + ); + + uploadAppActions.initiateViaSystemUpload('MFVA.apk'); + + let uploadSubmissionID: number | null = null; + + // Intercept submission model updated event and get the uploaded file ID + cy.interceptWsMessage< + | WS_MODEL_UPDATED_PAYLOAD_MAP['submission']['payload'] + | WS_MODEL_UPDATED_PAYLOAD_MAP['file']['payload'] + >((event, payload) => { + if ( + event === 'model_updated' && + payload?.model_name === 'submission' && + payload?.data?.status === 7 && + payload?.data?.file !== null + ) { + uploadSubmissionID = payload.data.id; + } + + if ( + uploadSubmissionID && + payload?.model_name === 'file' && + payload?.data?.submission === uploadSubmissionID + ) { + cy.wrap(payload?.data).as('uploadedFile'); + cy.wrap(payload?.data?.project).as('uploadedAppPrjID'); + + return true; + } + + return false; + }); + + // Wait for the uploaded file details to be available from the websocket intercept + cy.reload(); + + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + // Find the uploaded file in the SBOM project list + cy.get('@sbomProjectList') + .its('response.body.results') + .then((results: Array) => { + cy.get('@uploadedAppPrjID').then((uploadedAppPrjID) => { + const sbomProjectRow = results.find( + (r) => r.project === uploadedAppPrjID + ); + + if (sbomProjectRow) { + cy.wrap(sbomProjectRow.id).as('uploadedAppSBPrjId'); + } + }); + }); + + cy.get('@uploadedAppSBPrjId').then((sbPrjID) => { + cy.findByTestId(`sbomApp-row-${sbPrjID}`) + .findByTestId('sbomApp-actionBtn') + .click({ force: true }); + }); + + // Validate Past SBOM Analyses page + const pastAnalysesText = cyTranslate('sbomModule.pastSbomAnalyses'); + + cy.findByLabelText('sbom app action menu item') + .contains(pastAnalysesText) + .should('be.visible') + .click({ force: true }); + + cy.findAllByTestId('sbomScan-row').should('have.length.greaterThan', 0); + cy.findByText(pastAnalysesText).should('be.visible'); + + // Wait for SBOM Scan to complete + cy.wait(5000); + + // open the first (latest) report in the list + cy.findAllByTestId('sbomScan-viewReportBtn', { timeout: 10000 }) + .first() + .should('be.visible') + .click({ force: true }); + + cy.findAllByTestId('sbomReportList-reportGenerateBtn') + .should('not.be.disabled') + .click({ force: true }); + + cy.findByText( + cyTranslate('fileReport.detailedReport', { reportType: 'pdf' }), + NETWORK_WAIT_OPTS + ).should('be.visible'); + + cy.findByText( + cyTranslate('sbomModule.sbomDownloadPdfPrimaryText') + ).should('be.visible'); + + // Validate report is generated successfully + cy.findByTestId('sbomReportList-loading').should('not.exist'); + cy.findByTestId('sbomReportList-reportGenerateBtn').should('not.exist'); + + cy.findAllByTestId(/sbomReportList-reportDownloadBtn/).should( + 'have.length.at.least', + 2 + ); + + // Download PDF report + sbomPageActions.downloadSBOMAppReport('pdf'); + + cy.wait('@sbPdfDownload', { timeout: 30000 }) + .its('response.statusCode') + .should('equal', 200); + + // Download JSON + sbomPageActions.downloadSBOMAppReport('cyclonedx_json_file'); + + cy.wait('@sbCycloneDxJsonDownload', { timeout: 30000 }) + .its('response.statusCode') + .should('equal', 200); + }); + }); + + describe('SBOM Report Detail Page', () => { + beforeEach(() => { + cy.intercept(API_ROUTES.sbomFileSummary.route).as('sbomFileSummary'); + + cy.intercept(API_ROUTES.sbomFileComponents.route).as( + 'sbomFileComponents' + ); + + cy.intercept(API_ROUTES.sbomComponent.route).as('sbomComponent'); + + sbomPageActions.navigateToSbomDetailPage(SBOM_MFVA_APP_SEARCH_QUERY); + + cy.wait('@sbomFileSummary', NETWORK_WAIT_OPTS); + }); + + it('validates overview counts from API response', () => { + sbomPageActions.validateOComponentDetailsOverviewCounts( + '@sbomFileSummary' + ); + }); + + it('validates metadata section from API response', () => { + sbomPageActions.validateMetadataDynamic('@sbomFileComponents'); + }); + + it('validates default tree view state on page load', () => { + // Tree nodes are rendered + sbomPageActions.componentLinks().should('have.length.greaterThan', 0); + + // collapse all button is visible but disabled on initial load since all nodes are already collapsed + sbomPageActions.collapseAllBtn().should('be.disabled'); + }); + + describe('List View - Filters', () => { + beforeEach(() => { + // Switch to list view and wait for component data + cy.findByTestId('sbom-scan-details-list-view-btn').click(); + cy.wait('@sbomFileComponents', NETWORK_WAIT_OPTS); + }); + + it('filters rows by each component type', () => { + const componentTypes = [ + cyTranslate('library'), + cyTranslate('framework'), + cyTranslate('file'), + cyTranslate('sbomModule.mlModel'), + ]; + + componentTypes.forEach((type) => { + sbomPageActions.openComponentTypeFilter(); + sbomPageActions.selectComponentType(type); + + cy.get('body').click(0, 0); + + sbomPageActions.validateFilteredSBOMComponentRows(type); + sbomPageActions.openComponentTypeFilter(); + sbomPageActions.clearComponentTypeFilterOnly(); + }); + }); + + it('filters rows by each dependency type', () => { + const dependencyTypes = ['Direct', 'Transitive'] as const; + + dependencyTypes.forEach((type) => { + sbomPageActions.openDependencyTypeFilter(); + sbomPageActions.selectDependencyType(type); + + cy.get('body').click(0, 0); + + sbomPageActions.validateFilteredSBOMComponentRows(type); + sbomPageActions.openDependencyTypeFilter(); + sbomPageActions.clearDependencyTypeFilterOnly(); + }); + }); + }); + + describe('Tree View - Expand & Component Details', () => { + it('expands a tree node and enables collapse all, then collapses all', () => { + sbomPageActions.expandNodeIcon().should('be.visible').click(); + + // Node expanded — collapse all should now be enabled + sbomPageActions.collapseAllBtn().should('not.be.disabled'); + sbomPageActions.collapseAllBtn().click({ force: true }); + + // All collapsed — back to disabled + sbomPageActions.collapseAllBtn().should('be.disabled'); + }); + + it('opens component details, validates tabs and dynamic status, navigates back via breadcrumb', () => { + sbomPageActions.expandNodeIcon().should('be.visible').click(); + sbomPageActions.componentLinks().first().click({ force: true }); + + // Wait for component API before asserting + cy.wait('@sbomComponent', NETWORK_WAIT_OPTS); + + // Tabs visible + // HBS: data-test-sbomComponentDetails-tab='{{item.id}}' + (['overview', 'vulnerabilities'] as const).forEach((tabId) => { + sbomPageActions.componentDetailsTab(tabId).should('be.visible'); + }); + + // Summary fields visible + const summaryFieldLabels = [ + 'sbomModule.componentType', + 'dependencyType', + 'status', + ] as const; + + summaryFieldLabels.forEach((tabId) => { + sbomPageActions + .componentSummary(cyTranslate(tabId)) + .should('be.visible'); + }); + + sbomPageActions + .componentStatus(cyTranslate('chipStatus.secure')) + .should('exist'); + + // Navigate back via breadcrumb + cy.findByText( + new RegExp( + cyTranslate('sbomModule.allComponentsAndVulnerabilities'), + 'i' + ) + ) + .should('be.visible') + .click(); + + // Back on detail page — tree view button confirms correct page + cy.findByTestId('sbom-scan-details-tree-view-btn').should('be.visible'); + }); + }); + }); + + describe('SBOM Page - Pagination Tests', () => { + beforeEach(() => { + cy.findByLabelText('pagination previous button').as('prevBtn'); + cy.findByLabelText('pagination next button').as('nextBtn'); + + sbomPageActions.getAppTableRows().first().as('firstRow'); + }); + + describe('Navigation - Next/Previous', () => { + it('should navigate to next page and verify data changed', () => { + cy.get('@firstRow').invoke('text').as('initialFirstRow'); + cy.get('@nextBtn').should('not.be.disabled').click(); + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + sbomPageActions.getAppTableRows().should('have.length.greaterThan', 0); + + cy.get('@sbomProjectList') + .its('response.body.count') + .then((totalCount) => { + cy.findByLabelText('pagination page range') + .invoke('text') + .should((text) => { + const normalized = text.replace(/\s+/g, ' ').trim(); + expect(normalized).to.match(/^\d+-\d+/); + expect(normalized).to.include(`of ${totalCount} Apps`); + }); + }); + + cy.get('@firstRow') + .invoke('text') + .then((newFirstRow) => { + cy.get('@initialFirstRow').should('not.equal', newFirstRow); + }); + + cy.get('@prevBtn').should('not.be.disabled'); + }); + + it('should disable Previous button on first page', () => { + cy.get('@prevBtn').should('be.disabled'); + }); + }); + + describe('Button States', () => { + it('should enable/disable buttons correctly during navigation', () => { + cy.get('@prevBtn').should('be.disabled'); + cy.get('@nextBtn').should('not.be.disabled').click(); + + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + sbomPageActions.getAppTableRows().should('have.length.greaterThan', 0); + + cy.get('@prevBtn').should('not.be.disabled'); + }); + }); + + [25, 50].forEach((itemsPerPage) => { + describe('Items Per Page', () => { + it(`should update rows and URL when items per page is changed to ${itemsPerPage}`, () => { + cy.findByLabelText('pagination item per page options').click(); + cy.findByRole('option', { name: `${itemsPerPage}` }).click(); + + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + cy.url().should('include', `app_limit=${itemsPerPage}`); + + sbomPageActions + .getAppTableRows() + .should('have.length.greaterThan', 0); + + cy.get('@sbomProjectList') + .its('response.body.count') + .then((totalCount) => { + const expectedEnd = Math.min(itemsPerPage, totalCount); + + cy.findByLabelText('pagination page range') + .should('exist') + .invoke('text') + .should((text) => { + const normalized = text.replace(/\s+/g, ' ').trim(); + + expect(normalized).to.match(new RegExp(`^1-${expectedEnd}`)); + expect(normalized).to.include(`of ${totalCount} Apps`); + }); + }); + }); + }); + }); + + describe('URL State', () => { + it('should update URL when changing pages', () => { + cy.url().then((page1Url) => { + cy.get('@nextBtn').click(); + + cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS); + + cy.url().should('not.equal', page1Url); + + sbomPageActions + .getAppTableRows() + .should('have.length.greaterThan', 0); + }); + }); + }); + }); +}); diff --git a/mirage/factories/sbom-scan-summary.ts b/mirage/factories/sbom-scan-summary.ts index 916671e8c1..1ec81678c5 100644 --- a/mirage/factories/sbom-scan-summary.ts +++ b/mirage/factories/sbom-scan-summary.ts @@ -1,11 +1,13 @@ import { Factory } from 'miragejs'; import { faker } from '@faker-js/faker'; -export default Factory.extend({ +export const SBOM_SCAN_SUMMARY_FACTORY_DEF = { component_count: faker.number.int(1000), library_count: faker.number.int(125), framework_count: faker.number.int(125), application_count: faker.number.int(125), file_count: faker.number.int(125), machine_learning_model_count: faker.number.int(125), -}); +}; + +export default Factory.extend(SBOM_SCAN_SUMMARY_FACTORY_DEF);