Commit bc7dd70e authored by Scott Hampton's avatar Scott Hampton

Merge branch '292636-bulk-update-status-vulns' into 'master'

Add ability to bulk update vulnerabilities

See merge request gitlab-org/gitlab!54206
parents 3d6a16f9 eaebfa26
......@@ -50,12 +50,12 @@ The Activity filter behaves differently from the other Vulnerability Report filt
Contents of the unfiltered vulnerability report can be exported using our [export feature](#export-vulnerabilities).
You can also dismiss vulnerabilities in the table:
You can also change the status of vulnerabilities in the table:
1. Select the checkbox for each vulnerability you want to dismiss.
1. In the menu that appears, select the reason for dismissal and click **Dismiss Selected**.
1. Select the checkbox for each vulnerability you want to update the status of.
1. In the dropdown that appears select the desired status, then select **Change status**.
![Project Vulnerability Report](img/project_security_dashboard_dismissal_v13_9.png)
![Project Vulnerability Report](img/project_security_dashboard_status_change_v13_9.png)
## Project Vulnerability Report
......
<script>
import { GlButton, GlFormSelect } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__, n__ } from '~/locale';
import { GlButton, GlAlert } from '@gitlab/ui';
import vulnerabilityStateMutations from 'ee/security_dashboard/graphql/mutate_vulnerability_state';
import { __, s__, n__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import vulnerabilityDismiss from '../graphql/mutations/vulnerability_dismiss.mutation.graphql';
const REASON_NONE = s__('SecurityReports|[No reason]');
const REASON_WONT_FIX = s__("SecurityReports|Won't fix / Accept risk");
const REASON_FALSE_POSITIVE = s__('SecurityReports|False positive');
import StatusDropdown from './status_dropdown.vue';
export default {
name: 'SelectionSummary',
components: {
GlButton,
GlFormSelect,
GlAlert,
StatusDropdown,
},
props: {
selectedVulnerabilities: {
......@@ -23,101 +20,106 @@ export default {
},
data() {
return {
dismissalReason: null,
updateErrorText: null,
selectedStatus: null,
selectedStatusPayload: undefined,
};
},
computed: {
selectedVulnerabilitiesCount() {
return this.selectedVulnerabilities.length;
},
canDismissVulnerability() {
return Boolean(this.dismissalReason && this.selectedVulnerabilitiesCount > 0);
},
message() {
return n__(
'Dismiss %d selected vulnerability as',
'Dismiss %d selected vulnerabilities as',
this.selectedVulnerabilitiesCount,
);
shouldShowActionButtons() {
return Boolean(this.selectedStatus);
},
},
methods: {
handleDismiss() {
if (!this.canDismissVulnerability) return;
handleStatusDropdownChange({ action, payload }) {
this.selectedStatus = action;
this.selectedStatusPayload = payload;
},
this.dismissSelectedVulnerabilities();
resetSelected() {
this.$emit('cancel-selection');
},
dismissSelectedVulnerabilities() {
handleSubmit() {
this.updateErrorText = null;
let fulfilledCount = 0;
let rejectedCount = 0;
const rejected = [];
const promises = this.selectedVulnerabilities.map((vulnerability) =>
this.$apollo
const promises = this.selectedVulnerabilities.map((vulnerability) => {
return this.$apollo
.mutate({
mutation: vulnerabilityDismiss,
variables: { id: vulnerability.id, comment: this.dismissalReason },
mutation: vulnerabilityStateMutations[this.selectedStatus],
variables: { id: vulnerability.id, ...this.selectedStatusPayload },
})
.then(() => {
.then(({ data }) => {
const [queryName] = Object.keys(data);
if (data[queryName].errors?.length > 0) {
throw data[queryName].errors;
}
fulfilledCount += 1;
this.$emit('vulnerability-updated', vulnerability.id);
})
.catch(() => {
rejectedCount += 1;
}),
);
rejected.push(vulnerability.id.split('/').pop());
});
});
Promise.all(promises)
.then(() => {
return Promise.all(promises).then(() => {
if (fulfilledCount > 0) {
toast(
n__('%d vulnerability dismissed', '%d vulnerabilities dismissed', fulfilledCount),
);
toast(this.$options.i18n.vulnerabilitiesUpdated(fulfilledCount));
}
if (rejectedCount > 0) {
createFlash({
message: n__(
'SecurityReports|There was an error dismissing %d vulnerability. Please try again later.',
'SecurityReports|There was an error dismissing %d vulnerabilities. Please try again later.',
rejectedCount,
),
});
if (rejected.length > 0) {
this.updateErrorText = this.$options.i18n.vulnerabilitiesUpdateFailed(
rejected.join(', '),
);
}
})
.catch(() => {
createFlash({
message: s__('SecurityReports|There was an error dismissing the vulnerabilities.'),
});
});
},
},
dismissalReasons: [
{ value: null, text: s__('SecurityReports|Select a reason') },
REASON_FALSE_POSITIVE,
REASON_WONT_FIX,
REASON_NONE,
],
i18n: {
cancel: __('Cancel'),
selected: __('Selected'),
changeStatus: s__('SecurityReports|Change status'),
vulnerabilitiesUpdated: (count) =>
n__('%d vulnerability updated', '%d vulnerabilities updated', count),
vulnerabilitiesUpdateFailed: (vulnIds) =>
s__(`SecurityReports|Failed updating vulnerabilities with the following IDs: ${vulnIds}`),
},
};
</script>
<template>
<div class="card">
<form class="card-body d-flex align-items-center" @submit.prevent="handleDismiss">
<span data-testid="dismiss-message">{{ message }}</span>
<gl-form-select
v-model="dismissalReason"
class="mx-3 w-auto"
:options="$options.dismissalReasons"
/>
<gl-button
type="submit"
class="js-no-auto-disable"
category="secondary"
variant="warning"
:disabled="!canDismissVulnerability"
<div>
<gl-alert v-if="updateErrorText" variant="danger" :dismissible="false" class="gl-mb-3">
{{ updateErrorText }}
</gl-alert>
<div class="card gl-z-index-3!">
<form class="card-body gl-display-flex gl-align-items-center" @submit.prevent="handleSubmit">
<div
class="gl-line-height-0 gl-border-r-solid gl-border-gray-100 gl-pr-6 gl-border-1 gl-h-7 gl-display-flex gl-align-items-center"
>
{{ s__('SecurityReports|Dismiss Selected') }}
<span
><b>{{ selectedVulnerabilitiesCount }}</b> {{ $options.i18n.selected }}</span
>
</div>
<div class="gl-flex-fill-1 gl-ml-6 gl-mr-4">
<status-dropdown @change="handleStatusDropdownChange" />
</div>
<template v-if="shouldShowActionButtons">
<gl-button type="button" class="gl-mr-4" @click="resetSelected">
{{ $options.i18n.cancel }}
</gl-button>
<gl-button type="submit" category="primary" variant="confirm">
{{ $options.i18n.changeStatus }}
</gl-button>
</template>
</form>
</div>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { s__ } from '~/locale';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
data() {
return {
selectedKey: null,
};
},
computed: {
dropdownPlaceholderText() {
return this.selectedKey
? this.$options.states[this.selectedKey].displayName
: this.$options.i18n.defaultPlaceholder;
},
},
methods: {
setSelectedKey({ state, action, payload }) {
this.selectedKey = state;
this.$emit('change', { action, payload });
},
},
states: VULNERABILITY_STATE_OBJECTS,
i18n: {
defaultPlaceholder: s__('SecurityReports|Set status'),
},
};
</script>
<template>
<gl-dropdown :text="dropdownPlaceholderText">
<gl-dropdown-item
v-for="(state, key) in $options.states"
:key="key"
:is-checked="selectedKey === key"
is-check-item
@click="setSelectedKey(state)"
>
<div class="gl-font-weight-bold">{{ state.displayName }}</div>
<div>{{ state.description }}</div>
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -335,6 +335,7 @@ export default {
<selection-summary
v-if="shouldShowSelectionSummary"
:selected-vulnerabilities="Object.values(selectedVulnerabilities)"
@cancel-selection="deselectAllVulnerabilities"
@vulnerability-updated="deselectVulnerability"
/>
<gl-table
......
---
title: Add ability to bulk update vulnerabilities
merge_request: 54206
author:
type: changed
import { GlFormSelect, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import StatusDropdown from 'ee/security_dashboard/components/status_dropdown.vue';
import vulnerabilityStateMutations from 'ee/security_dashboard/graphql/mutate_vulnerability_state';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import toast from '~/vue_shared/plugins/global_toast';
jest.mock('~/flash');
jest.mock('~/vue_shared/plugins/global_toast');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Selection Summary component', () => {
let wrapper;
let spyMutate;
const defaultData = {
dismissalReason: null,
};
const defaultMocks = {
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
const createApolloProvider = (...queries) => {
return createMockApollo([...queries]);
};
const dismissButton = () => wrapper.find(GlButton);
const dismissMessage = () => wrapper.find('[data-testid="dismiss-message"]');
const formSelect = () => wrapper.find(GlFormSelect);
const createComponent = ({ props = {}, data = defaultData, mocks = defaultMocks } = {}) => {
spyMutate = mocks.$apollo.mutate;
wrapper = mount(SelectionSummary, {
mocks: {
...defaultMocks,
...mocks,
const findForm = () => wrapper.find('form');
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findCancelButton = () => wrapper.find('[type="button"]');
const findSubmitButton = () => wrapper.find('[type="submit"]');
const createComponent = ({ props = {}, data = defaultData, apolloProvider } = {}) => {
wrapper = shallowMount(SelectionSummary, {
localVue,
apolloProvider,
stubs: {
GlAlert,
},
propsData: {
selectedVulnerabilities: [],
......@@ -51,25 +55,36 @@ describe('Selection Summary component', () => {
});
it('renders correctly', () => {
expect(dismissMessage().text()).toBe('Dismiss 1 selected vulnerability as');
expect(findForm().text()).toBe('1 Selected');
});
describe('dismiss button', () => {
it('should have the button disabled if an option is not selected', () => {
expect(dismissButton().attributes('disabled')).toBe('disabled');
describe('with selected state', () => {
beforeEach(async () => {
wrapper.find(StatusDropdown).vm.$emit('change', { action: 'confirm' });
await wrapper.vm.$nextTick();
});
it('should have the button enabled if a vulnerability is selected and an option is selected', async () => {
expect(wrapper.vm.dismissalReason).toBe(null);
expect(wrapper.findAll('option')).toHaveLength(4);
it('displays the submit button when there is s state selected', () => {
expect(findSubmitButton().exists()).toBe(true);
});
const option = formSelect().findAll('option').at(1);
option.setSelected();
formSelect().trigger('change');
it('displays the cancel button when there is s state selected', () => {
expect(findCancelButton().exists()).toBe(true);
});
});
describe('with no selected state', () => {
beforeEach(async () => {
wrapper.find(StatusDropdown).vm.$emit('change', { action: null });
await wrapper.vm.$nextTick();
expect(wrapper.vm.dismissalReason).toEqual(option.attributes('value'));
expect(dismissButton().attributes('disabled')).toBe(undefined);
});
it('does not display the submit button when there is s state selected', () => {
expect(findSubmitButton().exists()).toBe(false);
});
it('does not display the cancel button when there is s state selected', () => {
expect(findCancelButton().exists()).toBe(false);
});
});
});
......@@ -80,54 +95,95 @@ describe('Selection Summary component', () => {
});
it('renders correctly', () => {
expect(dismissMessage().text()).toBe('Dismiss 2 selected vulnerabilities as');
expect(findForm().text()).toBe('2 Selected');
});
});
describe('clicking the dismiss vulnerability button', () => {
let mutateMock;
describe.each`
action | queryName | payload | expected
${'dismiss'} | ${'vulnerabilityDismiss'} | ${undefined} | ${'dismissed'}
${'confirm'} | ${'vulnerabilityConfirm'} | ${undefined} | ${'confirmed'}
${'resolve'} | ${'vulnerabilityResolve'} | ${undefined} | ${'resolved'}
${'revert'} | ${'vulnerabilityRevertToDetected'} | ${'Needs triage'} | ${'detected'}
`('state dropdown change', ({ action, queryName, payload, expected }) => {
const selectedVulnerabilities = [
{ id: 'gid://gitlab/Vulnerability/54' },
{ id: 'gid://gitlab/Vulnerability/56' },
{ id: 'gid://gitlab/Vulnerability/58' },
];
const submitForm = async () => {
wrapper.find(StatusDropdown).vm.$emit('change', { action, payload });
findForm().trigger('submit');
await waitForPromises();
};
describe('when API call fails', () => {
beforeEach(() => {
mutateMock = jest.fn((data) =>
data.variables.id % 2 === 0 ? Promise.resolve() : Promise.reject(),
);
const apolloProvider = createApolloProvider([
vulnerabilityStateMutations[action],
jest.fn().mockRejectedValue({
data: {
[queryName]: {
errors: [
{
message: 'Something went wrong',
},
],
},
},
}),
]);
createComponent({
props: { selectedVulnerabilities: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] },
data: { dismissalReason: 'Will Not Fix' },
mocks: { $apollo: { mutate: mutateMock } },
});
createComponent({ apolloProvider, props: { selectedVulnerabilities } });
});
it('should make an API request for each vulnerability', () => {
dismissButton().trigger('submit');
expect(spyMutate).toHaveBeenCalledTimes(5);
it(`does not emit vulnerability-updated event - ${action}`, async () => {
await submitForm();
expect(wrapper.emitted()['vulnerability-updated']).toBeUndefined();
});
it('should show toast with the right message for the successful calls', async () => {
dismissButton().trigger('submit');
await waitForPromises();
expect(toast).toHaveBeenCalledWith('2 vulnerabilities dismissed');
it(`calls the toaster - ${action}`, async () => {
await submitForm();
expect(findGlAlert().text()).toBe(
'Failed updating vulnerabilities with the following IDs: 54, 56, 58',
);
});
});
it('should show flash with the right message for the failed calls', async () => {
dismissButton().trigger('submit');
await waitForPromises();
describe('when API call is successful', () => {
beforeEach(() => {
const apolloProvider = createApolloProvider([
vulnerabilityStateMutations[action],
jest.fn().mockResolvedValue({
data: {
[queryName]: {
errors: [],
vulnerability: {
id: selectedVulnerabilities[0].id,
[`${expected}At`]: '2020-09-16T11:13:26Z',
state: expected.toUpperCase(),
},
},
},
}),
]);
expect(createFlash).toHaveBeenCalledWith({
message: 'There was an error dismissing 3 vulnerabilities. Please try again later.',
createComponent({ apolloProvider, props: { selectedVulnerabilities } });
});
it(`emits an update for each vulnerability - ${action}`, async () => {
await submitForm();
selectedVulnerabilities.forEach((v, i) => {
expect(wrapper.emitted()['vulnerability-updated'][i][0]).toBe(v.id);
});
});
describe('when vulnerabilities are not selected', () => {
beforeEach(() => {
createComponent();
it(`calls the toaster - ${action}`, async () => {
await submitForm();
expect(toast).toHaveBeenLastCalledWith('3 vulnerabilities updated');
});
it('should have the button disabled', () => {
expect(dismissButton().attributes('disabled')).toBe('disabled');
});
});
});
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusDropdown from 'ee/security_dashboard/components/status_dropdown.vue';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
describe('Status Dropdown component', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const createWrapper = () => {
wrapper = shallowMount(StatusDropdown, {
stubs: {
GlDropdown,
GlDropdownItem,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the correct placeholder', () => {
expect(findDropdown().props('text')).toBe('Set status');
});
describe.each(Object.keys(VULNERABILITY_STATE_OBJECTS).map((k, i) => [k, i]))(
'state - %s',
(state, index) => {
const status = VULNERABILITY_STATE_OBJECTS[state];
it(`renders ${state}`, () => {
expect(findDropdownItems().at(index).text()).toBe(
`${status.displayName} ${status.description}`,
);
});
it(`emits an event when clicked - ${state}`, () => {
findDropdownItems().at(index).vm.$emit('click');
expect(wrapper.emitted().change[0][0]).toEqual({
action: status.action,
payload: status.payload,
});
});
},
);
});
......@@ -348,6 +348,11 @@ msgid_plural "%d vulnerabilities dismissed"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability updated"
msgid_plural "%d vulnerabilities updated"
msgstr[0] ""
msgstr[1] ""
msgid "%d warning found:"
msgid_plural "%d warnings found:"
msgstr[0] ""
......@@ -26411,6 +26416,9 @@ msgstr ""
msgid "SecurityReports|All"
msgstr ""
msgid "SecurityReports|Change status"
msgstr ""
msgid "SecurityReports|Comment added to '%{vulnerabilityName}'"
msgstr ""
......@@ -26426,9 +26434,6 @@ msgstr ""
msgid "SecurityReports|Create issue"
msgstr ""
msgid "SecurityReports|Dismiss Selected"
msgstr ""
msgid "SecurityReports|Dismiss vulnerability"
msgstr ""
......@@ -26465,9 +26470,6 @@ msgstr ""
msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later."
msgstr ""
msgid "SecurityReports|False positive"
msgstr ""
msgid "SecurityReports|Fuzzing artifacts"
msgstr ""
......@@ -26543,7 +26545,7 @@ msgstr ""
msgid "SecurityReports|Select a project to add by using the project search field above."
msgstr ""
msgid "SecurityReports|Select a reason"
msgid "SecurityReports|Set status"
msgstr ""
msgid "SecurityReports|Severity"
......@@ -26579,11 +26581,6 @@ msgstr ""
msgid "SecurityReports|There was an error deleting the comment."
msgstr ""
msgid "SecurityReports|There was an error dismissing %d vulnerability. Please try again later."
msgid_plural "SecurityReports|There was an error dismissing %d vulnerabilities. Please try again later."
msgstr[0] ""
msgstr[1] ""
msgid "SecurityReports|There was an error dismissing the vulnerabilities."
msgstr ""
......@@ -26629,18 +26626,12 @@ msgstr ""
msgid "SecurityReports|With issues"
msgstr ""
msgid "SecurityReports|Won't fix / Accept risk"
msgstr ""
msgid "SecurityReports|You do not have sufficient permissions to access this report"
msgstr ""
msgid "SecurityReports|You must sign in as an authorized user to see this report"
msgstr ""
msgid "SecurityReports|[No reason]"
msgstr ""
msgid "Security|Policies"
msgstr ""
......
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