Commit 1dcc19d2 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '9715-dismiss-details' into 'master'

Security dashboard should show dismissal details on issues

Closes #9715

See merge request gitlab-org/gitlab-ee!11028
parents 036a8567 a10dcd98
......@@ -127,7 +127,8 @@ export default {
state.modal.vulnerability,
'hasMergeRequest',
Boolean(
vulnerability.merge_request_feedback && vulnerability.merge_request.merge_request_iid,
vulnerability.merge_request_feedback &&
vulnerability.merge_request_feedback.merge_request_iid,
),
);
Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback));
......
<script>
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
export default {
components: {
EventItem,
},
props: {
feedback: {
type: Object,
required: true,
},
project: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
eventText() {
const { project, feedback } = this;
const { pipeline } = feedback;
const pipelineLink =
pipeline && pipeline.path && pipeline.id
? `<a href="${pipeline.path}">#${pipeline.id}</a>`
: null;
const projectLink =
project && project.url && project.value
? `<a href="${_.escape(project.url)}">${_.escape(project.value)}</a>`
: null;
if (pipelineLink && projectLink) {
return sprintf(
__('Dismissed on pipeline %{pipelineLink} at %{projectLink}'),
{ pipelineLink, projectLink },
false,
);
} else if (pipelineLink && !projectLink) {
return sprintf(__('Dismissed on pipeline %{pipelineLink}'), { pipelineLink }, false);
} else if (!pipelineLink && projectLink) {
return sprintf(__('Dismissed at %{projectLink}'), { projectLink }, false);
}
return __('Dismissed');
},
},
};
</script>
<template>
<event-item
:author="feedback.author"
:created-at="feedback.created_at"
icon-name="cancel"
icon-style="ci-status-icon-pending"
>
<div v-html="eventText"></div>
</event-item>
</template>
<script>
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'EventItem',
components: {
Icon,
TimeAgoTooltip,
},
props: {
type: {
type: String,
required: true,
},
authorName: {
type: String,
author: {
type: Object,
required: true,
},
authorUsername: {
type: String,
required: true,
},
projectName: {
createdAt: {
type: String,
required: false,
default: '',
},
projectLink: {
iconName: {
type: String,
required: false,
default: '',
},
actionLinkText: {
type: String,
required: true,
default: 'plus',
},
actionLinkUrl: {
iconStyle: {
type: String,
required: true,
},
},
typeMap: {
issue: {
name: s__('Reports|issue'),
icon: 'issue-created',
},
mergeRequest: {
name: s__('Reports|merge request'),
icon: 'merge-request',
},
},
computed: {
typeData() {
return this.$options.typeMap[this.type] || {};
},
iconName() {
return this.typeData.icon || 'plus';
},
createdText() {
return sprintf(s__('ciReport|Created %{eventType}'), { eventType: this.typeData.name });
required: false,
default: 'ci-status-icon-success',
},
},
};
......@@ -65,24 +34,29 @@ export default {
<template>
<div class="card-body d-flex align-items-center">
<div class="circle-icon-container ci-status-icon-success">
<div class="circle-icon-container" :class="iconStyle">
<icon :size="16" :name="iconName" />
</div>
<div class="ml-3">
<div>
<strong class="js-author-name">{{ authorName }}</strong>
<em class="js-username">@{{ authorUsername }}</em>
</div>
<div>
<span v-if="typeData.name" class="js-created">{{ createdText }}</span>
<a class="js-action-link" :title="actionLinkText" :href="actionLinkUrl">
{{ actionLinkText }}
<div class="note-header-info pb-0">
<a
:href="author.path"
:data-user-id="author.id"
:data-username="author.username"
class="js-author"
>
<strong class="note-header-author-name">{{ author.name }}</strong>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light">@{{ author.username }}</span>
</a>
<template v-if="projectName">
<span>{{ __('at') }} </span>
<a class="js-project-name" :title="projectName" :href="projectLink">{{ projectName }}</a>
</template>
<span class="note-headline-light note-headline-meta">
<template v-if="createdAt">
<span class="system-note-separator">·</span>
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</template>
</span>
</div>
<slot></slot>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
export default {
components: {
EventItem,
},
props: {
feedback: {
type: Object,
required: true,
},
project: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
eventText() {
const { project, feedback } = this;
const issueLink = `<a href="${feedback.issue_url}">#${feedback.issue_iid}</a>`;
if (project && project.value && project.url) {
const projectLink = `<a href="${_.escape(project.url)}">${_.escape(project.value)}</a>`;
return sprintf(
__('Created issue %{issueLink} at %{projectLink}'),
{
issueLink,
projectLink,
},
false,
);
}
return sprintf(__('Created issue %{issueLink}'), { issueLink }, false);
},
},
};
</script>
<template>
<event-item :author="feedback.author" :created-at="feedback.created_at" icon-name="issue-created">
<div v-html="eventText"></div>
</event-item>
</template>
<script>
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
export default {
components: {
EventItem,
},
props: {
feedback: {
type: Object,
required: true,
},
project: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
eventText() {
const { project, feedback } = this;
const mergeRequestLink = `<a href="${feedback.merge_request_path}">!${
feedback.merge_request_iid
}</a>`;
if (project && project.value && project.url) {
const projectLink = `<a href="${_.escape(project.url)}">${_.escape(project.value)}</a>`;
return sprintf(
__('Created merge request %{mergeRequestLink} at %{projectLink}'),
{
mergeRequestLink,
projectLink,
},
false,
);
}
return sprintf(__('Created merge request %{mergeRequestLink}'), { mergeRequestLink }, false);
},
},
};
</script>
<template>
<event-item :author="feedback.author" :created-at="feedback.created_at" icon-name="merge-request">
<div v-html="eventText"></div>
</event-item>
</template>
......@@ -4,16 +4,22 @@ import Modal from '~/vue_shared/components/gl_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue';
import DismissalNote from 'ee/vue_shared/security_reports/components/dismissal_note.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.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 SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
import VulnerabilityDetails from 'ee/vue_shared/security_reports/components/vulnerability_details.vue';
export default {
components: {
DismissalNote,
EventItem,
ExpandButton,
IssueNote,
LoadingButton,
MergeRequestNote,
Modal,
SolutionCard,
SplitButton,
......@@ -80,7 +86,7 @@ export default {
);
},
project() {
return this.modal.data.project || {};
return this.modal.data.project;
},
solution() {
return this.vulnerability && this.vulnerability.solution;
......@@ -102,14 +108,20 @@ export default {
(this.canCreateFeedbackPermission || this.canCreateIssuePermission)
);
},
vulnerability() {
return this.modal.vulnerability;
},
issueFeedback() {
return this.vulnerability && this.vulnerability.issue_feedback;
},
mergeRequestFeedback() {
return this.vulnerability && this.vulnerability.merge_request_feedback;
},
vulnerability() {
return this.modal.vulnerability;
dismissalFeedback() {
return (
this.vulnerability &&
(this.vulnerability.dismissal_feedback || this.vulnerability.dismissalFeedback)
);
},
valuedFields() {
const { data } = this.modal;
......@@ -148,43 +160,21 @@ export default {
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" />
<hr v-else />
<ul v-if="vulnerability.hasIssue || vulnerability.hasMergeRequest" class="notes card">
<li v-if="vulnerability.hasIssue" class="note">
<event-item
type="issue"
:project-name="project.value"
:project-link="project.url"
:author-name="issueFeedback.author.name"
:author-username="issueFeedback.author.username"
:action-link-text="`#${issueFeedback.issue_iid}`"
:action-link-url="issueFeedback.issue_url"
/>
<ul v-if="issueFeedback || mergeRequestFeedback" class="notes card my-4">
<li v-if="issueFeedback" class="note">
<issue-note :feedback="issueFeedback" :project="project" />
</li>
<li v-if="vulnerability.hasMergeRequest" class="note">
<event-item
type="mergeRequest"
:project-name="project.value"
:project-link="project.url"
:author-name="mergeRequestFeedback.author.name"
:author-username="mergeRequestFeedback.author.username"
:action-link-text="`!${mergeRequestFeedback.merge_request_iid}`"
:action-link-url="mergeRequestFeedback.merge_request_path"
/>
<li v-if="mergeRequestFeedback" class="note">
<merge-request-note :feedback="mergeRequestFeedback" :project="project" />
</li>
</ul>
<div class="card my-4">
<dismissal-note v-if="dismissalFeedback" :feedback="dismissalFeedback" :project="project" />
</div>
<div class="prepend-top-20 append-bottom-10">
<div class="col-sm-12 text-secondary">
<template v-if="hasDismissedBy">
{{ s__('ciReport|Dismissed by') }}
<a :href="vulnerability.dismissalFeedback.author.web_url" class="pipeline-id">
@{{ vulnerability.dismissalFeedback.author.username }}
</a>
{{ s__('ciReport|on pipeline') }}
<a :href="vulnerability.dismissalFeedback.pipeline.path" class="pipeline-id"
>#{{ vulnerability.dismissalFeedback.pipeline.id }}</a
>.
</template>
<a
v-if="vulnerabilityFeedbackHelpPath"
:href="vulnerabilityFeedbackHelpPath"
......
---
title: Adds a dismissal item to the vulnerability modal
merge_request: 11028
author:
type: added
import { shallowMount } from '@vue/test-utils';
import component from 'ee/vue_shared/security_reports/components/dismissal_note.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
describe('dismissal note', () => {
const now = new Date();
const feedback = {
author: {
name: 'Tanuki',
username: 'gitlab',
},
created_at: now.toString(),
};
const pipeline = {
path: '/path-to-the-pipeline',
id: 2,
};
const project = {
value: 'Project one',
url: '/path-to-the-project',
};
describe('with no attached project or pipeline', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback },
});
});
it('should pass the author to the event item', () => {
expect(wrapper.find(EventItem).props('author')).toBe(feedback.author);
});
it('should pass the created date to the event item', () => {
expect(wrapper.find(EventItem).props('createdAt')).toBe(feedback.created_at);
});
it('should return the event text with no project data', () => {
expect(wrapper.text()).toBe('Dismissed');
});
});
describe('with an attached project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback, project },
});
});
it('should return the event text with project data', () => {
expect(wrapper.text()).toBe(`Dismissed at ${project.value}`);
});
});
describe('with an attached pipeline', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback: { ...feedback, pipeline } },
});
});
it('should return the event text with project data', () => {
expect(wrapper.text()).toBe(`Dismissed on pipeline #${pipeline.id}`);
});
});
describe('with an attached pipeline and project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback: { ...feedback, pipeline }, project },
});
});
it('should return the event text with project data', () => {
expect(wrapper.text()).toBe(`Dismissed on pipeline #${pipeline.id} at ${project.value}`);
});
});
describe('with unsafe data', () => {
let wrapper;
const unsafeProject = {
...project,
value: 'Foo <script>alert("XSS")</script>',
};
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: {
feedback,
project: unsafeProject,
},
});
});
it('should escape the project name', () => {
// Note: We have to check the computed prop here because
// vue test utils unescapes the result of wrapper.text()
expect(wrapper.vm.eventText).not.toContain(project.value);
expect(wrapper.vm.eventText).toContain(
'Foo &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;',
);
});
});
});
......@@ -5,10 +5,10 @@ import mountComponent from 'helpers/vue_mount_component_helper';
describe('Event Item', () => {
const Component = Vue.extend(component);
const props = {
authorName: 'Tanuki',
authorUsername: 'gitlab',
actionLinkText: 'foo',
actionLinkUrl: 'example.com',
author: {
name: 'Tanuki',
username: 'gitlab',
},
};
let vm;
......@@ -16,66 +16,23 @@ describe('Event Item', () => {
vm.$destroy();
});
describe('issue item', () => {
beforeEach(() => {
props.type = 'issue';
vm = mountComponent(Component, props);
});
it('uses the issue icon', () => {
expect(vm.iconName).toBe('issue-created');
});
it('uses the issue name', () => {
expect(vm.$el.querySelector('.js-created').textContent).toContain('issue');
});
it('uses the author name', () => {
expect(vm.$el.querySelector('.js-author-name').textContent).toContain(props.authorName);
});
it('uses the author username', () => {
expect(vm.$el.querySelector('.js-username').textContent).toContain(props.authorUsername);
});
it('uses the action link text', () => {
expect(vm.$el.querySelector('.js-action-link').textContent).toContain(props.actionLinkText);
});
it('uses the action link url', () => {
expect(vm.$el.querySelector('.js-action-link').getAttribute('href')).toBe(
props.actionLinkUrl,
);
});
beforeEach(() => {
vm = mountComponent(Component, props);
});
describe('merge request item', () => {
beforeEach(() => {
props.type = 'mergeRequest';
vm = mountComponent(Component, props);
});
it('uses the merge request icon', () => {
expect(vm.iconName).toBe('merge-request');
});
it('uses the issue name', () => {
expect(vm.$el.querySelector('.js-created').textContent).toContain('merge request');
});
it('uses the author name', () => {
expect(vm.$el.querySelector('.js-author').textContent).toContain(props.author.name);
});
describe('unknown item', () => {
beforeEach(() => {
props.type = 'notARealType';
vm = mountComponent(Component, props);
});
it('uses the author username', () => {
expect(vm.$el.querySelector('.js-author').textContent).toContain(`@${props.author.username}`);
});
it('uses the fallback icon', () => {
expect(vm.iconName).toBe('plus');
});
it('uses the fallback icon', () => {
expect(vm.iconName).toBe('plus');
});
it("doesn't display the created text", () => {
expect(vm.$el.querySelector('.js-created')).toBeNull();
});
it('uses the fallback icon class', () => {
expect(vm.iconStyle).toBe('ci-status-icon-success');
});
});
import { shallowMount } from '@vue/test-utils';
import component from 'ee/vue_shared/security_reports/components/issue_note.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
describe('Issue note', () => {
const now = new Date();
const feedback = {
author: {
name: 'Tanuki',
username: 'gitlab',
},
issue_url: '/path-to-the-issue',
issue_iid: 1,
created_at: now.toString(),
};
const project = {
value: 'Project one',
url: '/path-to-the-project',
};
describe('with no attached project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback },
});
});
it('should pass the author to the event item', () => {
expect(wrapper.find(EventItem).props('author')).toBe(feedback.author);
});
it('should pass the created date to the event item', () => {
expect(wrapper.find(EventItem).props('createdAt')).toBe(feedback.created_at);
});
it('should return the event text with no project data', () => {
expect(wrapper.text()).toBe(`Created issue #${feedback.issue_iid}`);
});
});
describe('with an attached project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback, project },
});
});
it('should return the event text with project data', () => {
expect(wrapper.text()).toBe(`Created issue #${feedback.issue_iid} at ${project.value}`);
});
});
describe('with unsafe data', () => {
let wrapper;
const unsafeProject = {
...project,
value: 'Foo <script>alert("XSS")</script>',
};
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: {
feedback,
project: unsafeProject,
},
});
});
it('should escape the project name', () => {
// Note: We have to check the computed prop here because
// vue test utils unescapes the result of wrapper.text()
expect(wrapper.vm.eventText).not.toContain(project.value);
expect(wrapper.vm.eventText).toContain(
'Foo &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;',
);
});
});
});
import { shallowMount } from '@vue/test-utils';
import component from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
describe('Merge request note', () => {
const now = new Date();
const feedback = {
author: {
name: 'Tanuki',
username: 'gitlab',
},
merge_request_path: '/path-to-the-issue',
merge_request_iid: 1,
created_at: now.toString(),
};
const project = {
value: 'Project one',
url: '/path-to-the-project',
};
describe('with no attached project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback },
});
});
it('should pass the author to the event item', () => {
expect(wrapper.find(EventItem).props('author')).toBe(feedback.author);
});
it('should pass the created date to the event item', () => {
expect(wrapper.find(EventItem).props('createdAt')).toBe(feedback.created_at);
});
it('should return the event text with no project data', () => {
expect(wrapper.text()).toBe(`Created merge request !${feedback.merge_request_iid}`);
});
});
describe('with an attached project', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: { feedback, project },
});
});
it('should return the event text with project data', () => {
expect(wrapper.text()).toBe(
`Created merge request !${feedback.merge_request_iid} at ${project.value}`,
);
});
});
describe('with unsafe data', () => {
let wrapper;
const unsafeProject = {
...project,
value: 'Foo <script>alert("XSS")</script>',
};
beforeEach(() => {
wrapper = shallowMount(component, {
propsData: {
feedback,
project: unsafeProject,
},
});
});
it('should escape the project name', () => {
// Note: We have to check the computed prop here because
// vue test utils unescapes the result of wrapper.text()
expect(wrapper.vm.eventText).not.toContain(project.value);
expect(wrapper.vm.eventText).toContain(
'Foo &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;',
);
});
});
});
......@@ -20,13 +20,14 @@ describe('Security Reports modal', () => {
};
props.modal.vulnerability.isDismissed = true;
props.modal.vulnerability.dismissalFeedback = {
author: { username: 'jsmith' },
pipeline: { id: '123' },
author: { username: 'jsmith', name: 'John Smith' },
pipeline: { id: '123', path: '#' },
};
vm = mountComponent(Component, props);
});
it('renders dismissal author and associated pipeline', () => {
expect(vm.$el.textContent.trim()).toContain('John Smith');
expect(vm.$el.textContent.trim()).toContain('@jsmith');
expect(vm.$el.textContent.trim()).toContain('#123');
});
......
......@@ -3443,6 +3443,18 @@ msgstr ""
msgid "Created by me"
msgstr ""
msgid "Created issue %{issueLink}"
msgstr ""
msgid "Created issue %{issueLink} at %{projectLink}"
msgstr ""
msgid "Created merge request %{mergeRequestLink}"
msgstr ""
msgid "Created merge request %{mergeRequestLink} at %{projectLink}"
msgstr ""
msgid "Created on"
msgstr ""
......@@ -3970,6 +3982,18 @@ msgstr ""
msgid "Dismiss trial promotion"
msgstr ""
msgid "Dismissed"
msgstr ""
msgid "Dismissed at %{projectLink}"
msgstr ""
msgid "Dismissed on pipeline %{pipelineLink}"
msgstr ""
msgid "Dismissed on pipeline %{pipelineLink} at %{projectLink}"
msgstr ""
msgid "Do you want to customize how Google Code email addresses and usernames are imported into GitLab?"
msgstr ""
......@@ -9869,12 +9893,6 @@ msgstr ""
msgid "Reports|Vulnerability"
msgstr ""
msgid "Reports|issue"
msgstr ""
msgid "Reports|merge request"
msgstr ""
msgid "Reports|no changed test results"
msgstr ""
......@@ -13815,9 +13833,6 @@ msgstr ""
msgid "among other things"
msgstr ""
msgid "at"
msgstr ""
msgid "attach a new file"
msgstr ""
......@@ -13965,9 +13980,6 @@ msgstr ""
msgid "ciReport|Create merge request"
msgstr ""
msgid "ciReport|Created %{eventType}"
msgstr ""
msgid "ciReport|DAST"
msgstr ""
......@@ -13986,9 +13998,6 @@ msgstr ""
msgid "ciReport|Dismiss vulnerability"
msgstr ""
msgid "ciReport|Dismissed by"
msgstr ""
msgid "ciReport|Download and apply the patch to fix this vulnerability."
msgstr ""
......@@ -14127,9 +14136,6 @@ msgstr[1] ""
msgid "ciReport|View full report"
msgstr ""
msgid "ciReport|on pipeline"
msgstr ""
msgid "commented on %{link_to_project}"
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