+ {{ + s__('Vulnerability|Gitlab Duo has identified this vulnerability as a false positive') + }} +
+{{ vulnerability.latestFlag.description }}
+{ + let wrapper; + let apolloMutateSpy; + + const defaultVulnerability = { + id: 'gid://gitlab/Vulnerabilities::Finding/123', + title: 'Test Vulnerability', + state: VULNERABILITY_UNTRIAGED_STATUS, + latestFlag: { + status: EXPECTED_STATUS, + confidenceScore: 0.5, + description: 'This is likely a false positive because...', + }, + }; + + const createComponent = (props = {}, options = {}) => { + apolloMutateSpy = jest.fn().mockResolvedValue({}); + + return shallowMountExtended(AiPossibleFpBadge, { + propsData: { + vulnerability: { + ...defaultVulnerability, + ...props.vulnerability, + }, + canAdminVulnerability: false, + ...props, + }, + stubs: { + GlPopover, + }, + mocks: { + $apollo: { + mutate: apolloMutateSpy, + }, + }, + ...options, + }); + }; + + const findBadge = () => wrapper.findComponent(GlBadge); + const findBadgeText = () => wrapper.findByTestId('ai-fix-in-progress-b'); + const findProgressBar = () => wrapper.findComponent(GlProgressBar); + const findRemoveFlagButton = () => wrapper.findComponent(GlButton); + + describe('when confidence score is between minimal and likely threshold', () => { + beforeEach(() => { + wrapper = createComponent({ + vulnerability: { + latestFlag: { + status: EXPECTED_STATUS, + confidenceScore: 0.5, + }, + }, + }); + }); + + it('renders the badge with warning variant', () => { + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('renders "Possible FP" text', () => { + expect(findBadgeText().text()).toBe('Possible FP'); + }); + + it('renders the confidence score correctly', () => { + expect(wrapper.text()).toContain('AI Confidence Score'); + expect(findProgressBar().props('value')).toBe(50); + expect(findProgressBar().props('variant')).toBe('warning'); + expect(wrapper.text()).toContain('50%'); + }); + }); + + describe('when confidence score is above the likely threshold', () => { + beforeEach(() => { + wrapper = createComponent({ + vulnerability: { + latestFlag: { + status: EXPECTED_STATUS, + confidenceScore: CONFIDENCE_SCORES.LIKELY_FALSE_POSITIVE + 0.1, + }, + }, + }); + }); + + it('renders the badge with success variant', () => { + expect(findBadge().props('variant')).toBe('success'); + }); + + it('renders "Likely FP" text', () => { + expect(findBadgeText().text()).toBe('Likely FP'); + }); + + it('renders the progress bar with success variant', () => { + expect(findProgressBar().props('variant')).toBe('success'); + expect(findProgressBar().props('value')).toBe(80); + }); + }); + + describe('when confidence score is below the minimal threshold', () => { + beforeEach(() => { + wrapper = createComponent({ + vulnerability: { + latestFlag: { + status: EXPECTED_STATUS, + confidenceScore: CONFIDENCE_SCORES.MINIMAL - 0.1, + }, + }, + }); + }); + + it('does not render the badge', () => { + expect(findBadge().exists()).toBe(false); + }); + }); + + describe('when status is not the expected one', () => { + beforeEach(() => { + wrapper = createComponent({ + vulnerability: { + latestFlag: { + status: 'SOME_OTHER_STATUS', + confidenceScore: 0.9, + }, + }, + }); + }); + + it('does not render the badge', () => { + expect(findBadge().exists()).toBe(false); + }); + }); + + describe('when flag description is present', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the description section', () => { + expect(wrapper.text()).toContain('Why it is likely a false positive'); + expect(wrapper.text()).toContain('This is likely a false positive because...'); + }); + }); + + describe('when flag description is not present', () => { + beforeEach(() => { + wrapper = createComponent({ + vulnerability: { + latestFlag: { + status: EXPECTED_STATUS, + confidenceScore: 0.5, + description: null, + }, + }, + }); + }); + + it('does not render the description section', () => { + expect(wrapper.text()).not.toContain('Why it is likely a false positive'); + }); + }); + + describe('Apollo mutations', () => { + beforeEach(() => { + wrapper = createComponent({ + canAdminVulnerability: true, + }); + }); + + it('renders the remove flag button', () => { + expect(findRemoveFlagButton().exists()).toBe(true); + expect(findRemoveFlagButton().text()).toBe('Remove False Positive Flag'); + }); + + it('calls Apollo mutation with correct parameters when removing flag', async () => { + await findRemoveFlagButton().vm.$emit('click'); + + expect(apolloMutateSpy).toHaveBeenCalledWith({ + mutation: VULNERABILITY_STATE_OBJECTS.dismissed.mutation, + variables: { + id: defaultVulnerability.id, + dismissalReason: 'FALSE_POSITIVE', + comment: AI_FP_DISMISSAL_COMMENT, + }, + refetchQueries: [null], + }); + }); + }); + + describe('when user cannot admin vulnerability', () => { + beforeEach(() => { + wrapper = createComponent({ + canAdminVulnerability: false, + }); + }); + + it('does not render the remove flag button', () => { + expect(findRemoveFlagButton().exists()).toBe(false); + }); + }); + + describe('when vulnerability is not untriaged', () => { + beforeEach(() => { + wrapper = createComponent({ + canAdminVulnerability: true, + vulnerability: { + ...defaultVulnerability, + state: 'RESOLVED', + }, + }); + }); + + it('does not render the remove flag button', () => { + expect(findRemoveFlagButton().exists()).toBe(false); + }); + }); +}); diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js index 439b29d53a0c656bee6cced4887296b58c4c97ad..4b99c4f225e80714d9817dcd37c40828310164ad 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js @@ -62,6 +62,7 @@ describe('Vulnerability list GraphQL component', () => { showProjectNamespace = false, hasJiraVulnerabilitiesIntegrationEnabled = false, hideVulnerabilitySeverityOverride = true, + aiExperimentSastFpDetection = false, dashboardType = DASHBOARD_TYPE_GROUP, apolloProvider = createMockApollo([[groupVulnerabilitiesQuery, vulnerabilitiesHandler]]), reportGroupFilters, @@ -81,6 +82,7 @@ describe('Vulnerability list GraphQL component', () => { hasJiraVulnerabilitiesIntegrationEnabled, glFeatures: { hideVulnerabilitySeverityOverride, + aiExperimentSastFpDetection, }, ...provide, }, @@ -222,6 +224,24 @@ describe('Vulnerability list GraphQL component', () => { expect.objectContaining({ includeSeverityOverrides: true }), ); }); + + it('calls query with correct params when `aiExperimentSastFpDetection` FF is disabled', () => { + createWrapper({ aiExperimentSastFpDetection: false }); + + expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1); + expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ includeLatestFlag: false }), + ); + }); + + it('calls query with correct params when `aiExperimentSastFpDetection` FF is enabled', () => { + createWrapper({ aiExperimentSastFpDetection: true }); + + expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1); + expect(vulnerabilitiesRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ includeLatestFlag: true }), + ); + }); }); describe('vulnerability list component', () => { diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js index 1bbed3385c87a45ef1becf71129c468ceb8edae7..01841068e68a01e3e37b77d7c84a25abb9816fd9 100644 --- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js +++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js @@ -11,9 +11,10 @@ import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerabi import VulnerabilityListStatus from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_status.vue'; import MergeRequestBadge from 'ee/security_dashboard/components/shared/merge_request_badge.vue'; import SolutionBadge from 'ee/security_dashboard/components/shared/solution_badge.vue'; +import AiFixedBadge from 'ee/security_dashboard/components/shared/ai_fixed_badge.vue'; import AiResolutionBadge from 'ee/security_dashboard/components/shared/ai_resolution_badge.vue'; import AiFixInProgressBadge from 'ee/security_dashboard/components/shared/ai_fix_in_progress_badge.vue'; -import AiFixedBadge from 'ee/security_dashboard/components/shared/ai_fixed_badge.vue'; +import AiPossibleFpBadge from 'ee/security_dashboard/components/shared/ai_possible_fp_badge.vue'; import { DASHBOARD_TYPE_PROJECT } from 'ee/security_dashboard/constants'; import FalsePositiveBadge from 'ee/vulnerabilities/components/false_positive_badge.vue'; import ArchivalBadge from 'ee/vulnerabilities/components/archival_badge.vue'; @@ -75,7 +76,10 @@ describe('Vulnerability list component', () => { hasJiraVulnerabilitiesIntegrationEnabled: false, canAdminVulnerability: true, validityChecksEnabled: false, - glFeatures: { validityChecksSecurityFindingStatus: true }, + glFeatures: { + validityChecksSecurityFindingStatus: true, + aiExperimentSastFpDetection: false, + }, ...provide, }), }); @@ -876,6 +880,67 @@ describe('Vulnerability list component', () => { it('should not render when the vulnerability does not have a false positive', () => { expect(findFalsePositiveBadgeInRow(1).exists()).toBe(false); }); + + describe('AI possible false positive badge', () => { + const findAiPossibleFpBadgeInRow = findComponentBadgeInRow(AiPossibleFpBadge); + + describe('when aiExperimentSastFpDetection feature flag is enabled', () => { + beforeEach(() => { + newVulnerabilities = generateVulnerabilities(); + newVulnerabilities[0].latestFlag = { + status: 'DETECTED_AS_FP', + confidenceScore: 0.5, + description: 'This appears to be a false positive because...', + }; + createWrapper({ + props: { vulnerabilities: newVulnerabilities }, + provide: { + glFeatures: { aiExperimentSastFpDetection: true }, + canAdminVulnerability: true, + }, + }); + }); + + it('should render AI possible FP badge when vulnerability has latestFlag', () => { + expect(findAiPossibleFpBadgeInRow(0).exists()).toBe(true); + }); + + it('should not render standard false positive badge when AI FP badge is shown', () => { + expect(findFalsePositiveBadgeInRow(0).exists()).toBe(false); + }); + + it('should not render AI possible FP badge for vulnerabilities without latestFlag', () => { + expect(findAiPossibleFpBadgeInRow(1).exists()).toBe(false); + }); + }); + + describe('when aiExperimentSastFpDetection feature flag is disabled', () => { + beforeEach(() => { + newVulnerabilities = generateVulnerabilities(); + newVulnerabilities[0].latestFlag = { + status: 'DETECTED_AS_FP', + confidenceScore: 0.7, + description: 'This appears to be a false positive because...', + }; + newVulnerabilities[0].falsePositive = true; + createWrapper({ + props: { vulnerabilities: newVulnerabilities }, + provide: { + glFeatures: { aiExperimentSastFpDetection: false }, + canAdminVulnerability: true, + }, + }); + }); + + it('should not render AI possible FP badge even when vulnerability has latestFlag', () => { + expect(findAiPossibleFpBadgeInRow(0).exists()).toBe(false); + }); + + it('should render standard false positive badge when feature flag is disabled', () => { + expect(findFalsePositiveBadgeInRow(0).exists()).toBe(true); + }); + }); + }); }); describe('solution badge', () => { diff --git a/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js b/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js index 05d6a66b17a8e0717a5314e608a4cc4dc49eabf0..89d49a5462047c9881342145a6d81bc81c0ac0ba 100644 --- a/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js +++ b/ee/spec/frontend/vulnerabilities/vulnerability_details_spec.js @@ -1,4 +1,5 @@ -import { GlLink, GlLabel } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { GlLink, GlLabel, GlProgressBar } from '@gitlab/ui'; import { getAllByRole, getByTestId } from '@testing-library/dom'; import { MountingPortal } from 'portal-vue'; import ValidityCheck from 'ee/vulnerabilities/components/validity_check.vue'; @@ -6,10 +7,13 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import VulnerabilityDetails from 'ee/vulnerabilities/components/vulnerability_details.vue'; +import { createAlert } from '~/alert'; import { SUPPORTING_MESSAGE_TYPES, VULNERABILITY_TRAINING_HEADING, CODE_FLOW_TAB_URL, + VULNERABILITY_STATE_OBJECTS, + AI_FP_DISMISSAL_COMMENT, } from 'ee/vulnerabilities/constants'; import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue'; import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue'; @@ -18,6 +22,7 @@ import DependencyPath from 'ee/vulnerabilities/components/dependency_path.vue'; import { stubComponent } from 'helpers/stub_component'; jest.mock('~/behaviors/markdown/render_gfm'); +jest.mock('~/alert'); describe('Vulnerability Details', () => { let wrapper; @@ -26,6 +31,15 @@ describe('Vulnerability Details', () => { push: jest.fn(), }; + const $apollo = { + mutate: jest.fn(), + }; + + const mocks = { + $router, + $apollo, + }; + const vulnerability = { id: 123, severity: 'bad severity', @@ -54,6 +68,7 @@ describe('Vulnerability Details', () => { validityChecks = false, vulnerabilityReportTypeScannerFilter = true, dependencyPaths = true, + aiExperimentSastFpDetection = false, } = {}, ) => { const propsData = { @@ -69,6 +84,7 @@ describe('Vulnerability Details', () => { vulnerabilityReportTypeScannerFilter, dependencyPaths, validityChecks, + aiExperimentSastFpDetection, }, }, stubs: { @@ -77,8 +93,9 @@ describe('Vulnerability Details', () => { ? { template: '