Commit 1f3a8d10 authored by Phil Hughes's avatar Phil Hughes

Merge branch '196767-create-issue-from-standalone-vulnerability' into 'master'

Add ability to create an issue from a standalone vulnerability

See merge request gitlab-org/gitlab!24314
parents 3d46d7ca 142d5872
......@@ -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