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