Commit 9daa303d authored by Gabriel Mazetto's avatar Gabriel Mazetto

Merge branch '9424-add-related-issues-component-to-sav-page' into 'master'

Add related issues component to standalone vulnerability page

See merge request gitlab-org/gitlab!35625
parents 0bfa95de 8f8b4f11
...@@ -37,6 +37,7 @@ export default { ...@@ -37,6 +37,7 @@ export default {
confirmOrderPath: '/-/subscriptions', confirmOrderPath: '/-/subscriptions',
vulnerabilityPath: '/api/:version/vulnerabilities/:id', vulnerabilityPath: '/api/:version/vulnerabilities/:id',
vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action', vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action',
vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
applicationSettingsPath: '/api/:version/application/settings', applicationSettingsPath: '/api/:version/application/settings',
......
...@@ -49,6 +49,8 @@ function createFooterApp() { ...@@ -49,6 +49,8 @@ function createFooterApp() {
project, project,
remediations, remediations,
solution, solution,
id,
canModifyRelatedIssues,
} = convertObjectPropsToCamelCase(JSON.parse(el.dataset.vulnerability)); } = convertObjectPropsToCamelCase(JSON.parse(el.dataset.vulnerability));
const remediation = remediations?.length ? remediations[0] : null; const remediation = remediations?.length ? remediations[0] : null;
...@@ -58,6 +60,7 @@ function createFooterApp() { ...@@ -58,6 +60,7 @@ function createFooterApp() {
const hasRemediation = Boolean(remediation); const hasRemediation = Boolean(remediation);
const props = { const props = {
vulnerabilityId: id,
discussionsUrl, discussionsUrl,
notesUrl, notesUrl,
solutionInfo: { solutionInfo: {
...@@ -71,6 +74,7 @@ function createFooterApp() { ...@@ -71,6 +74,7 @@ function createFooterApp() {
}, },
issueFeedback, issueFeedback,
mergeRequestFeedback, mergeRequestFeedback,
canModifyRelatedIssues,
project: { project: {
url: project.full_path, url: project.full_path,
value: project.full_name, value: project.full_name,
......
...@@ -7,13 +7,15 @@ import { s__, __ } from '~/locale'; ...@@ -7,13 +7,15 @@ import { s__, __ } from '~/locale';
import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue'; import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue'; import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
import RelatedIssues from './related_issues.vue';
import Api from 'ee/api';
import HistoryEntry from './history_entry.vue'; import HistoryEntry from './history_entry.vue';
import VulnerabilitiesEventBus from './vulnerabilities_event_bus'; import VulnerabilitiesEventBus from './vulnerabilities_event_bus';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
export default { export default {
name: 'VulnerabilityFooter', name: 'VulnerabilityFooter',
components: { IssueNote, SolutionCard, MergeRequestNote, HistoryEntry }, components: { IssueNote, SolutionCard, MergeRequestNote, HistoryEntry, RelatedIssues },
props: { props: {
discussionsUrl: { discussionsUrl: {
type: String, type: String,
...@@ -41,6 +43,14 @@ export default { ...@@ -41,6 +43,14 @@ export default {
required: false, required: false,
default: () => null, default: () => null,
}, },
vulnerabilityId: {
type: Number,
required: true,
},
canModifyRelatedIssues: {
type: Boolean,
required: true,
},
}, },
data: () => ({ data: () => ({
...@@ -63,6 +73,9 @@ export default { ...@@ -63,6 +73,9 @@ export default {
hasSolution() { hasSolution() {
return Boolean(this.solutionInfo.solution || this.solutionInfo.remediation); return Boolean(this.solutionInfo.solution || this.solutionInfo.remediation);
}, },
issueLinksEndpoint() {
return Api.buildUrl(Api.vulnerabilityIssueLinksPath).replace(':id', this.vulnerabilityId);
},
}, },
created() { created() {
...@@ -179,7 +192,7 @@ export default { ...@@ -179,7 +192,7 @@ export default {
<div data-qa-selector="vulnerability_footer"> <div data-qa-selector="vulnerability_footer">
<solution-card v-if="hasSolution" v-bind="solutionInfo" /> <solution-card v-if="hasSolution" v-bind="solutionInfo" />
<div v-if="issueFeedback || mergeRequestFeedback" class="card"> <div v-if="issueFeedback || mergeRequestFeedback" class="card gl-mt-5">
<issue-note <issue-note
v-if="issueFeedback" v-if="issueFeedback"
:feedback="issueFeedback" :feedback="issueFeedback"
...@@ -193,6 +206,13 @@ export default { ...@@ -193,6 +206,13 @@ export default {
class="card-body" class="card-body"
/> />
</div> </div>
<related-issues
:endpoint="issueLinksEndpoint"
:can-modify-related-issues="canModifyRelatedIssues"
:project-path="project.url"
/>
<hr /> <hr />
<ul v-if="discussions.length" ref="historyList" class="notes discussion-body"> <ul v-if="discussions.length" ref="historyList" class="notes discussion-body">
......
<script>
import axios from 'axios';
import RelatedIssuesStore from 'ee/related_issues/stores/related_issues_store';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
import { sprintf, __ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import { RELATED_ISSUES_ERRORS } from '../constants';
import createFlash from '~/flash';
import { getFormattedIssue, getAddRelatedIssueRequestParams } from '../helpers';
export default {
name: 'VulnerabilityRelatedIssues',
components: { RelatedIssuesBlock },
props: {
endpoint: {
type: String,
required: true,
},
canModifyRelatedIssues: {
type: Boolean,
required: false,
default: false,
},
helpPath: {
type: String,
required: false,
default: '',
},
projectPath: {
type: String,
required: true,
},
},
data() {
this.store = new RelatedIssuesStore();
return {
state: this.store.state,
isFetching: false,
isSubmitting: false,
isFormVisible: false,
inputValue: '',
};
},
computed: {
vulnerabilityProjectId() {
return this.projectPath.replace(/^\//, ''); // Remove the leading slash, i.e. '/root/test' -> 'root/test'.
},
},
created() {
this.fetchRelatedIssues();
},
methods: {
toggleFormVisibility() {
this.isFormVisible = !this.isFormVisible;
},
resetForm() {
this.isFormVisible = false;
this.store.setPendingReferences([]);
this.inputValue = '';
},
addRelatedIssue({ pendingReferences }) {
this.processAllReferences(pendingReferences);
this.isSubmitting = true;
const errors = [];
// The endpoint can only accept one issue, so we need to do a separate call for each pending reference.
const requests = this.state.pendingReferences.map(reference => {
return axios
.post(
this.endpoint,
getAddRelatedIssueRequestParams(reference, this.vulnerabilityProjectId),
)
.then(({ data }) => {
const issue = getFormattedIssue(data.issue);
// When adding an issue, the issue returned by the API doesn't have the vulnerabilityLinkId property; it's
// instead in a separate ID property. We need to add it back in, or else the issue can't be deleted until
// the page is refreshed.
issue.vulnerabilityLinkId = issue.vulnerabilityLinkId ?? data.id;
const index = this.state.pendingReferences.indexOf(reference);
this.removePendingReference(index);
this.store.addRelatedIssues(issue);
})
.catch(({ response }) => {
errors.push({
issueReference: reference,
errorMessage: response.data?.message ?? RELATED_ISSUES_ERRORS.ISSUE_ID_ERROR,
});
});
});
return Promise.all(requests).then(() => {
this.isSubmitting = false;
const hasErrors = Boolean(errors.length);
this.isFormVisible = hasErrors;
if (hasErrors) {
const messages = errors.map(error => sprintf(RELATED_ISSUES_ERRORS.LINK_ERROR, error));
createFlash(messages.join(' '));
}
});
},
removeRelatedIssue(idToRemove) {
const issue = this.state.relatedIssues.find(({ id }) => id === idToRemove);
axios
.delete(joinPaths(this.endpoint, issue.vulnerabilityLinkId.toString()))
.then(() => {
this.store.removeRelatedIssue(issue);
})
.catch(() => {
createFlash(RELATED_ISSUES_ERRORS.UNLINK_ERROR);
});
},
fetchRelatedIssues() {
this.isFetching = true;
axios
.get(this.endpoint)
.then(({ data }) => {
const issues = data.map(getFormattedIssue);
this.store.setRelatedIssues(issues);
})
.catch(() => {
createFlash(__('An error occurred while fetching issues.'));
})
.finally(() => {
this.isFetching = false;
});
},
addPendingReferences({ untouchedRawReferences, touchedReference = '' }) {
this.store.addPendingReferences(untouchedRawReferences);
this.inputValue = touchedReference;
},
removePendingReference(indexToRemove) {
this.store.removePendingRelatedIssue(indexToRemove);
},
processAllReferences(value = '') {
const rawReferences = value.split(/\s+/).filter(reference => reference.trim().length > 0);
this.addPendingReferences({ untouchedRawReferences: rawReferences });
},
},
autoCompleteSources: gl?.GfmAutoComplete?.dataSources,
issuableType: issuableTypesMap.ISSUE,
pathIdSeparator: PathIdSeparator.Issue,
};
</script>
<template>
<related-issues-block
:help-path="helpPath"
:is-fetching="isFetching"
:is-submitting="isSubmitting"
:related-issues="state.relatedIssues"
:can-admin="canModifyRelatedIssues"
:pending-references="state.pendingReferences"
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="$options.autoCompleteSources"
:issuable-type="$options.issuableType"
:path-id-separator="$options.pathIdSeparator"
:show-categorized-issues="false"
@toggleAddRelatedIssuesForm="toggleFormVisibility"
@addIssuableFormInput="addPendingReferences"
@addIssuableFormBlur="processAllReferences"
@addIssuableFormSubmit="addRelatedIssue"
@addIssuableFormCancel="resetForm"
@pendingIssuableRemoveRequest="removePendingReference"
@relatedIssueRemoveRequest="removeRelatedIssue"
>
<template #headerText>{{ __('Related issues') }}</template>
</related-issues-block>
</template>
...@@ -53,3 +53,16 @@ export const FEEDBACK_TYPES = { ...@@ -53,3 +53,16 @@ export const FEEDBACK_TYPES = {
ISSUE: 'issue', ISSUE: 'issue',
MERGE_REQUEST: 'merge_request', MERGE_REQUEST: 'merge_request',
}; };
export const RELATED_ISSUES_ERRORS = {
LINK_ERROR: s__('VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}.'),
UNLINK_ERROR: s__(
'VulnerabilityManagement|Something went wrong while trying to unlink the issue. Please try again later.',
),
ISSUE_ID_ERROR: s__('VulnerabilityManagement|invalid issue link or ID'),
};
export const REGEXES = {
ISSUE_FORMAT: /^#?(\d+)$/, // Matches '123' and '#123'.
LINK_FORMAT: /\/(.+\/.+)\/-\/issues\/(\d+)/, // Matches '/username/project/-/issues/123'.
};
import { isAbsolute, isSafeURL } from '~/lib/utils/url_utility';
import { REGEXES } from './constants';
window.isAbsolute = isAbsolute;
window.isSafeURL = isSafeURL;
// Get the issue in the format expected by the descendant components of related_issues_block.vue.
export const getFormattedIssue = issue => ({
...issue,
reference: `#${issue.iid}`,
path: issue.web_url,
});
export const getAddRelatedIssueRequestParams = (reference, defaultProjectId) => {
let issueId = reference;
let projectId = defaultProjectId;
// If the reference is an issue number, parse out just the issue number.
if (REGEXES.ISSUE_FORMAT.test(reference)) {
[, issueId] = REGEXES.ISSUE_FORMAT.exec(reference);
}
// If the reference is an absolute URL and matches the issues URL format, parse out the project and issue.
else if (isSafeURL(reference) && isAbsolute(reference)) {
const { pathname } = new URL(reference);
if (REGEXES.LINK_FORMAT.test(pathname)) {
[, projectId, issueId] = REGEXES.LINK_FORMAT.exec(pathname);
}
}
return { target_issue_iid: issueId, target_project_id: projectId };
};
...@@ -14,6 +14,7 @@ module Projects ...@@ -14,6 +14,7 @@ module Projects
def show def show
pipeline = vulnerability.finding.pipelines.first pipeline = vulnerability.finding.pipelines.first
@pipeline = pipeline if Ability.allowed?(current_user, :read_pipeline, pipeline) @pipeline = pipeline if Ability.allowed?(current_user, :read_pipeline, pipeline)
@gfm_form = true
end end
private private
......
...@@ -16,7 +16,8 @@ module VulnerabilitiesHelper ...@@ -16,7 +16,8 @@ module VulnerabilitiesHelper
discussions_url: discussions_project_security_vulnerability_path(vulnerability.project, vulnerability), discussions_url: discussions_project_security_vulnerability_path(vulnerability.project, vulnerability),
notes_url: project_security_vulnerability_notes_path(vulnerability.project, vulnerability), notes_url: project_security_vulnerability_notes_path(vulnerability.project, vulnerability),
vulnerability_feedback_help_path: help_page_path('user/application_security/index', anchor: 'interacting-with-the-vulnerabilities'), vulnerability_feedback_help_path: help_page_path('user/application_security/index', anchor: 'interacting-with-the-vulnerabilities'),
pipeline: vulnerability_pipeline_data(pipeline) pipeline: vulnerability_pipeline_data(pipeline),
can_modify_related_issues: current_user.can?(:admin_vulnerability_issue_link, vulnerability)
} }
result.merge(vulnerability_data(vulnerability), vulnerability_finding_data(vulnerability)) result.merge(vulnerability_data(vulnerability), vulnerability_finding_data(vulnerability))
......
---
title: Add related issues panel to standalone vulnerability page
merge_request: 35625
author:
type: added
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Api from 'ee/api';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue'; import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue'; import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import VulnerabilitiesEventBus from 'ee/vulnerabilities/components/vulnerabilities_event_bus'; import VulnerabilitiesEventBus from 'ee/vulnerabilities/components/vulnerabilities_event_bus';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue'; import IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue'; import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
...@@ -32,9 +34,11 @@ describe('Vulnerability Footer', () => { ...@@ -32,9 +34,11 @@ describe('Vulnerability Footer', () => {
finding: {}, finding: {},
notesUrl: '/notes', notesUrl: '/notes',
project: { project: {
full_path: '/root/security-reports', url: '/root/security-reports',
full_name: 'Administrator / Security Reports', value: 'Administrator / Security Reports',
}, },
vulnerabilityId: 1,
canModifyRelatedIssues: true,
}; };
const solutionInfoProp = { const solutionInfoProp = {
...@@ -239,4 +243,23 @@ describe('Vulnerability Footer', () => { ...@@ -239,4 +243,23 @@ describe('Vulnerability Footer', () => {
}); });
}); });
}); });
describe('related issues', () => {
const relatedIssues = () => wrapper.find(RelatedIssues);
it('has the correct props', () => {
const endpoint = Api.buildUrl(Api.vulnerabilityIssueLinksPath).replace(
':id',
minimumProps.vulnerabilityId,
);
createWrapper();
expect(relatedIssues().exists()).toBe(true);
expect(relatedIssues().props()).toMatchObject({
endpoint,
canModifyRelatedIssues: minimumProps.canModifyRelatedIssues,
projectPath: minimumProps.project.url,
});
});
});
}); });
import { getFormattedIssue, getAddRelatedIssueRequestParams } from 'ee/vulnerabilities/helpers';
describe('Vulnerabilities helpers', () => {
describe('getFormattedIssue', () => {
it.each([{ iid: 135, web_url: 'some/url' }, { iid: undefined, web_url: undefined }])(
'returns formatted issue with expected properties for issue %s',
issue => {
const formattedIssue = getFormattedIssue(issue);
expect(formattedIssue).toMatchObject({
...issue,
reference: `#${issue.iid}`,
path: issue.web_url,
});
},
);
});
describe('getAddRelatedIssueRequestParams', () => {
const defaultPath = 'default/path';
it.each`
reference | target_issue_iid | target_project_id
${'135'} | ${'135'} | ${defaultPath}
${'#246'} | ${'246'} | ${defaultPath}
${'https://localhost:3000/root/test/-/issues/357'} | ${'357'} | ${'root/test'}
${'/root/test/-/issues/357'} | ${'/root/test/-/issues/357'} | ${defaultPath}
${'invalidReference'} | ${'invalidReference'} | ${defaultPath}
${'/?something/@#$%/@#$%/-/issues/1234'} | ${'/?something/@#$%/@#$%/-/issues/1234'} | ${defaultPath}
${'http://something/@#$%/@#$%/-/issues/1234'} | ${'http://something/@#$%/@#$%/-/issues/1234'} | ${defaultPath}
`(
'gets correct request params for the reference "$reference"',
async ({ reference, target_issue_iid, target_project_id }) => {
const params = getAddRelatedIssueRequestParams(reference, defaultPath);
expect(params).toMatchObject({ target_issue_iid, target_project_id });
},
);
});
});
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuableTypesMap, PathIdSeparator } from 'ee/related_issues/constants';
jest.mock('~/flash');
const mockAxios = new MockAdapter(axios);
describe('Vulnerability related issues component', () => {
let wrapper;
const propsData = {
endpoint: 'endpoint',
projectPath: 'project/path',
helpPath: 'help/path',
canModifyRelatedIssues: true,
};
const issue1 = { id: 3, vulnerabilityLinkId: 987 };
const issue2 = { id: 25, vulnerabilityLinkId: 876 };
const createWrapper = async (data = {}) => {
wrapper = shallowMount(RelatedIssues, { propsData, data: () => data });
// Need this special check because RelatedIssues creates the store and uses its state in the data function, so we
// need to set the state of the store, not replace the state property.
if (data.state) {
wrapper.vm.store.state = data.state;
}
};
const relatedIssuesBlock = () => wrapper.find(RelatedIssuesBlock);
const blockProp = prop => relatedIssuesBlock().props(prop);
const blockEmit = (eventName, data) => relatedIssuesBlock().vm.$emit(eventName, data);
afterEach(() => {
wrapper.destroy();
mockAxios.reset();
});
it('passes the expected props to the RelatedIssuesBlock component', async () => {
window.gl = { GfmAutoComplete: { dataSources: {} } };
const data = {
isFetching: true,
isSubmitting: true,
isFormVisible: true,
inputValue: 'input value',
state: {
relatedIssues: [{}, {}, {}],
pendingReferences: ['#1', '#2', '#3'],
},
};
createWrapper(data);
expect(relatedIssuesBlock().props()).toMatchObject({
helpPath: propsData.helpPath,
isFetching: data.isFetching,
isSubmitting: data.isSubmitting,
relatedIssues: data.state.relatedIssues,
canAdmin: propsData.canModifyRelatedIssues,
pendingReferences: data.state.pendingReferences,
isFormVisible: data.isFormVisible,
inputValue: data.inputValue,
autoCompleteSources: window.gl.GfmAutoComplete.dataSources,
issuableType: issuableTypesMap.ISSUE,
pathIdSeparator: PathIdSeparator.Issue,
showCategorizedIssues: false,
});
});
describe('fetch related issues', () => {
it('fetches related issues when the component is created', async () => {
mockAxios.onGet(propsData.endpoint).replyOnce(httpStatusCodes.OK, [issue1, issue2]);
createWrapper();
await axios.waitForAll();
expect(mockAxios.history.get).toHaveLength(1);
expect(blockProp('relatedIssues')).toMatchObject([issue1, issue2]);
});
it('shows an error message if the fetch fails', async () => {
mockAxios.onGet(propsData.endpoint).replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
createWrapper();
await axios.waitForAll();
expect(blockProp('relatedIssues')).toEqual([]);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('add related issue', () => {
beforeEach(() => {
mockAxios.onGet(propsData.endpoint).replyOnce(httpStatusCodes.OK, []);
createWrapper({ isFormVisible: true });
});
it('adds related issue with vulnerabilityLinkId populated', async () => {
mockAxios
.onPost(propsData.endpoint)
.replyOnce(httpStatusCodes.OK, { issue: {}, id: issue1.vulnerabilityLinkId });
blockEmit('addIssuableFormSubmit', { pendingReferences: '#1' });
await axios.waitForAll();
expect(mockAxios.history.post).toHaveLength(1);
const requestData = JSON.parse(mockAxios.history.post[0].data);
expect(requestData.target_issue_iid).toBe('1');
expect(requestData.target_project_id).toBe(propsData.projectPath);
expect(blockProp('relatedIssues')).toHaveLength(1);
expect(blockProp('relatedIssues')[0].vulnerabilityLinkId).toBe(issue1.vulnerabilityLinkId);
expect(createFlash).not.toHaveBeenCalled();
});
it('adds multiple issues', async () => {
mockAxios.onPost(propsData.endpoint).reply(httpStatusCodes.OK, { issue: {} });
blockEmit('addIssuableFormSubmit', { pendingReferences: '#1 #2 #3' });
await axios.waitForAll();
expect(mockAxios.history.post).toHaveLength(3);
expect(blockProp('relatedIssues')).toHaveLength(3);
expect(blockProp('isFormVisible')).toBe(false);
expect(blockProp('inputValue')).toBe('');
});
it('adds only issues that returns issue', async () => {
mockAxios
.onPost(propsData.endpoint)
.replyOnce(httpStatusCodes.OK, { issue: {} })
.onPost(propsData.endpoint)
.replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE)
.onPost(propsData.endpoint)
.replyOnce(httpStatusCodes.OK, { issue: {} })
.onPost(propsData.endpoint)
.replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
blockEmit('addIssuableFormSubmit', { pendingReferences: '#1 #2 #3 #4' });
await axios.waitForAll();
expect(mockAxios.history.post).toHaveLength(4);
expect(blockProp('relatedIssues')).toHaveLength(2);
expect(blockProp('isFormVisible')).toBe(true);
expect(blockProp('inputValue')).toBe('');
expect(blockProp('pendingReferences')).toEqual(['#2', '#4']);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
describe('related issues form', () => {
it.each`
from | to
${true} | ${false}
${false} | ${true}
`('toggles form visibility from $from to $to', async ({ from, to }) => {
createWrapper({ isFormVisible: from });
blockEmit('toggleAddRelatedIssuesForm');
await wrapper.vm.$nextTick();
expect(blockProp('isFormVisible')).toBe(to);
});
it('resets form and hides it', async () => {
createWrapper({
inputValue: 'some input value',
isFormVisible: true,
state: { pendingReferences: ['135', '246'] },
});
blockEmit('addIssuableFormCancel');
await wrapper.vm.$nextTick();
expect(blockProp('isFormVisible')).toBe(false);
expect(blockProp('inputValue')).toBe('');
expect(blockProp('pendingReferences')).toEqual([]);
});
});
describe('pending references', () => {
it('adds pending references', async () => {
const pendingReferences = ['135', '246'];
const untouchedRawReferences = ['357', '468'];
const touchedReference = 'touchedReference';
createWrapper({ state: { pendingReferences } });
blockEmit('addIssuableFormInput', { untouchedRawReferences, touchedReference });
await wrapper.vm.$nextTick();
expect(blockProp('pendingReferences')).toEqual(
pendingReferences.concat(untouchedRawReferences),
);
expect(blockProp('inputValue')).toBe(touchedReference);
});
it('processes pending references', async () => {
createWrapper();
blockEmit('addIssuableFormBlur', '135 246');
await wrapper.vm.$nextTick();
expect(blockProp('pendingReferences')).toEqual(['135', '246']);
expect(blockProp('inputValue')).toBe('');
});
it('removes pending reference', async () => {
createWrapper({ state: { pendingReferences: ['135', '246', '357'] } });
blockEmit('pendingIssuableRemoveRequest', 1);
await wrapper.vm.$nextTick();
expect(blockProp('pendingReferences')).toEqual(['135', '357']);
});
});
describe('remove related issue', () => {
beforeEach(async () => {
mockAxios.onGet(propsData.endpoint).replyOnce(httpStatusCodes.OK, [issue1, issue2]);
createWrapper();
await axios.waitForAll();
});
it('removes related issue', async () => {
mockAxios
.onDelete(`${propsData.endpoint}/${issue1.vulnerabilityLinkId}`)
.replyOnce(httpStatusCodes.OK);
blockEmit('relatedIssueRemoveRequest', issue1.id);
await axios.waitForAll();
expect(mockAxios.history.delete).toHaveLength(1);
expect(blockProp('relatedIssues')).toMatchObject([issue2]);
});
it('shows error message if related issue could not be removed', async () => {
mockAxios
.onDelete(`${propsData.endpoint}/${issue1.vulnerabilityLinkId}`)
.replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
blockEmit('relatedIssueRemoveRequest', issue1.id);
await axios.waitForAll();
expect(mockAxios.history.delete).toHaveLength(1);
expect(blockProp('relatedIssues')).toMatchObject([issue1, issue2]);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
...@@ -3,45 +3,44 @@ ...@@ -3,45 +3,44 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe VulnerabilitiesHelper do RSpec.describe VulnerabilitiesHelper do
let_it_be(:user) { build(:user) } let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository, :public) } let(:project) { create(:project, :repository, :public) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) } let(:pipeline) { create(:ci_pipeline, :success, project: project) }
let_it_be(:finding) { create(:vulnerabilities_occurrence, pipelines: [pipeline], project: project, severity: :high) } let(:finding) { create(:vulnerabilities_occurrence, pipelines: [pipeline], project: project, severity: :high) }
let_it_be(:vulnerability) { create(:vulnerability, title: "My vulnerability", project: project, findings: [finding]) } let(:vulnerability) { create(:vulnerability, title: "My vulnerability", project: project, findings: [finding]) }
let(:vulnerability_serializer_hash) do
vulnerability.slice(
:id,
:title,
:state,
:severity,
:confidence,
:report_type,
:resolved_on_default_branch,
:project_default_branch,
:resolved_by_id,
:dismissed_by_id,
:confirmed_by_id
)
end
let(:finding_serializer_hash) do
finding.slice(:description,
:identifiers,
:links,
:location,
:name,
:issue_feedback,
:project,
:remediations,
:solution
)
end
before do before do
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:current_user).and_return(user)
end end
RSpec.shared_examples 'vulnerability properties' do RSpec.shared_examples 'vulnerability properties' do
let(:vulnerability_serializer_hash) do
vulnerability.slice(
:id,
:title,
:state,
:severity,
:confidence,
:report_type,
:resolved_on_default_branch,
:project_default_branch,
:resolved_by_id,
:dismissed_by_id,
:confirmed_by_id)
end
let(:finding_serializer_hash) do
finding.slice(:description,
:identifiers,
:links,
:location,
:name,
:issue_feedback,
:project,
:remediations,
:solution)
end
before do before do
vulnerability_serializer_stub = instance_double("VulnerabilitySerializer") vulnerability_serializer_stub = instance_double("VulnerabilitySerializer")
expect(VulnerabilitySerializer).to receive(:new).and_return(vulnerability_serializer_stub) expect(VulnerabilitySerializer).to receive(:new).and_return(vulnerability_serializer_stub)
...@@ -65,16 +64,50 @@ RSpec.describe VulnerabilitiesHelper do ...@@ -65,16 +64,50 @@ RSpec.describe VulnerabilitiesHelper do
discussions_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/discussions", discussions_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/discussions",
notes_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/notes", notes_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/notes",
vulnerability_feedback_help_path: kind_of(String), vulnerability_feedback_help_path: kind_of(String),
pipeline: anything pipeline: anything,
can_modify_related_issues: false
) )
end end
end end
describe '#vulnerability_details' do describe '#vulnerability_details' do
before do
allow(helper).to receive(:can?).and_return(true)
end
subject { helper.vulnerability_details(vulnerability, pipeline) } subject { helper.vulnerability_details(vulnerability, pipeline) }
describe 'when pipeline exists' do describe '[:can_modify_related_issues]' do
let(:pipeline) { create(:ci_pipeline) } context 'with security dashboard feature enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
context 'when user can manage related issues' do
before do
project.add_developer(user)
end
it { is_expected.to include(can_modify_related_issues: true) }
end
context 'when user cannot manage related issues' do
it { is_expected.to include(can_modify_related_issues: false) }
end
end
context 'with security dashboard feature disabled' do
before do
stub_licensed_features(security_dashboard: false)
project.add_developer(user)
end
it { is_expected.to include(can_modify_related_issues: false) }
end
end
context 'when pipeline exists' do
subject { helper.vulnerability_details(vulnerability, pipeline) }
include_examples 'vulnerability properties' include_examples 'vulnerability properties'
...@@ -87,8 +120,8 @@ RSpec.describe VulnerabilitiesHelper do ...@@ -87,8 +120,8 @@ RSpec.describe VulnerabilitiesHelper do
end end
end end
describe 'when pipeline is nil' do context 'when pipeline is nil' do
let(:pipeline) { nil } subject { helper.vulnerability_details(vulnerability, nil) }
include_examples 'vulnerability properties' include_examples 'vulnerability properties'
......
...@@ -19557,6 +19557,9 @@ msgstr "" ...@@ -19557,6 +19557,9 @@ msgstr ""
msgid "Related Merged Requests" msgid "Related Merged Requests"
msgstr "" msgstr ""
msgid "Related issues"
msgstr ""
msgid "Related merge requests" msgid "Related merge requests"
msgstr "" msgstr ""
...@@ -26443,6 +26446,9 @@ msgstr "" ...@@ -26443,6 +26446,9 @@ msgstr ""
msgid "VulnerabilityManagement|Confirmed %{timeago} by %{user}" msgid "VulnerabilityManagement|Confirmed %{timeago} by %{user}"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}."
msgstr ""
msgid "VulnerabilityManagement|Detected %{timeago} in pipeline %{pipelineLink}" msgid "VulnerabilityManagement|Detected %{timeago} in pipeline %{pipelineLink}"
msgstr "" msgstr ""
...@@ -26470,6 +26476,9 @@ msgstr "" ...@@ -26470,6 +26476,9 @@ msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later." msgid "VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later."
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to unlink the issue. Please try again later."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not create an issue." msgid "VulnerabilityManagement|Something went wrong, could not create an issue."
msgstr "" msgstr ""
...@@ -26485,6 +26494,9 @@ msgstr "" ...@@ -26485,6 +26494,9 @@ msgstr ""
msgid "VulnerabilityManagement|Will not fix or a false-positive" msgid "VulnerabilityManagement|Will not fix or a false-positive"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|invalid issue link or ID"
msgstr ""
msgid "VulnerabilityStatusTypes|All" msgid "VulnerabilityStatusTypes|All"
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