diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index b22bc86ab..e621b6883 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - containers: [1, 2] + containers: [1, 2, 3] steps: - uses: actions/checkout@v4 @@ -59,6 +59,8 @@ jobs: CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_TEST_PROJECT_ID }} CYPRESS_DYNAMIC_SCAN_SYSTEM_APK_FILE_ID: ${{ vars.CYPRESS_DYNAMIC_SCAN_SYSTEM_APK_FILE_ID }} CYPRESS_DYNAMIC_SCAN_SYSTEM_IPA_FILE_ID: ${{ vars.CYPRESS_DYNAMIC_SCAN_SYSTEM_IPA_FILE_ID }} + CYPRESS_IGNORE_VULNERABILITY_TEST_PACKAGE_NAME: ${{ vars.CYPRESS_IGNORE_VULNERABILITY_TEST_PACKAGE_NAME }} + CYPRESS_API_HOST: ${{ vars.CYPRESS_API_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: start: npm run startprod diff --git a/app/components/analysis-risk/override-edit-drawer/index.hbs b/app/components/analysis-risk/override-edit-drawer/index.hbs index b0df25add..4ea519314 100644 --- a/app/components/analysis-risk/override-edit-drawer/index.hbs +++ b/app/components/analysis-risk/override-edit-drawer/index.hbs @@ -1,4 +1,10 @@ - + {{#if this.appBarData.onBackClick}} @@ -22,6 +28,7 @@ diff --git a/app/components/analysis-risk/override-edit-drawer/override-details/index.hbs b/app/components/analysis-risk/override-edit-drawer/override-details/index.hbs index eb591d375..034cdc85a 100644 --- a/app/components/analysis-risk/override-edit-drawer/override-details/index.hbs +++ b/app/components/analysis-risk/override-edit-drawer/override-details/index.hbs @@ -5,7 +5,11 @@
- + - + {{#each this.overrideAuditDetails as |oad|}} @@ -38,10 +39,12 @@ @color='textSecondary' @iconName='east' data-test-analysisRisk-overrideEditDrawer-overrideForm-successFromToRiskIcon + data-test-cy='overrideEditDrawer-overrideForm-successFromToRiskIcon' /> @@ -70,6 +73,7 @@ {{/if}} \ No newline at end of file diff --git a/app/components/file-chart/index.hbs b/app/components/file-chart/index.hbs index 8c537cd6a..040e160d1 100644 --- a/app/components/file-chart/index.hbs +++ b/app/components/file-chart/index.hbs @@ -85,6 +85,7 @@ @iconName='drive-file-rename-outline' @color='secondary' data-test-fileChartSeverityLevel-ignoreVulnerabilityIcon + data-test-cy='fileChartSeverityLevel-ignoreVulnerabilityIcon' /> diff --git a/app/components/file-details/summary-old/index.hbs b/app/components/file-details/summary-old/index.hbs index 529728bea..494070de0 100644 --- a/app/components/file-details/summary-old/index.hbs +++ b/app/components/file-details/summary-old/index.hbs @@ -31,6 +31,7 @@ {{on 'click' this.handleFileMoreMenuOpen}} @variant='outlined' data-test-fileDetailsSummary-moreMenuBtn + aria-label='file summary more button' > diff --git a/app/components/file-details/vulnerability-analysis-details/edit-analysis-button/index.hbs b/app/components/file-details/vulnerability-analysis-details/edit-analysis-button/index.hbs index a2c537128..faa93bce4 100644 --- a/app/components/file-details/vulnerability-analysis-details/edit-analysis-button/index.hbs +++ b/app/components/file-details/vulnerability-analysis-details/edit-analysis-button/index.hbs @@ -13,6 +13,7 @@ }} >
diff --git a/app/components/file-details/vulnerability-analysis/table/index.hbs b/app/components/file-details/vulnerability-analysis/table/index.hbs index c749f0dfd..7c43dfde3 100644 --- a/app/components/file-details/vulnerability-analysis/table/index.hbs +++ b/app/components/file-details/vulnerability-analysis/table/index.hbs @@ -54,6 +54,9 @@ diff --git a/app/components/project-settings/analysis-settings/vulnerability-list/action/index.hbs b/app/components/project-settings/analysis-settings/vulnerability-list/action/index.hbs index fa399180e..884aba5fc 100644 --- a/app/components/project-settings/analysis-settings/vulnerability-list/action/index.hbs +++ b/app/components/project-settings/analysis-settings/vulnerability-list/action/index.hbs @@ -2,6 +2,7 @@ @variant='outlined' @size='small' {{on 'click' (fn @setVulnerabilityDataModel @vulnerabilityPreference)}} + aria-label='vulnerability preference action' data-test-prjSettings-analysisSettings-vulnPreferenceItem-action > {{#if @vulnerabilityPreference.riskOverridden}} diff --git a/app/components/project-settings/analysis-settings/vulnerability-list/index.hbs b/app/components/project-settings/analysis-settings/vulnerability-list/index.hbs index ccc4791bd..3112bf4ad 100644 --- a/app/components/project-settings/analysis-settings/vulnerability-list/index.hbs +++ b/app/components/project-settings/analysis-settings/vulnerability-list/index.hbs @@ -54,6 +54,7 @@ ; + +// Test Constants +export const RISK_TEXT_MAP = { + 0: 'Passed', + 1: 'Low', + 2: 'Medium', + 3: 'High', + 4: 'Critical', +}; + +export const ANALYSIS_OVERRIDE_CRITERIA = { + current_file: cyTranslate('currentFileOnly'), + all_future_upload: cyTranslate('allFutureAnalyses'), +}; + +const DEFAULT_ASSERT_OPTS = { + timeout: 20000, +}; + +export default class IgnoreVulnerabilityActions { + /** + * Closes the edit modal by clicking the close button. + */ + closeEditDrawer() { + cy.findByTestId('overrideEditDrawer-closeBtn').click({ force: true }); + } + + /** + * Opens the edit drawer by clicking the button with the specified label. + * + * @param {string} btnLabel - The label text of the button to be clicked. + */ + openEditDrawer(btnLabel: string) { + cy.findByLabelText(btnLabel, DEFAULT_ASSERT_OPTS).click({ + force: true, + }); + } + + /** + * Finds a button by its label and clicks it. + * + * @param {string} label - The accessible name of the button to be found. + */ + findBtnByLabelAndClick(label: string) { + cy.findByRole('button', { + ...DEFAULT_ASSERT_OPTS, + name: label, + }).click({ force: true }); + } + + /** + * Retrieves the vulnerability analysis item by its alias. + * + * @param {string} alias - The alias used to identify the vulnerability item. + * @returns {Cypress.Chainable} A Cypress chainable object containing the vulnerability item. + */ + getAnalysisVulnerability( + alias: string + ): Cypress.Chainable { + return cy.get(alias, DEFAULT_ASSERT_OPTS); + } + + /** + * Finds and clicks the first anchor element with the specified href attribute. + * + * @param {string} href - The href attribute value to find and click the link. + */ + findAndClickLinkByHrefAttr(href: string) { + cy.get(`a[href="${href}"]`, DEFAULT_ASSERT_OPTS) + .first() + .click({ force: true }); + } + + /** + * Navigates to the file details page from an analysis view based on a file ID. + * + * @param {string} fileIdAlias - The alias for the file ID used to construct the href. + */ + goToFileDetailsFromAnalysis(fileIdAlias: string) { + cy.get(fileIdAlias).then((fileId) => + this.findAndClickLinkByHrefAttr(`/dashboard/file/${fileId}`) + ); + } + + /** + * Retrieves a vulnerability preference item row by its test ID. + * + * @param {number} id - The ID of the vulnerability preference item. + * @returns {Cypress.Chainable>} - The Cypress chainable for the found item. + */ + getVulnPrefItemRow(id: number): Cypress.Chainable> { + return cy.findByTestId(`vulnPreference-item-${id}`, DEFAULT_ASSERT_OPTS); + } + + /** + * Asserts the existence of the Edit Analysis Drawer. + * + * @returns {Cypress.Chainable>} - The Cypress chainable for the found drawer element. + */ + getEditAnalysisDrawer(): Cypress.Chainable> { + return cy + .findByTestId('editAnaysis-drawer', DEFAULT_ASSERT_OPTS) + .should('exist'); + } + + /** + * Intercepts an analysis item response for a given ID and assigns an alias. + * + * @param {Object} params - The parameters for intercepting the analysis item. + * @param {string} params.idAlias - The alias for the analysis item ID. + * @param {string} params.intAlias - The alias for the intercept request. + */ + interceptAnalysisItemRes({ + idAlias, + intAlias, + }: { + idAlias: string; + intAlias: string; + }) { + cy.get(idAlias).then((id) => + cy + .intercept({ url: `${API_ROUTES.analysis.route}/${id}`, times: 2 }) + .as(intAlias) + ); + } + + /** + * Navigates to the project analysis settings page using the provided project ID alias. + * + * @param {string} prjIdAlias - The alias used to retrieve the project ID. + */ + goToPrjAnalysisSettings(prjIdAlias: string) { + this.findBtnByLabelAndClick('file summary more button'); + + cy.get(prjIdAlias).then((prjId) => { + this.findAndClickLinkByHrefAttr(`/dashboard/project/${prjId}/settings`); + }); + + cy.findByRole('link', { name: cyTranslate('analysisSettings') }).click(); + + // Assert project analysis settings page + this.assertMultipleTextInfo( + [ + cyTranslate('projectSettings.headerText'), + cyTranslate('analysisSettings'), + ], + DEFAULT_ASSERT_OPTS + ); + } + + /** + * Navigates to the analysis details page for a specific file and analysis using their aliases. + * + * @param {string} fileIdAlias - The alias used to retrieve the file ID. + * @param {string} analysisIdAlias - The alias used to retrieve the analysis ID. + */ + doGoToAnalysisDetailsPage(fileIdAlias: string, analysisIdAlias: string) { + cy.getAliases([fileIdAlias, analysisIdAlias]).then( + ([fileId, analysisId]) => { + cy.visit( + `/dashboard/file/${fileId}/analysis/${analysisId}`, + DEFAULT_ASSERT_OPTS + ); + } + ); + } + + /** + * Asserts that multiple pieces of text are present on an elemebt. + * + * @param {string[]} infoList - An array of text strings to be verified. + * @param {SelectorMatcherOptions} [opts] - Optional configuration for the text selector. + */ + assertMultipleTextInfo(infoList: string[], opts?: SelectorMatcherOptions) { + infoList.forEach((info) => + cy + .findByText(info, { + exact: false, + ...opts, + }) + .should('exist') + ); + } + + /** + * Edits the analysis details in the edit drawer by selecting options and entering a reason for the override. + * + * @param {Object} params - The parameters for editing the analysis details. + * @param {boolean | string | undefined} [params.toPassed] - Indicates whether to select 'All future analysis' override option. + * @param {boolean | string | undefined} [params.riskTextToModifyTo] - The risk text to modify to, used if `toPassed` is false. + * @param {boolean | string | undefined} [params.allFutureAnalysis] - Indicates whether to select 'All future analysis' or 'Current file only' criteria option. + * @param {boolean | string | undefined} [params.isInPrjSettingsPage] - Indicates whether the override is on the project settings page. + */ + doEditAnalysisDetails({ + toPassed, + riskTextToModifyTo, + allFutureAnalysis, + isInPrjSettingsPage, + }: Record) { + // Open override select options + cy.findByTestId('overrideEditDrawer-overrideForm-overrideToSelect').click(); + + cy.get('.ember-power-select-options').within(() => { + if (toPassed) { + // Select 'Ignore vulnerability' override option + cy.findByText(cyTranslate('ignoreVulnerability')).click(); + } else { + // Select another risk override option + cy.findByText(String(riskTextToModifyTo), { + exact: false, + }).click(); + } + }); + + // Open criteria select options + cy.findByTestId('overrideEditDrawer-overrideForm-criteriaSelect').click(); + + if (!isInPrjSettingsPage) { + if (allFutureAnalysis) { + // Select 'All future analysis' override option + cy.findByText(cyTranslate('allFutureAnalyses')).click(); + } else { + // Select 'current file' override option + cy.findByText(cyTranslate('currentFileOnly')).click(); + } + } + + cy.findByPlaceholderText( + cyTranslate('editOverrideVulnerability.reasonForOverridePlaceholder') + ) + .as('editOverrideVulnerabilityReason') + .should('not.have.value'); + + cy.get('@editOverrideVulnerabilityReason') + .type('Edit Test Reason', { delay: 0 }) + .then(($input) => { + expect($input.val()).to.equal('Edit Test Reason'); + }); + } + + /** + * Asserts that the edited analysis information is correctly displayed in the UI. + * + * @param {string} analysisAlias - The alias of the analysis item to be validated. + * @param {Object} info - The information to check against the displayed values. + * @param {string} info.riskTextToModifyTo - The modified risk text to verify in the UI. + * @param {string} info.riskyAnalysisRiskText - The risky analysis risk text to verify in the UI. + * @param {boolean} [info.isInPrjSettingsPage] - Optional flag indicating whether the information is in the project settings page. + */ + assertEditedAnalysisInfo( + analysisAlias: string, + info: { + riskTextToModifyTo: string; + riskyAnalysisRiskText: string; + isInPrjSettingsPage?: boolean; + } + ) { + cy.get(analysisAlias).then( + ({ + overridden_risk, + override_criteria, + overridden_date, + overridden_risk_comment, + overridden_by, + }) => { + const overriddenRisk = RISK_TEXT_MAP[overridden_risk as RiskTextKeys]; + const overriddenDate = dayjs(overridden_date).format('MMM DD, YYYY'); + + const overriddenCriteria = + ANALYSIS_OVERRIDE_CRITERIA[ + override_criteria as keyof typeof ANALYSIS_OVERRIDE_CRITERIA + ]; + + cy.findByTestId( + 'overrideEditDrawer-overrideDetails-overriddenRiskInfo' + ).within(() => + this.assertMultipleTextInfo([ + overridden_risk_comment, + overriddenRisk, + overriddenCriteria, + ]) + ); + + // Check for overridden props and their titles + cy.findByTestId( + 'overrideEditDrawer-overrideDetails-auditDetails' + ).within(() => { + this.assertMultipleTextInfo( + [ + cyTranslate('editOverrideVulnerability.overriddenOn'), + overriddenDate, + !info.isInPrjSettingsPage + ? (info.riskTextToModifyTo, + info.riskyAnalysisRiskText, + cyTranslate('editOverrideVulnerability.overriddenSeverity'), + cyTranslate('editOverrideVulnerability.overriddenBy'), + overridden_by) + : null, + ].filter(Boolean) as string[] + ); + }); + } + ); + } + + /** + * Resets or removes analysis overrides based on provided options. + * + * @param {Object} params - The parameters for resetting or removing overrides. + * @param {boolean} [params.resetForCurrentFileOnly] - Indicates whether to reset overrides for the current file only. + * @param {boolean} [params.removeOverride] - Indicates whether to remove overrides for all future analyses. + * @param {boolean} [params.allFutureAnalysis] - Indicates whether to confirm reset or removal for all future analyses. + * @param {boolean} [params.isInPrjSettingsPage] - Indicates whether the action is performed in the project settings page. + */ + doResetAnalysis({ + resetForCurrentFileOnly, + removeOverride, + allFutureAnalysis, + isInPrjSettingsPage, + }: Record) { + // RESET Edited Analysis + this.findBtnByLabelAndClick(cyTranslate('resetOverride')); + + if (allFutureAnalysis && !isInPrjSettingsPage) { + this.assertMultipleTextInfo( + [ + cyTranslate( + 'fileAnalysisDetails.currentFileResetOrRemoveOverrideConfirmTitle' + ), + cyTranslate('fileAnalysisDetails.editAnalysisResetConfirmNoteText'), + cyTranslate('fileAnalysisDetails.editAnalysisResetConfirmNoteTitle'), + ], + DEFAULT_ASSERT_OPTS + ); + } + + if (resetForCurrentFileOnly && !isInPrjSettingsPage) { + // Reset override for all future analysis + this.findBtnByLabelAndClick( + cyTranslate('fileAnalysisDetails.resetForTheCurrentFile') + ); + } else if (removeOverride && !isInPrjSettingsPage) { + // Remove override for all future analyses + this.findBtnByLabelAndClick( + cyTranslate('fileAnalysisDetails.removeOverride') + ); + } else { + this.findBtnByLabelAndClick(cyTranslate('yes')); + } + + const resetConfirmText = isInPrjSettingsPage + ? cyTranslate( + 'projectSettings.vulnerabilityPreference.resetSuccessMessage' + ) + : removeOverride + ? cyTranslate('fileAnalysisDetails.removeOverrideSuccessMessage') + : cyTranslate('fileAnalysisDetails.currentFileResetSuccessMessage'); + + // Text confirmation of reset + cy.findByText(resetConfirmText, { + ...DEFAULT_ASSERT_OPTS, + exact: false, + }).should('exist'); + } +} diff --git a/cypress/support/Mirage/factories.config.ts b/cypress/support/Mirage/factories.config.ts index 6fc4be7de..e3448f70e 100644 --- a/cypress/support/Mirage/factories.config.ts +++ b/cypress/support/Mirage/factories.config.ts @@ -4,6 +4,10 @@ import { BASE_FACTORY_DEF } from 'irene/mirage/factories/base'; import FileFactory, { FILE_FACTORY_DEF } from 'irene/mirage/factories/file'; import User, { USER_FACTORY_DEF } from 'irene/mirage/factories/user'; +import AnalysisFactory, { + ANALYSIS_FACTORY_DEF, +} from 'irene/mirage/factories/analysis'; + import SbomProjectFactory, { SBOM_PROJECT_FACTORY_DEF, } from 'irene/mirage/factories/sbom-project'; @@ -51,6 +55,18 @@ type FlattenFactoryMethods = { type IncludeBaseFactoryProps = FlattenFactoryMethods & FlattenFactoryMethods; +type AnalysisModelFactoryDef = FlattenFactoryMethods< + typeof ANALYSIS_FACTORY_DEF & { + id: number; + vulnerability: number; + overridden_by: string; + overridden_date: Date; + overridden_risk: number; + overridden_risk_comment: string; + override_criteria: string; + } +>; + export interface MirageFactoryDefProps { user: FlattenFactoryMethods; 'upload-app': FlattenFactoryMethods; @@ -72,8 +88,11 @@ export interface MirageFactoryDefProps { project: IncludeBaseFactoryProps & { last_file_id: number; }; + vulnerability: IncludeBaseFactoryProps; + analysis: AnalysisModelFactoryDef; + 'unknown-analysis-status': IncludeBaseFactoryProps< typeof UNKNOWN_ANALYSIS_STATUS_FACTORY_DEF >; @@ -82,6 +101,7 @@ export interface MirageFactoryDefProps { typeof FILE_FACTORY_DEF & { project: number; executable_name: string; + analyses: Array; } >; } @@ -96,6 +116,7 @@ const MIRAGE_FACTORIES: Record< file: FileFactory, project: ProjectFactory, vulnerability: VulnerabilityFactory, + analysis: AnalysisFactory, 'unknown-analysis-status': UnknownAnalysisStatus, 'organization-member': OrganizationMember, user: User, diff --git a/cypress/support/api.routes.ts b/cypress/support/api.routes.ts index 101edd9de..1638a6ba1 100644 --- a/cypress/support/api.routes.ts +++ b/cypress/support/api.routes.ts @@ -53,6 +53,10 @@ export const API_ROUTES = { route: '/api/hudson-api/projects', alias: 'hudsonProjectList', }, + vulnerabilityPreferenceList: { + route: '/api/profiles/*/vulnerability_preferences', + alias: 'vulnerabilityPreferenceList', + }, // Single Record routes file: { route: '/api/v2/files', alias: 'file' }, @@ -77,4 +81,8 @@ export const API_ROUTES = { route: '/api/profiles/*/device_preference?*', alias: 'devicePreference', }, + editAnalysisRisk: { + route: '/api/files/*/vulnerability_preferences/*/risk', + alias: 'editAnalysisRisk', + }, } as const; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 7a89fc608..97b4c9d25 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -41,3 +41,34 @@ Cypress.Commands.add( return cy.wrap(values); } ); + +Cypress.Commands.add('getBySel', (selector, ...args) => { + return cy.get(`[data-test=${selector}]`, ...args); +}); + +Cypress.Commands.add('getBySelLike', (selector, ...args) => { + return cy.get(`[data-test*=${selector}]`, ...args); +}); + +Cypress.Commands.add( + 'makeAuthenticatedAPIRequest', + ( + options: Partial + ): Cypress.Chainable> => { + return cy + .window() + .its('localStorage') + .invoke('getItem', 'ember_simple_auth-session') + .then((authInfo) => { + const authDetails = authInfo ? JSON.parse(authInfo) : {}; + + return cy.request({ + ...options, + headers: { + ...options.headers, + Authorization: `Basic ${authDetails['authenticated']['b64token']}`, + }, + }); + }); + } +); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 8ee82c9b1..c2e7ec5bf 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,5 +1,17 @@ declare namespace Cypress { + type CutomCypressSelectCommand = ( + selector: string, + opts?: Partial + ) => Chainable>; + interface Chainable { getAliases(names: string[]): Chainable; + + getBySel: CutomCypressSelectCommand; + getBySelLike: CutomCypressSelectCommand; + + makeAuthenticatedAPIRequest: ( + options: Partial + ) => Cypress.Chainable>; } } diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 8aaa972f0..920f367e0 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -75,3 +75,16 @@ export function removeHostFromUrl(urlString: string): string { return urlWithoutHost; } + +/** + * Returns the corresponding text description for a given vulnerability type. + * + * @param {number} vulnType - The numeric code representing the type of vulnerability. + * @returns {string | undefined} The text description of the vulnerability type + * (e.g., 'static', 'dynamic', 'manual', 'api'), or `undefined` if the code is not recognized. + */ +export function getVulnerabilityTypeText(vulnType: number): string { + const typeTextMap = { 1: 'static', 2: 'dynamic', 3: 'manual', 4: 'api' }; + + return typeTextMap[vulnType as keyof typeof typeTextMap]; +} diff --git a/cypress/tests/ignore-vulnerability.spec.ts b/cypress/tests/ignore-vulnerability.spec.ts new file mode 100644 index 000000000..08f48a1ca --- /dev/null +++ b/cypress/tests/ignore-vulnerability.spec.ts @@ -0,0 +1,603 @@ +import cyTranslate from '../support/translations'; +import { type MirageFactoryDefProps } from '../support/Mirage'; + +import LoginActions from '../support/Actions/auth/LoginActions'; +import NetworkActions from '../support/Actions/common/NetworkActions'; + +import IgnoreVulnerabilityActions, { + RISK_TEXT_MAP, + type RiskTextKeys, + type VulnerabilityListResponse, + type AnalysisItemProps, +} from '../support/Actions/common/IgnoreVulnerabilityActions'; + +import { API_ROUTES } from '../support/api.routes'; +import { APPLICATION_ROUTES } from '../support/application.routes'; +import { getVulnerabilityTypeText } from '../support/utils'; + +// Grouped test Actions +const loginActions = new LoginActions(); +const networkActions = new NetworkActions(); +const iVActions = new IgnoreVulnerabilityActions(); + +// User credentials +const username = Cypress.env('TEST_USERNAME'); +const password = Cypress.env('TEST_PASSWORD'); +const API_HOST = Cypress.env('API_HOST'); + +const ignoreVulnPrjPackageName = Cypress.env( + 'IGNORE_VULNERABILITY_TEST_PACKAGE_NAME' +); + +// Assertion Timeout Overrides +const DEFAULT_ASSERT_OPTS = { + timeout: 120000, +}; + +const NETWORK_WAIT_OPTS = { + timeout: 60000, +}; + +// fixes cross origin errors +Cypress.on('uncaught:exception', () => { + // returning false here prevents Cypress from failing the test + return false; +}); + +// Test body +describe('Ignore Vulnerability', () => { + beforeEach(() => { + // Hide websocket and analyses logs + networkActions.hideNetworkLogsFor({ ...API_ROUTES.websockets }); + + /* TODO: Remove or retain this interception based on whether + * access to submissions API is restricted or not. + * ------------------------------------------------- + * Necessary to intercept submission requests that were returning 404 + * This was causing the submission adapters to fail + */ + networkActions + .interceptParameterizedRoute<{ subId: string }>({ + route: '/api/submissions/:subId', + routeHandler: async (req, params) => { + req.continue((res) => { + if (res.statusCode === 404) { + res.send({ + statusCode: 200, + body: { + id: params.subId, + status: 7, + }, + }); + } + }); + }, + }) + .as('submissionReq'); + + // Login or restore session + loginActions.loginWithCredAndSaveSession({ username, password }); + + // Network interceptions + cy.intercept(API_ROUTES.check.route).as('checkUserRoute'); + cy.intercept(API_ROUTES.userInfo.route).as('userInfoRoute'); + cy.intercept(API_ROUTES.projectList.route).as('orgProjectList'); + cy.intercept(API_ROUTES.submissionList.route).as('submissionList'); + + cy.intercept(API_ROUTES.vulnerabilityPreferenceList.route).as( + 'vulnerabilityPreferenceList' + ); + + cy.intercept(API_ROUTES.vulnerabilityList.route).as( + 'vulnerabilityListResponse' + ); + }); + + // Test Scenarios + const ignoreVulnerabilityScenarios = [ + { + currentFileOnly: true, + toPassed: true, + }, + { + currentFileOnly: true, + toPassed: false, + }, + { + allFutureAnalysis: true, + toPassed: false, + resetForCurrentFileOnly: true, + }, + { + allFutureAnalysis: true, + toPassed: false, + removeOverride: true, + checkPrjSettingsOverrideAfterReset: true, + }, + { + allFutureAnalysis: true, + toPassed: false, + removeOverride: true, + resetOverrideFromPrjSettings: true, + }, + { + allFutureAnalysis: true, + toPassed: true, + removeOverride: true, + }, + { + allFutureAnalysis: true, + toPassed: true, + resetForCurrentFileOnly: true, + checkOverrideInPrjSettings: true, + }, + ]; + + ignoreVulnerabilityScenarios.forEach((testParams) => { + const { + currentFileOnly, + allFutureAnalysis, + toPassed, + resetForCurrentFileOnly, + removeOverride, + checkOverrideInPrjSettings, + checkPrjSettingsOverrideAfterReset, + resetOverrideFromPrjSettings, + } = testParams; + + const filesToIgnoreFor = currentFileOnly + ? '"current file"' + : '"all future files"'; + + const suplementaryRiskDesc = toPassed + ? 'from Risk --> Passed' + : 'from Risk --> Risk '; + + const suplementaryResetDesc = resetOverrideFromPrjSettings + ? 'from Project Settings Page' + : removeOverride + ? 'for "Current File + All Future Files"' + : 'for "Current File Only"'; + + it( + `It ignores vulnerability ${suplementaryRiskDesc} (CRITERIA: ${filesToIgnoreFor}) (RESET: ${suplementaryResetDesc}) `, + { retries: { runMode: 2 } }, + function () { + // Used for clean up in afterEach + cy.wrap(false).as('testCompleted'); + + // Visit projects page + cy.visit(APPLICATION_ROUTES.projects); + + // Necessary API call before showing dashboard elements + cy.wait('@submissionList', NETWORK_WAIT_OPTS); + + // Save vulnerability list for later assertions + cy.wait('@vulnerabilityListResponse', NETWORK_WAIT_OPTS) + .its('response.body') + .then((vulnerabilities: { data: VulnerabilityListResponse }) => + cy.wrap(vulnerabilities.data).as('vulnerabilityList') + ); + + // Get project card to click + cy.wait('@orgProjectList', NETWORK_WAIT_OPTS) + .its('response.body') + .then( + (projectListRes: { + results: Array; + }) => { + const projectList = projectListRes.results; + + const ignoreVulTestProject = projectList.find((p) => + ignoreVulnPrjPackageName.includes(p.package_name) + ); + + if (ignoreVulTestProject) { + const lastFileId = ignoreVulTestProject.last_file_id; + const ignoreVulTestPrjFileIntURL = `${API_ROUTES.file.route}/${lastFileId}`; + + cy.wrap(ignoreVulTestProject.id).as('ignoreVulTestProjectId'); + cy.wrap(lastFileId).as('ignoreVulTestProjectLatestFile'); + + cy.intercept('GET', ignoreVulTestPrjFileIntURL).as( + 'ignoreVulTestPrjFileRes' + ); + } + } + ); + + // Sanity check for when in project listing page + cy.findByText(cyTranslate('startNewScan')).should('exist'); + + // Wait for project list cards to completely display + cy.wait(12000); + + // Go to latest file to perform tests in + cy.get('@ignoreVulTestProjectLatestFile').then((id) => + cy + .findByTestId(`project-overview-${id}`, DEFAULT_ASSERT_OPTS) + .click({ force: true }) + ); + + // Check if in file page + cy.url().should( + 'contain', + APPLICATION_ROUTES.file, + DEFAULT_ASSERT_OPTS + ); + + // Wait for ignore vulnerability file to load + cy.wait('@ignoreVulTestPrjFileRes', NETWORK_WAIT_OPTS) + .its('response.body') + .then((file: MirageFactoryDefProps['file']) => { + const analyses = file.analyses; + + // File details check + cy.findByAltText(`${file.name} - logo`).should('exist'); + cy.wrap(file).its('id').should('exist'); + cy.wrap(file).its('name').should('exist'); + + // Get first risky analyses + const riskyAnalysis = analyses.find((a) => { + return ( + a.overridden_risk === null && // Ignore edited risks + Number(a.computed_risk) > 0 // Ignore passed analyses + ); + }); + + const riskyAnalysisRisk = riskyAnalysis?.computed_risk; + const riskyAnalysisPageURL = `/dashboard/file/${file.id}/analysis/${riskyAnalysis?.id}`; + + const riskyAnalysisRiskText = + RISK_TEXT_MAP[riskyAnalysisRisk as RiskTextKeys]; + + // Get possible risks to select + const possibleRisksToEditTo = Object.keys(RISK_TEXT_MAP).filter( + (r) => r !== String(riskyAnalysis?.computed_risk) && r !== '0' + ); + + // '0' represents Passed + cy.wrap(toPassed ? 0 : possibleRisksToEditTo[0]).as('riskToEditTo'); + + cy.wrap(riskyAnalysis).as('riskyAnalysis'); + cy.wrap(riskyAnalysisRisk).as('riskyAnalysisRisk'); + cy.wrap(riskyAnalysisPageURL).as('riskyAnalysisPageURL'); + cy.wrap(riskyAnalysis?.id).as('riskyAnalysisId'); + + // Get vulnerability info + cy.get('@vulnerabilityList').then( + (vulnerabilities) => { + const vulnAttributes = vulnerabilities.find( + (v) => v.id === riskyAnalysis?.vulnerability + )?.attributes; + + // Save analysis vulnerability for later use + cy.wrap({ + ...vulnAttributes, + id: riskyAnalysis?.vulnerability, + }).as('riskyAnalysisVuln'); + + // Assert vulnerability details in analysis row + cy.findByTestId(`file-analysis-${riskyAnalysis?.id}`) + .as('riskyAnalysisRiskRow') + .should('exist') + .within(() => { + // Check vulnerability details + iVActions.assertMultipleTextInfo([ + riskyAnalysisRiskText, + String(vulnAttributes?.name.trim()), + ]); + + // Check that the appropriate scan types exists in row + vulnAttributes?.types.forEach((t) => + iVActions.assertMultipleTextInfo([ + getVulnerabilityTypeText(t), + ]) + ); + }); + } + ); + }); + + // Go to analysis page + cy.get('@riskyAnalysisRiskRow').click(); + + // Check if in analysis page + cy.get('@riskyAnalysisPageURL').then((url) => + cy.url().should('contain', url) + ); + + cy.getAliases(['@riskToEditTo', '@riskyAnalysisRisk']).then( + ([r1, r2]) => { + const riskTextToModifyTo = RISK_TEXT_MAP[r1 as RiskTextKeys]; + const riskyAnalysisRiskText = RISK_TEXT_MAP[r2 as RiskTextKeys]; + + // Check if in the correct analysis page + iVActions + .getAnalysisVulnerability('@riskyAnalysisVuln') + .then((v) => { + cy.findByTestId('analysisDetails-header').within(() => { + iVActions.assertMultipleTextInfo([ + riskyAnalysisRiskText, + v.name, + ]); + }); + + // Open edit analysis drawer + iVActions.openEditDrawer('edit analysis button'); + + // Check if edit drawer is open + iVActions.getEditAnalysisDrawer().within(() => { + iVActions.assertMultipleTextInfo([ + riskyAnalysisRiskText, + v.name, + cyTranslate('editOverrideVulnerability.overrideTo'), + cyTranslate('reason'), + ]); + }); + + iVActions.doEditAnalysisDetails({ + toPassed, + riskTextToModifyTo, + allFutureAnalysis, + isInPrjSettingsPage: false, + }); + + iVActions.findBtnByLabelAndClick(cyTranslate('save')); + }); + + // Intercept analysis item response after saving + iVActions.interceptAnalysisItemRes({ + idAlias: '@riskyAnalysisId', + intAlias: 'getAnalysisItemRes', + }); + + // Wait for save action to complete + cy.wait( + ['@getAnalysisItemRes', '@getAnalysisItemRes'], + DEFAULT_ASSERT_OPTS + ).then(([, int]) => { + cy.wrap(int?.response?.body).as('updatedRiskyAnalysis'); + + // Assert UI after save action + cy.findByText( + cyTranslate('fileAnalysisDetails.overrideSuccessMessage') + ).should('exist'); + + cy.findByTestId( + 'overrideEditDrawer-overrideForm-successFromToRiskIcon' + ).should('exist'); + + cy.findByTestId( + 'overrideEditDrawer-overrideForm-successOriginalRisk' + ) + .should('exist') + .contains(new RegExp(riskyAnalysisRiskText, 'i')); + + cy.findByTestId( + 'overrideEditDrawer-overrideForm-successOverriddenRisk' + ) + .should('exist') + .contains(new RegExp(riskTextToModifyTo, 'i')); + }); + + iVActions.closeEditDrawer(); + + cy.wait(4000); + + // Check if ignore vulnerability icon shows in file details page for 'ignored vulnerabilities' + // And if file "passed" risk sorting is in order + if (!allFutureAnalysis && toPassed) { + iVActions.goToFileDetailsFromAnalysis( + '@ignoreVulTestProjectLatestFile' + ); + + cy.findByTestId( + 'fileChartSeverityLevel-ignoreVulnerabilityIcon', + DEFAULT_ASSERT_OPTS + ).should('exist'); + + // Find all passed risk and check if first is overridden + cy.getBySel('file-analysis-computedRisk-0') + .first() + .should( + 'have.attr', + 'data-test-file-analysis-isOverriddenAsPassed', + 'true' + ); + + // Go back to analysis details page + cy.get('@riskyAnalysisRiskRow').click(); + } + + // Check if override is in project analysis settings page + if (allFutureAnalysis && checkOverrideInPrjSettings) { + iVActions.goToFileDetailsFromAnalysis( + '@ignoreVulTestProjectLatestFile' + ); + + iVActions.goToPrjAnalysisSettings('@ignoreVulTestProjectId'); + + cy.wait('@vulnerabilityPreferenceList', DEFAULT_ASSERT_OPTS); + + // Do analysis edit details check in edit drawer + iVActions + .getAnalysisVulnerability('@riskyAnalysisVuln') + .then((v) => { + iVActions.getVulnPrefItemRow(v.id).within(() => { + iVActions.assertMultipleTextInfo([ + v.name, + riskTextToModifyTo, + ]); + + iVActions.openEditDrawer('vulnerability preference action'); + }); + + // Assert analysis details in drawer + iVActions.assertEditedAnalysisInfo('@updatedRiskyAnalysis', { + riskTextToModifyTo, + riskyAnalysisRiskText, + }); + + iVActions.closeEditDrawer(); + }); + + iVActions.doGoToAnalysisDetailsPage( + '@ignoreVulTestProjectLatestFile', + '@riskyAnalysisId' + ); + } + + // Open edit analysis drawer + iVActions.openEditDrawer('edit analysis button'); + + // Check if edit drawer is open + iVActions.getEditAnalysisDrawer().within(() => { + // Assert analysis details in drawer + iVActions.assertEditedAnalysisInfo('@updatedRiskyAnalysis', { + riskTextToModifyTo, + riskyAnalysisRiskText, + }); + + // Only do override from analysis details page if not coerced to + // Necessary for testing override from project settings page + if (!resetOverrideFromPrjSettings) { + iVActions.doResetAnalysis({ + resetForCurrentFileOnly, + allFutureAnalysis, + removeOverride, + }); + } + + iVActions.closeEditDrawer(); + }); + + // Perform check to ensure reset is reflected in project settings also + if (checkPrjSettingsOverrideAfterReset) { + iVActions.goToFileDetailsFromAnalysis( + '@ignoreVulTestProjectLatestFile' + ); + + iVActions.goToPrjAnalysisSettings('@ignoreVulTestProjectId'); + + iVActions + .getAnalysisVulnerability('@riskyAnalysisVuln') + .then((v) => { + iVActions.getVulnPrefItemRow(v.id).within(() => { + cy.findByText(v.name).should('exist'); + cy.findByText(riskTextToModifyTo).should('not.exist'); + cy.findByText(riskyAnalysisRiskText).should('not.exist'); + + iVActions.openEditDrawer('vulnerability preference action'); + }); + + // Edit reason should be empty after reset + iVActions.getEditAnalysisDrawer().within(() => { + cy.get('@editOverrideVulnerabilityReason') + .should('exist') + .should('not.have.value'); + }); + }); + } + + // Perform "All future analysis" reset from project settings page + if (allFutureAnalysis && resetOverrideFromPrjSettings) { + iVActions.goToFileDetailsFromAnalysis( + '@ignoreVulTestProjectLatestFile' + ); + + iVActions.goToPrjAnalysisSettings('@ignoreVulTestProjectId'); + + cy.wait('@vulnerabilityPreferenceList', DEFAULT_ASSERT_OPTS); + + iVActions + .getAnalysisVulnerability('@riskyAnalysisVuln') + .then((v) => { + iVActions.getVulnPrefItemRow(v.id).within(() => + // Open edit drawer + iVActions.openEditDrawer('vulnerability preference action') + ); + + // Assert default unedited state in edit drawer + iVActions.getEditAnalysisDrawer().within(() => { + iVActions.doResetAnalysis({ + resetForCurrentFileOnly, + allFutureAnalysis, + removeOverride, + isInPrjSettingsPage: true, + }); + + iVActions.closeEditDrawer(); + }); + + // Analysis row should be reset + iVActions.getVulnPrefItemRow(v.id).within(() => { + cy.findByText(v.name).should('exist'); + cy.findByText(riskTextToModifyTo).should('not.exist'); + }); + + iVActions.interceptAnalysisItemRes({ + idAlias: '@riskyAnalysisId', + intAlias: 'updatedAnalysisItem', + }); + + iVActions.doGoToAnalysisDetailsPage( + '@ignoreVulTestProjectLatestFile', + '@riskyAnalysisId' + ); + + // Open edit analysis drawer + iVActions.openEditDrawer('edit analysis button'); + + // Get updated info of reset analysis + cy.wait('@updatedAnalysisItem') + .its('response.body') + .then((resetAnalysisItem) => { + cy.wrap(resetAnalysisItem).as('resetAnalysisItem'); + }); + + // Assert that updated info is being shown correctly + iVActions.getEditAnalysisDrawer().within(() => { + // Edited props should still exist + iVActions.assertEditedAnalysisInfo('@resetAnalysisItem', { + riskTextToModifyTo, + riskyAnalysisRiskText, + }); + + // Reset from project settings alters the "All future analyses" to "Current file only" + iVActions.doResetAnalysis({ + resetForCurrentFileOnly, + currentFileOnly: true, + removeOverride: false, + }); + + iVActions.closeEditDrawer(); + }); + }); + } + + cy.wrap(true).as('testCompleted'); + } + ); + } + ); + }); + + afterEach(() => { + // Reset edited analysis if test fails + cy.get('@testCompleted').then((testCompleted) => { + if (!testCompleted) { + cy.getAliases([ + '@ignoreVulTestProjectLatestFile', + '@riskyAnalysis', + ]).then(([fileId, analysis]) => + cy.makeAuthenticatedAPIRequest({ + method: 'DELETE', + url: `${API_HOST}/api/files/${fileId}/vulnerability_preferences/${(analysis as AnalysisItemProps).vulnerability}/risk`, + body: { all: true }, + }) + ); + } + }); + }); +}); diff --git a/mirage/factories/analysis.js b/mirage/factories/analysis.ts similarity index 79% rename from mirage/factories/analysis.js rename to mirage/factories/analysis.ts index e919b3421..b73b82397 100644 --- a/mirage/factories/analysis.js +++ b/mirage/factories/analysis.ts @@ -1,8 +1,9 @@ -import { Factory, trait } from 'miragejs'; +// @ts-expect-error "trait" prop missing from miragejs +import { Factory, ModelInstance, Server, trait } from 'miragejs'; import { faker } from '@faker-js/faker'; import ENUMS from 'irene/enums'; -export default Factory.extend({ +export const ANALYSIS_FACTORY_DEF = { analiser_version: 1, cvss_version: 3, cvss_base: 10.0, @@ -20,7 +21,7 @@ export default Factory.extend({ ], isIgnored: faker.datatype.boolean(), - overriden_risk: faker.helpers.arrayElement([null, 1, 2, 3, 4]), + overridden_risk: faker.helpers.arrayElement([null, 1, 2, 3, 4]), status: faker.helpers.arrayElement(ENUMS.ANALYSIS.VALUES), created_on: faker.date.past(), updated_on: faker.date.past(), @@ -34,7 +35,7 @@ export default Factory.extend({ }, findings() { - let desc = []; + const desc = []; const uuid = faker.string.uuid(); for (let i = 0; i < 3; i++) { @@ -46,9 +47,12 @@ export default Factory.extend({ return desc; }, +}; +export default Factory.extend({ + ...ANALYSIS_FACTORY_DEF, withOwasp: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ owasp: server.createList('owasp', 2).map((it) => it.id), }); @@ -56,7 +60,7 @@ export default Factory.extend({ }), withOwaspMobile2024: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ owaspmobile2024: server .createList('owaspmobile2024', 2) @@ -66,7 +70,7 @@ export default Factory.extend({ }), withOwaspApi2023: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ owaspapi2023: server.createList('owaspapi2023', 2).map((it) => it.id), }); @@ -74,7 +78,7 @@ export default Factory.extend({ }), withCwe: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ cwe: server.createList('cwe', 2).map((it) => it.id), }); @@ -82,7 +86,7 @@ export default Factory.extend({ }), withAsvs: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ asvs: server.createList('asvs', 2).map((it) => it.id), }); @@ -90,7 +94,7 @@ export default Factory.extend({ }), withMasvs: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ masvs: server.createList('masvs', 2).map((it) => it.id), }); @@ -98,7 +102,7 @@ export default Factory.extend({ }), withMstg: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ mstg: server.createList('mstg', 2).map((it) => it.id), }); @@ -106,7 +110,7 @@ export default Factory.extend({ }), withPcidss: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ pcidss: server.createList('pcidss', 2).map((it) => it.id), }); @@ -114,7 +118,7 @@ export default Factory.extend({ }), withPcidss4: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ pcidss4: server.createList('pcidss4', 2).map((it) => it.id), }); @@ -122,7 +126,7 @@ export default Factory.extend({ }), withHipaa: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ hipaa: server.createList('hipaa', 2).map((it) => it.id), }); @@ -130,7 +134,7 @@ export default Factory.extend({ }), withGdpr: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ gdpr: server.createList('gdpr', 2).map((it) => it.id), }); @@ -138,7 +142,7 @@ export default Factory.extend({ }), withNistsp80053: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ nistsp80053: server.createList('nistsp80053', 2).map((it) => it.id), }); @@ -146,7 +150,7 @@ export default Factory.extend({ }), withNistsp800171: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ nistsp800171: server.createList('nistsp800171', 2).map((it) => it.id), }); @@ -154,7 +158,7 @@ export default Factory.extend({ }), withSama: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ sama: server.createList('sama', 2).map((it) => it.id), }); @@ -162,7 +166,7 @@ export default Factory.extend({ }), withAllRegulatory: trait({ - afterCreate(model, server) { + afterCreate(model: ModelInstance, server: Server) { model.update({ owasp: server.createList('owasp', 2).map((it) => it.id), owaspmobile2024: server