Commit 8f8b4f11 authored by Daniel Tian's avatar Daniel Tian Committed by Gabriel Mazetto

Add vulnerability related issues component

dd related issues component to standalone vulnerability page
parent b6f045f6
......@@ -37,6 +37,7 @@ export default {
confirmOrderPath: '/-/subscriptions',
vulnerabilityPath: '/api/:version/vulnerabilities/:id',
vulnerabilityActionPath: '/api/:version/vulnerabilities/:id/:action',
vulnerabilityIssueLinksPath: '/api/:version/vulnerabilities/:id/issue_links',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
applicationSettingsPath: '/api/:version/application/settings',
......
......@@ -49,6 +49,8 @@ function createFooterApp() {
project,
remediations,
solution,
id,
canModifyRelatedIssues,
} = convertObjectPropsToCamelCase(JSON.parse(el.dataset.vulnerability));
const remediation = remediations?.length ? remediations[0] : null;
......@@ -58,6 +60,7 @@ function createFooterApp() {
const hasRemediation = Boolean(remediation);
const props = {
vulnerabilityId: id,
discussionsUrl,
notesUrl,
solutionInfo: {
......@@ -71,6 +74,7 @@ function createFooterApp() {
},
issueFeedback,
mergeRequestFeedback,
canModifyRelatedIssues,
project: {
url: project.full_path,
value: project.full_name,
......
......@@ -7,13 +7,15 @@ import { s__, __ } from '~/locale';
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 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 VulnerabilitiesEventBus from './vulnerabilities_event_bus';
import initUserPopovers from '~/user_popovers';
export default {
name: 'VulnerabilityFooter',
components: { IssueNote, SolutionCard, MergeRequestNote, HistoryEntry },
components: { IssueNote, SolutionCard, MergeRequestNote, HistoryEntry, RelatedIssues },
props: {
discussionsUrl: {
type: String,
......@@ -41,6 +43,14 @@ export default {
required: false,
default: () => null,
},
vulnerabilityId: {
type: Number,
required: true,
},
canModifyRelatedIssues: {
type: Boolean,
required: true,
},
},
data: () => ({
......@@ -63,6 +73,9 @@ export default {
hasSolution() {
return Boolean(this.solutionInfo.solution || this.solutionInfo.remediation);
},
issueLinksEndpoint() {
return Api.buildUrl(Api.vulnerabilityIssueLinksPath).replace(':id', this.vulnerabilityId);
},
},
created() {
......@@ -179,7 +192,7 @@ export default {
<div data-qa-selector="vulnerability_footer">
<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
v-if="issueFeedback"
:feedback="issueFeedback"
......@@ -193,6 +206,13 @@ export default {
class="card-body"
/>
</div>
<related-issues
:endpoint="issueLinksEndpoint"
:can-modify-related-issues="canModifyRelatedIssues"
:project-path="project.url"
/>
<hr />
<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 = {
ISSUE: 'issue',
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
def show
pipeline = vulnerability.finding.pipelines.first
@pipeline = pipeline if Ability.allowed?(current_user, :read_pipeline, pipeline)
@gfm_form = true
end
private
......
......@@ -16,7 +16,8 @@ module VulnerabilitiesHelper
discussions_url: discussions_project_security_vulnerability_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'),
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))
......
---
title: Add related issues panel to standalone vulnerability page
merge_request: 35625
author:
type: added
import { shallowMount } from '@vue/test-utils';
import Api from 'ee/api';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
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 IssueNote from 'ee/vue_shared/security_reports/components/issue_note.vue';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
......@@ -32,9 +34,11 @@ describe('Vulnerability Footer', () => {
finding: {},
notesUrl: '/notes',
project: {
full_path: '/root/security-reports',
full_name: 'Administrator / Security Reports',
url: '/root/security-reports',
value: 'Administrator / Security Reports',
},
vulnerabilityId: 1,
canModifyRelatedIssues: true,
};
const solutionInfoProp = {
......@@ -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,11 +3,17 @@
require 'spec_helper'
RSpec.describe VulnerabilitiesHelper do
let_it_be(:user) { build(:user) }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
let_it_be(:finding) { create(:vulnerabilities_occurrence, pipelines: [pipeline], project: project, severity: :high) }
let_it_be(:vulnerability) { create(:vulnerability, title: "My vulnerability", project: project, findings: [finding]) }
let_it_be(:user) { create(:user) }
let(:project) { create(:project, :repository, :public) }
let(:pipeline) { create(:ci_pipeline, :success, project: project) }
let(:finding) { create(:vulnerabilities_occurrence, pipelines: [pipeline], project: project, severity: :high) }
let(:vulnerability) { create(:vulnerability, title: "My vulnerability", project: project, findings: [finding]) }
before do
allow(helper).to receive(:current_user).and_return(user)
end
RSpec.shared_examples 'vulnerability properties' do
let(:vulnerability_serializer_hash) do
vulnerability.slice(
:id,
......@@ -20,9 +26,9 @@ RSpec.describe VulnerabilitiesHelper do
:project_default_branch,
:resolved_by_id,
:dismissed_by_id,
:confirmed_by_id
)
:confirmed_by_id)
end
let(:finding_serializer_hash) do
finding.slice(:description,
:identifiers,
......@@ -32,16 +38,9 @@ RSpec.describe VulnerabilitiesHelper do
:issue_feedback,
:project,
:remediations,
:solution
)
:solution)
end
before do
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:current_user).and_return(user)
end
RSpec.shared_examples 'vulnerability properties' do
before do
vulnerability_serializer_stub = instance_double("VulnerabilitySerializer")
expect(VulnerabilitySerializer).to receive(:new).and_return(vulnerability_serializer_stub)
......@@ -65,16 +64,50 @@ RSpec.describe VulnerabilitiesHelper do
discussions_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/discussions",
notes_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/notes",
vulnerability_feedback_help_path: kind_of(String),
pipeline: anything
pipeline: anything,
can_modify_related_issues: false
)
end
end
describe '#vulnerability_details' do
before do
allow(helper).to receive(:can?).and_return(true)
end
subject { helper.vulnerability_details(vulnerability, pipeline) }
describe 'when pipeline exists' do
let(:pipeline) { create(:ci_pipeline) }
describe '[:can_modify_related_issues]' do
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'
......@@ -87,8 +120,8 @@ RSpec.describe VulnerabilitiesHelper do
end
end
describe 'when pipeline is nil' do
let(:pipeline) { nil }
context 'when pipeline is nil' do
subject { helper.vulnerability_details(vulnerability, nil) }
include_examples 'vulnerability properties'
......
......@@ -19557,6 +19557,9 @@ msgstr ""
msgid "Related Merged Requests"
msgstr ""
msgid "Related issues"
msgstr ""
msgid "Related merge requests"
msgstr ""
......@@ -26443,6 +26446,9 @@ msgstr ""
msgid "VulnerabilityManagement|Confirmed %{timeago} by %{user}"
msgstr ""
msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}."
msgstr ""
msgid "VulnerabilityManagement|Detected %{timeago} in pipeline %{pipelineLink}"
msgstr ""
......@@ -26470,6 +26476,9 @@ msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to save the comment. Please try again later."
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."
msgstr ""
......@@ -26485,6 +26494,9 @@ msgstr ""
msgid "VulnerabilityManagement|Will not fix or a false-positive"
msgstr ""
msgid "VulnerabilityManagement|invalid issue link or ID"
msgstr ""
msgid "VulnerabilityStatusTypes|All"
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