Commit 19c87e94 authored by Savas Vedova's avatar Savas Vedova

Merge branch '322889-move-vulnerability-report-alerts' into 'master'

Move scanner warnings in Vulnerability Report

See merge request gitlab-org/gitlab!60716
parents 7082dd0a 3ef54b6f
<script>
import { GlAlert, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import produce from 'immer';
import { difference } from 'lodash';
import { Portal } from 'portal-vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import securityScannersQuery from '../graphql/queries/project_security_scanners.query.graphql';
import vulnerabilitiesQuery from '../graphql/queries/project_vulnerabilities.query.graphql';
import { preparePageInfo } from '../helpers';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
import SecurityScannerAlert from './security_scanner_alert.vue';
import VulnerabilityList from './vulnerability_list.vue';
export default {
......@@ -14,9 +19,15 @@ export default {
GlAlert,
GlLoadingIcon,
GlIntersectionObserver,
LocalStorageSync,
Portal,
SecurityScannerAlert,
VulnerabilityList,
},
inject: {
vulnerabilityReportAlertsPortal: {
default: '',
},
projectFullPath: {
default: '',
},
......@@ -35,6 +46,7 @@ export default {
return {
pageInfo: {},
vulnerabilities: [],
scannerAlertDismissed: false,
securityScanners: {},
errorLoadingVulnerabilities: false,
sortBy: 'severity',
......@@ -97,6 +109,21 @@ export default {
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
notEnabledSecurityScanners() {
const { available = [], enabled = [] } = this.securityScanners;
return difference(available, enabled);
},
noPipelineRunSecurityScanners() {
const { enabled = [], pipelineRun = [] } = this.securityScanners;
return difference(enabled, pipelineRun);
},
shouldShowScannersAlert() {
return (
!this.scannerAlertDismissed &&
(this.notEnabledSecurityScanners.length > 0 ||
this.noPipelineRunSecurityScanners.length > 0)
);
},
},
watch: {
filters() {
......@@ -128,7 +155,11 @@ export default {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = parseBoolean(value);
},
},
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY: 'vulnerability_list_scanner_alert_dismissed',
i18n: {
API_FUZZING: __('API Fuzzing'),
CONTAINER_SCANNING: __('Container Scanning'),
......@@ -148,21 +179,36 @@ export default {
)
}}
</gl-alert>
<vulnerability-list
v-else
:is-loading="isLoadingFirstVulnerabilities"
:filters="filters"
:vulnerabilities="vulnerabilities"
:security-scanners="securityScanners"
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
<template v-else>
<local-storage-sync
:value="String(scannerAlertDismissed)"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<portal v-if="shouldShowScannersAlert" :to="vulnerabilityReportAlertsPortal">
<security-scanner-alert
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
/>
</portal>
<vulnerability-list
:is-loading="isLoadingFirstVulnerabilities"
:filters="filters"
:vulnerabilities="vulnerabilities"
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
</template>
</div>
</template>
......@@ -8,7 +8,6 @@ import {
GlTooltipDirective,
GlTable,
} from '@gitlab/ui';
import { difference } from 'lodash';
import AutoFixHelpText from 'ee/security_dashboard/components/auto_fix_help_text.vue';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/empty_states/dashboard_has_no_vulnerabilities.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/empty_states/filters_produced_no_results.vue';
......@@ -21,15 +20,10 @@ import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
import IssuesBadge from './issues_badge.vue';
import SecurityScannerAlert from './security_scanner_alert.vue';
import SelectionSummary from './selection_summary.vue';
export const SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY =
'vulnerability_list_scanner_alert_dismissed';
export default {
components: {
GlFormCheckbox,
......@@ -39,10 +33,8 @@ export default {
GlTable,
GlTruncate,
IssuesBadge,
LocalStorageSync,
AutoFixHelpText,
RemediatedBadge,
SecurityScannerAlert,
SelectionSummary,
SeverityBadge,
VulnerabilityCommentIcon,
......@@ -67,11 +59,6 @@ export default {
required: false,
default: () => ({}),
},
securityScanners: {
type: Object,
required: false,
default: () => ({}),
},
shouldShowSelection: {
type: Boolean,
required: false,
......@@ -95,7 +82,6 @@ export default {
data() {
return {
selectedVulnerabilities: {},
scannerAlertDismissed: 'false',
sortBy: 'severity',
sortDesc: true,
};
......@@ -117,21 +103,6 @@ export default {
(v) => v.scanner?.vendor !== 'GitLab' && v.scanner?.vendor !== '',
);
},
notEnabledSecurityScanners() {
const { available = [], enabled = [] } = this.securityScanners;
return difference(available, enabled);
},
noPipelineRunSecurityScanners() {
const { enabled = [], pipelineRun = [] } = this.securityScanners;
return difference(enabled, pipelineRun);
},
shouldShowScannersAlert() {
return (
this.scannerAlertDismissed !== 'true' &&
(this.notEnabledSecurityScanners.length > 0 ||
this.noPipelineRunSecurityScanners.length > 0)
);
},
hasSelectedAllVulnerabilities() {
if (!this.filteredVulnerabilities.length) {
return false;
......@@ -261,9 +232,6 @@ export default {
primaryIdentifier(identifiers) {
return getPrimaryIdentifier(identifiers, 'externalType');
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = value;
},
isSelected(vulnerability = {}) {
return Boolean(this.selectedVulnerabilities[vulnerability.id]);
},
......@@ -331,17 +299,11 @@ export default {
},
},
VULNERABILITIES_PER_PAGE,
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
};
</script>
<template>
<div class="vulnerability-list">
<local-storage-sync
:value="scannerAlertDismissed"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<selection-summary
v-if="shouldShowSelectionSummary"
:selected-vulnerabilities="Object.values(selectedVulnerabilities)"
......@@ -376,16 +338,6 @@ export default {
/>
</template>
<template v-if="shouldShowScannersAlert" #top-row>
<td :colspan="fields.length" class="gl-px-0!">
<security-scanner-alert
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
/>
</td>
</template>
<template #cell(checkbox)="{ item }">
<gl-form-checkbox
class="gl-display-inline-block! gl-m-0 gl-pointer-events-none"
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { PortalTarget } from 'portal-vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
......@@ -33,11 +34,17 @@ export default {
DashboardNotConfiguredGroup,
DashboardNotConfiguredInstance,
DashboardNotConfiguredProject,
PortalTarget,
ProjectPipelineStatus,
GlLoadingIcon,
VulnerabilitiesCountList,
},
mixins: [glFeatureFlagsMixin()],
provide() {
return {
vulnerabilityReportAlertsPortal: this.$options.vulnerabilityReportAlertsPortal,
};
},
inject: {
dashboardType: {},
groupFullPath: { default: undefined },
......@@ -106,6 +113,7 @@ export default {
this.shouldShowAutoFixUserCallout = false;
},
},
vulnerabilityReportAlertsPortal: 'vulnerability-report-alerts-portal',
autoFixUserCalloutCookieName: 'auto_fix_user_callout_dismissed',
i18n: {
title: s__('SecurityReports|Vulnerability Report'),
......@@ -123,6 +131,7 @@ export default {
<dashboard-not-configured-project v-else-if="isProject" />
</template>
<template v-else>
<portal-target :name="$options.vulnerabilityReportAlertsPortal" multiple />
<auto-fix-user-callout
v-if="shouldShowAutoFixUserCallout"
:help-page-path="autoFixDocumentation"
......
---
title: Show scanner warning at the top of the Vulnerability Report
merge_request: 60716
author:
type: changed
......@@ -114,7 +114,6 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: false,
securityScanners: {},
shouldShowSelection: true,
shouldShowProjectNamespace: true,
vulnerabilities,
......
......@@ -95,7 +95,6 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: false,
securityScanners: {},
shouldShowSelection: true,
shouldShowProjectNamespace: true,
vulnerabilities,
......
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { Portal } from 'portal-vue';
import VueApollo from 'vue-apollo';
import ProjectVulnerabilitiesApp from 'ee/security_dashboard/components/project_vulnerabilities.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue';
import VulnerabilityList from 'ee/security_dashboard/components/vulnerability_list.vue';
import securityScannersQuery from 'ee/security_dashboard/graphql/queries/project_security_scanners.query.graphql';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { generateVulnerabilities } from './mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Vulnerabilities app component', () => {
useLocalStorageSpy();
let wrapper;
const apolloMock = {
queries: { vulnerabilities: { loading: true } },
......@@ -35,8 +43,21 @@ describe('Vulnerabilities app component', () => {
});
};
const securityScannersHandler = async ({
available = [],
enabled = [],
pipelineRun = [],
} = {}) => ({
data: {
project: {
securityScanners: { available, enabled, pipelineRun },
},
},
});
const findIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findAlert = () => wrapper.find(GlAlert);
const findSecurityScannerAlert = (root = wrapper) => root.findComponent(SecurityScannerAlert);
const findVulnerabilityList = () => wrapper.find(VulnerabilityList);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
......@@ -45,10 +66,6 @@ describe('Vulnerabilities app component', () => {
expect(findLoadingIcon().exists()).toBe(nextPage);
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -165,30 +182,8 @@ describe('Vulnerabilities app component', () => {
});
});
describe('security scanners', () => {
const notEnabledScannersHelpPath = '#not-enabled';
const noPipelineRunScannersHelpPath = '#no-pipeline';
beforeEach(() => {
createWrapper({
props: { notEnabledScannersHelpPath, noPipelineRunScannersHelpPath },
});
});
it('should pass the security scanners to the vulnerability list', () => {
const securityScanners = {
enabled: ['SAST', 'DAST', 'API_FUZZING', 'COVERAGE_FUZZING'],
pipelineRun: ['SAST', 'DAST', 'API_FUZZING', 'COVERAGE_FUZZING'],
};
wrapper.setData({ securityScanners });
expect(findVulnerabilityList().props().securityScanners).toEqual(securityScanners);
});
});
describe('filters prop', () => {
const mockQuery = jest.fn().mockResolvedValue({
const vulnerabilitiesHandler = jest.fn().mockResolvedValue({
data: {
project: {
vulnerabilities: {
......@@ -199,25 +194,125 @@ describe('Vulnerabilities app component', () => {
},
});
const createWrapperWithApollo = ({ query, filters }) => {
const createWrapperWithApollo = ({ filters }) => {
wrapper = shallowMount(ProjectVulnerabilitiesApp, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, query]]),
apolloProvider: createMockApollo([
[vulnerabilitiesQuery, vulnerabilitiesHandler],
[securityScannersQuery, securityScannersHandler],
]),
propsData: { filters },
provide: { groupFullPath: 'path' },
});
};
it('does not run the query when filters is null', () => {
createWrapperWithApollo({ query: mockQuery, filters: null });
createWrapperWithApollo({ filters: null });
expect(mockQuery).not.toHaveBeenCalled();
expect(vulnerabilitiesHandler).not.toHaveBeenCalled();
});
it('runs query when filters is an object', () => {
createWrapperWithApollo({ query: mockQuery, filters: {} });
createWrapperWithApollo({ filters: {} });
expect(vulnerabilitiesHandler).toHaveBeenCalled();
});
});
describe('security scanner alerts', () => {
const vulnerabilityReportAlertsPortal = 'test-alerts-portal';
const createWrapperForScannerAlerts = async ({ securityScanners }) => {
wrapper = shallowMount(ProjectVulnerabilitiesApp, {
localVue,
apolloProvider: createMockApollo([
[securityScannersQuery, () => securityScannersHandler(securityScanners)],
]),
provide: {
vulnerabilityReportAlertsPortal,
projectFullPath: 'path',
},
stubs: {
LocalStorageSync,
},
});
await waitForPromises();
};
describe.each`
available | enabled | pipelineRun | expectAlertShown
${['DAST']} | ${[]} | ${[]} | ${true}
${['DAST']} | ${['DAST']} | ${[]} | ${true}
${['DAST']} | ${[]} | ${['DAST']} | ${true}
${['DAST']} | ${['DAST']} | ${['DAST']} | ${false}
${[]} | ${[]} | ${[]} | ${false}
`('visibility', ({ available, enabled, pipelineRun, expectAlertShown }) => {
beforeEach(() => {});
it(`should${expectAlertShown ? '' : ' not'} show the alert`, async () => {
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
expect(findSecurityScannerAlert().exists()).toBe(expectAlertShown);
});
if (expectAlertShown) {
it('should portal the alert to the provided vulnerabilityReportAlertsPortal', async () => {
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
const portal = wrapper.findComponent(Portal);
expect(portal.props('to')).toBe(vulnerabilityReportAlertsPortal);
expect(findSecurityScannerAlert(portal).exists()).toBe(true);
});
}
it('should never show the alert once it has been dismissed', async () => {
window.localStorage.setItem(
ProjectVulnerabilitiesApp.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
);
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
expect(findSecurityScannerAlert().exists()).toBe(false);
});
});
describe('dismissal', () => {
beforeEach(() => {
return createWrapperForScannerAlerts({
securityScanners: { available: ['DAST'], enabled: [], pipelineRun: [] },
});
});
it('should hide the alert when it is dismissed', async () => {
const scannerAlert = findSecurityScannerAlert();
expect(scannerAlert.exists()).toBe(true);
scannerAlert.vm.$emit('dismiss');
expect(mockQuery).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(scannerAlert.exists()).toBe(false);
});
it('should remember the dismissal state', async () => {
findSecurityScannerAlert().vm.$emit('dismiss');
await wrapper.vm.$nextTick();
expect(window.localStorage.setItem.mock.calls).toContainEqual([
ProjectVulnerabilitiesApp.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
]);
});
});
});
});
......@@ -4,21 +4,15 @@ import { capitalize } from 'lodash';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/empty_states/dashboard_has_no_vulnerabilities.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/empty_states/filters_produced_no_results.vue';
import IssuesBadge from 'ee/security_dashboard/components/issues_badge.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import VulnerabilityCommentIcon from 'ee/security_dashboard/components/vulnerability_comment_icon.vue';
import VulnerabilityList, {
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
} from 'ee/security_dashboard/components/vulnerability_list.vue';
import VulnerabilityList from 'ee/security_dashboard/components/vulnerability_list.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { generateVulnerabilities, vulnerabilities } from './mock_data';
describe('Vulnerability list component', () => {
useLocalStorageSpy();
let wrapper;
const createWrapper = ({ props = {}, listeners, provide = {} } = {}) => {
......@@ -56,8 +50,6 @@ describe('Vulnerability list component', () => {
const findAutoFixBulbInRow = (row) => row.find('[data-testid="vulnerability-solutions-bulb"]');
const findIssuesBadge = (index = 0) => wrapper.findAllComponents(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.findComponent(RemediatedBadge);
const findSecurityScannerAlert = () => wrapper.findComponent(SecurityScannerAlert);
const findDismissalButton = () => findSecurityScannerAlert().find('button[aria-label="Dismiss"]');
const findSelectionSummary = () => wrapper.findComponent(SelectionSummary);
const findRowVulnerabilityCommentIcon = (row) =>
findRow(row).findComponent(VulnerabilityCommentIcon);
......@@ -510,66 +502,6 @@ describe('Vulnerability list component', () => {
});
});
describe('security scanner alerts', () => {
describe.each`
available | enabled | pipelineRun | expectAlertShown
${['DAST']} | ${[]} | ${[]} | ${true}
${['DAST']} | ${['DAST']} | ${[]} | ${true}
${['DAST']} | ${[]} | ${['DAST']} | ${true}
${['DAST']} | ${['DAST']} | ${['DAST']} | ${false}
${[]} | ${[]} | ${[]} | ${false}
`('visibility', ({ available, enabled, pipelineRun, expectAlertShown }) => {
it(`should${expectAlertShown ? '' : ' not'} show the alert`, () => {
wrapper = createWrapper({
props: { securityScanners: { available, enabled, pipelineRun } },
});
expect(findSecurityScannerAlert().exists()).toBe(expectAlertShown);
});
it('should never show the alert once it has been dismissed', async () => {
window.localStorage.setItem(SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY, 'true');
wrapper = createWrapper({
props: { securityScanners: { available, enabled, pipelineRun } },
});
await wrapper.vm.$nextTick();
expect(findSecurityScannerAlert().exists()).toBe(false);
});
});
describe('dismissal', () => {
beforeEach(() => {
wrapper = createWrapper({
props: { securityScanners: { available: ['DAST'], enabled: [] } },
});
});
it('should hide the alert when it is dismissed', async () => {
expect(findSecurityScannerAlert().exists()).toBe(true);
findDismissalButton().trigger('click');
await wrapper.vm.$nextTick();
expect(findSecurityScannerAlert().exists()).toBe(false);
});
it('should remember the dismissal state', async () => {
findDismissalButton().trigger('click');
await wrapper.vm.$nextTick();
expect(window.localStorage.setItem.mock.calls).toContainEqual([
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
]);
});
});
});
describe('when has a sort-changed listener defined', () => {
let spy;
......
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Cookies from 'js-cookie';
import { PortalTarget } from 'portal-vue';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import AutoFixUserCallout from 'ee/security_dashboard/components/auto_fix_user_callout.vue';
......@@ -26,6 +27,7 @@ import { mockVulnerableProjectsInstance, mockVulnerableProjectsGroup } from '../
describe('Vulnerability Report', () => {
let wrapper;
const findAlertsPortalTarget = () => wrapper.findComponent(PortalTarget);
const findSurveyRequestBanner = () => wrapper.findComponent(SurveyRequestBanner);
const findInstanceVulnerabilities = () => wrapper.findComponent(InstanceVulnerabilities);
const findGroupVulnerabilities = () => wrapper.findComponent(GroupVulnerabilities);
......@@ -78,6 +80,12 @@ describe('Vulnerability Report', () => {
});
});
it('renders the alerts portal target', () => {
const portalTarget = findAlertsPortalTarget();
expect(portalTarget.exists()).toBe(true);
expect(portalTarget.props('name')).toBe(VulnerabilityReport.vulnerabilityReportAlertsPortal);
});
it('should show the header', () => {
expect(findHeader().exists()).toBe(true);
});
......@@ -172,6 +180,7 @@ describe('Vulnerability Report', () => {
});
it('only renders the empty state', () => {
expect(findAlertsPortalTarget().exists()).toBe(false);
expect(findGroupEmptyState().exists()).toBe(true);
expect(findInstanceEmptyState().exists()).toBe(false);
expect(findProjectEmptyState().exists()).toBe(false);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment