Add ability to create an issue from a standalone vulnerability

Added a new 'Create issue' button in the vulnerability header. Clicking
on the button triggers an AJAX call to create an issue for the current
vulnerability. When the request resolves, the user is redirected to the
newly create issue.
parent bfc5352f
......@@ -38,11 +38,21 @@ function createSolutionCardApp() {
function createHeaderApp() {
const el = document.getElementById('js-vulnerability-show-header');
const { state, id } = el.dataset;
const { createIssueUrl } = el.dataset;
const vulnerability = JSON.parse(el.dataset.vulnerability);
const finding = JSON.parse(el.dataset.finding);
return new Vue({
el,
render: h => h(HeaderApp, { props: { state, id: Number(id) } }),
render: h =>
h(HeaderApp, {
props: {
vulnerability,
finding,
createIssueUrl,
},
}),
});
}
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue';
export default {
components: { GlLoadingIcon, VulnerabilityStateDropdown },
components: {
GlLoadingIcon,
VulnerabilityStateDropdown,
LoadingButton,
},
props: {
state: { type: String, required: true },
id: { type: Number, required: true },
vulnerability: {
type: Object,
required: true,
},
finding: {
type: Object,
required: true,
},
createIssueUrl: {
type: String,
required: true,
},
},
data: () => ({
isLoading: false,
isCreatingIssue: false,
}),
methods: {
......@@ -22,7 +39,7 @@ export default {
this.isLoading = true;
axios
.post(`/api/v4/vulnerabilities/${this.id}/${newState}`)
.post(`/api/v4/vulnerabilities/${this.vulnerability.id}/${newState}`)
// Reload the page for now since the rest of the page is still a static haml file.
.then(() => window.location.reload(true))
.catch(() => {
......@@ -36,6 +53,27 @@ export default {
this.isLoading = false;
});
},
createIssue() {
this.isCreatingIssue = true;
axios
.post(this.createIssueUrl, {
vulnerability_feedback: {
feedback_type: 'issue',
category: this.vulnerability.report_type,
project_fingerprint: this.finding.project_fingerprint,
vulnerability_data: { ...this.vulnerability, category: this.vulnerability.report_type },
},
})
.then(({ data: { issue_url } }) => {
redirectTo(issue_url);
})
.catch(() => {
this.isCreatingIssue = false;
createFlash(
s__('VulnerabilityManagement|Something went wrong, could not create an issue.'),
);
});
},
},
};
</script>
......@@ -43,6 +81,18 @@ export default {
<template>
<div>
<gl-loading-icon v-if="isLoading" />
<vulnerability-state-dropdown v-else :state="state" @change="onVulnerabilityStateChange" />
<vulnerability-state-dropdown
v-else
:state="vulnerability.state"
@change="onVulnerabilityStateChange"
/>
<loading-button
ref="create-issue-btn"
class="align-items-center d-inline-flex"
:loading="isCreatingIssue"
:label="s__('VulnerabilityManagement|Create issue')"
container-class="btn btn-success btn-inverted"
@click="createIssue"
/>
</div>
</template>
......@@ -17,8 +17,9 @@
%span#js-vulnerability-created
= time_ago_with_tooltip(@vulnerability.created_at)
%label.mb-0.mr-2= _('Status')
#js-vulnerability-show-header{ data: { state: @vulnerability.state,
id: @vulnerability.id } }
#js-vulnerability-show-header{ data: { vulnerability: @vulnerability.to_json,
finding: @vulnerability.finding.to_json,
create_issue_url: create_vulnerability_feedback_issue_path(@vulnerability.finding.project) } }
.issue-details.issuable-details
.detail-page-description.content-block
......
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import App from 'ee/vulnerabilities/components/app.vue';
......@@ -8,15 +10,30 @@ import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerabil
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility');
describe('Vulnerability management app', () => {
let wrapper;
const vulnerability = {
id: 1,
state: 'doesnt matter',
report_type: 'sast',
};
const finding = {
project_fingerprint: 'abc123',
report_type: 'sast',
};
const createIssueUrl = 'create_issue_path';
const findCreateIssueButton = () => wrapper.find({ ref: 'create-issue-btn' });
beforeEach(() => {
wrapper = shallowMount(App, {
propsData: {
id: 1,
state: 'doesnt matter',
vulnerability,
finding,
createIssueUrl,
},
});
});
......@@ -27,30 +44,71 @@ describe('Vulnerability management app', () => {
createFlash.mockReset();
});
it('the vulnerability state dropdown is rendered', () => {
expect(wrapper.find(VulnerabilityStateDropdown).exists()).toBe(true);
});
describe('state dropdown', () => {
it('the vulnerability state dropdown is rendered', () => {
expect(wrapper.find(VulnerabilityStateDropdown).exists()).toBe(true);
});
it('when the vulnerability state dropdown emits a change event, a POST API call is made', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(201);
it('when the vulnerability state dropdown emits a change event, a POST API call is made', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(201);
dropdown.vm.$emit('change');
dropdown.vm.$emit('change');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1); // Check that a POST request was made.
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1); // Check that a POST request was made.
});
});
it('when the vulnerability state changes but the API call fails, an error message is displayed', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(400);
dropdown.vm.$emit('change');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
it('when the vulnerability state changes but the API call fails, an error message is displayed', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(400);
describe('create issue button', () => {
it('renders properly', () => {
expect(findCreateIssueButton().exists()).toBe(true);
});
dropdown.vm.$emit('change');
it('calls create issue endpoint on click and redirects to new issue', () => {
const issueUrl = '/group/project/issues/123';
mockAxios.onPost(createIssueUrl).reply(200, {
issue_url: issueUrl,
});
findCreateIssueButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
const [postRequest] = mockAxios.history.post;
expect(postRequest.url).toBe(createIssueUrl);
expect(JSON.parse(postRequest.data)).toMatchObject({
vulnerability_feedback: {
feedback_type: 'issue',
category: vulnerability.report_type,
project_fingerprint: finding.project_fingerprint,
vulnerability_data: { ...vulnerability, category: vulnerability.report_type },
},
});
expect(redirectTo).toHaveBeenCalledWith(issueUrl);
});
});
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1);
it('shows an error message when issue creation fails', () => {
mockAxios.onPost(createIssueUrl).reply(500);
findCreateIssueButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong, could not create an issue.',
);
});
});
});
});
......@@ -21618,12 +21618,18 @@ msgstr ""
msgid "VulnerabilityManagement|Confirm"
msgstr ""
msgid "VulnerabilityManagement|Create issue"
msgstr ""
msgid "VulnerabilityManagement|Dismiss"
msgstr ""
msgid "VulnerabilityManagement|Resolved"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not create an issue."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state."
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