Commit a10dcd98 authored by Sam Beckham's avatar Sam Beckham Committed by Filipa Lacerda

Adds dismissalFeedback to the secure modal

This starts us down the path of unifying the data on the modal and
allows us to start showing dismissal data in the same way across the two
different uses of the security reports modal
parent 036a8567
......@@ -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>
<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);
expect(vm.$el.querySelector('.js-author').textContent).toContain(props.author.name);
});
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,
);
});
});
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');
});
});
describe('unknown item', () => {
beforeEach(() => {
props.type = 'notARealType';
vm = mountComponent(Component, props);
expect(vm.$el.querySelector('.js-author').textContent).toContain(`@${props.author.username}`);
});
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