Commit cd1b20ae authored by Filipa Lacerda's avatar Filipa Lacerda

Backports security reports reusable components into CE code base

parent 16b867d8
<script>
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
import { inserted } from '~/feature_highlight/feature_highlight_helper';
import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
export default {
name: 'ReportsHelpPopover',
components: {
Icon,
},
props: {
options: {
type: Object,
required: true,
},
},
mounted() {
const $el = $(this.$el);
$el
.popover({
html: true,
trigger: 'focus',
container: 'body',
placement: 'top',
template:
'<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>',
...this.options,
})
.on('mouseenter', mouseenter)
.on('mouseleave', debouncedMouseleave(300))
.on('inserted.bs.popover', inserted)
.on('show.bs.popover', () => {
window.addEventListener('scroll', togglePopover.bind($el, false), { once: true });
});
},
};
</script>
<template>
<button
type="button"
class="btn btn-blank btn-transparent btn-help"
tabindex="0"
>
<icon name="question" />
</button>
</template>
<script>
import IssuesBlock from './report_issues.vue';
/**
* Renders block of issues
*/
export default {
components: {
IssuesBlock,
},
props: {
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
neutralIssues: {
type: Array,
required: false,
default: () => [],
},
allIssues: {
type: Array,
required: false,
default: () => [],
},
type: {
type: String,
required: true,
},
},
data() {
return {
isFullReportVisible: false,
};
},
computed: {
unresolvedIssuesStatus() {
return this.type === 'license' ? 'neutral' : 'failed';
},
},
methods: {
openFullReport() {
this.isFullReportVisible = true;
},
},
};
</script>
<template>
<div class="report-block-container">
<issues-block
v-if="unresolvedIssues.length"
:type="type"
:status="unresolvedIssuesStatus"
:issues="unresolvedIssues"
class="js-mr-code-new-issues"
/>
<issues-block
v-if="isFullReportVisible"
:type="type"
:issues="allIssues"
class="js-mr-code-all-issues"
status="failed"
/>
<issues-block
v-if="neutralIssues.length"
:type="type"
:issues="neutralIssues"
class="js-mr-code-non-issues"
status="neutral"
/>
<issues-block
v-if="resolvedIssues.length"
:type="type"
:issues="resolvedIssues"
class="js-mr-code-resolved-issues"
status="success"
/>
<button
v-if="allIssues.length && !isFullReportVisible"
type="button"
class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link"
@click="openFullReport"
>
{{ s__("ciReport|Show complete code vulnerabilities report") }}
</button>
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
props: {
issue: {
type: Object,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
},
methods: {
...mapActions(['openModal']),
handleIssueClick() {
const { issue, status, openModal } = this;
openModal({ issue, status });
},
},
};
</script>
<template>
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
@click="handleIssueClick()"
>
{{ issue.title }}
</button>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'ReportIssues',
components: {
Icon,
},
props: {
issues: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
// failed || success
status: {
type: String,
required: true,
},
},
computed: {
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === 'failed';
},
isStatusSuccess() {
return this.status === 'success';
},
isStatusNeutral() {
return this.status === 'neutral';
},
},
};
</script>
<template>
<div>
<ul class="report-block-list">
<li
v-for="(issue, index) in issues"
:class="{ 'is-dismissed': issue.isDismissed }"
:key="index"
class="report-block-list-issue"
>
<div
:class="{
failed: isStatusFailed,
success: isStatusSuccess,
neutral: isStatusNeutral,
}"
class="report-block-list-icon append-right-5"
>
<icon
:name="iconName"
:size="32"
/>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'ReportIssueLink',
props: {
issue: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="report-block-list-issue-description-link">
in
<a
v-if="issue.urlPath"
:href="issue.urlPath"
target="_blank"
rel="noopener noreferrer nofollow"
class="break-link"
>
{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
</a>
<template v-else>
{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
</template>
</div>
</template>
<script>
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import IssuesList from './issues_list.vue';
import Popover from './help_popover.vue';
const LOADING = 'LOADING';
const ERROR = 'ERROR';
const SUCCESS = 'SUCCESS';
export default {
name: 'ReportSection',
components: {
IssuesList,
StatusIcon,
Popover,
},
props: {
alwaysOpen: {
type: Boolean,
required: false,
default: false,
},
type: {
type: String,
required: false,
default: '',
},
status: {
type: String,
required: true,
},
loadingText: {
type: String,
required: false,
default: '',
},
errorText: {
type: String,
required: false,
default: '',
},
successText: {
type: String,
required: true,
},
unresolvedIssues: {
type: Array,
required: false,
default: () => [],
},
resolvedIssues: {
type: Array,
required: false,
default: () => [],
},
neutralIssues: {
type: Array,
required: false,
default: () => [],
},
allIssues: {
type: Array,
required: false,
default: () => [],
},
infoText: {
type: [String, Boolean],
required: false,
default: false,
},
hasIssues: {
type: Boolean,
required: true,
},
popoverOptions: {
type: Object,
default: () => ({}),
required: false,
},
},
data() {
return {
isCollapsed: true,
};
},
computed: {
collapseText() {
return this.isCollapsed ? __('Expand') : __('Collapse');
},
isLoading() {
return this.status === LOADING;
},
loadingFailed() {
return this.status === ERROR;
},
isSuccess() {
return this.status === SUCCESS;
},
isCollapsible() {
return !this.alwaysOpen && this.hasIssues;
},
isExpanded() {
return this.alwaysOpen || !this.isCollapsed;
},
statusIconName() {
if (this.isLoading) {
return 'loading';
}
if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
return 'warning';
}
return 'success';
},
headerText() {
if (this.isLoading) {
return this.loadingText;
}
if (this.isSuccess) {
return this.successText;
}
if (this.loadingFailed) {
return this.errorText;
}
return '';
},
hasPopover() {
return Object.keys(this.popoverOptions).length > 0;
},
},
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
},
};
</script>
<template>
<section class="media-section">
<div
class="media"
>
<status-icon
:status="statusIconName"
/>
<div
class="media-body space-children d-flex"
>
<span
class="js-code-text code-text"
>
{{ headerText }}
<popover
v-if="hasPopover"
:options="popoverOptions"
class="prepend-left-5"
/>
</span>
<button
v-if="isCollapsible"
type="button"
class="js-collapse-btn btn bt-default float-right btn-sm"
@click="toggleCollapsed"
>
{{ collapseText }}
</button>
</div>
</div>
<div
v-if="hasIssues"
v-show="isExpanded"
class="js-report-section-container"
>
<slot name="body">
<issues-list
:unresolved-issues="unresolvedIssues"
:resolved-issues="resolvedIssues"
:all-issues="allIssues"
:type="type"
/>
</slot>
</div>
</section>
</template>
<script>
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Popover from './help_popover.vue';
/**
* Renders the summary row for each report
*
* Used both in MR widget and Pipeline's view for:
* - Unit tests reports
* - Security reports
*/
export default {
name: 'ReportSummaryRow',
components: {
CiIcon,
LoadingIcon,
Popover,
},
props: {
summary: {
type: String,
required: true,
},
statusIcon: {
type: String,
required: true,
},
popoverOptions: {
type: Object,
required: true,
},
},
computed: {
iconStatus() {
return {
group: this.statusIcon,
icon: `status_${this.statusIcon}`,
};
},
},
};
</script>
<template>
<div class="report-block-list-issue report-block-list-issue-parent">
<div class="report-block-list-icon append-right-10 prepend-left-5">
<loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
/>
<ci-icon
v-else
:status="iconStatus"
/>
</div>
<div class="report-block-list-issue-description">
<div class="report-block-list-issue-description-text">
{{ summary }}
</div>
<popover :options="popoverOptions" />
</div>
</div>
</template>
import Vue from 'vue';
import Vuex from 'vuex';
import component from '~/vue_shared/components/reports/modal_open_name.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Modal open name', () => {
const Component = Vue.extend(component);
let vm;
const store = new Vuex.Store({
actions: {
openModal: () => {},
},
state: {},
mutations: {},
});
beforeEach(() => {
vm = mountComponentWithStore(Component, {
store,
props: {
issue: {
title: 'Issue',
},
status: 'failed',
},
});
});
afterEach(() => {
vm.$destroy();
});
it('renders the issue name', () => {
expect(vm.$el.textContent.trim()).toEqual('Issue');
});
it('calls openModal actions when button is clicked', () => {
spyOn(vm, 'openModal');
vm.$el.click();
expect(vm.openModal).toHaveBeenCalled();
});
});
// import Vue from 'vue';
// import reportIssues from '~/vue_shared/reports/components/report_issues.vue';
// describe('Report issues', () => {
// let vm;
// let ReportIssues;
// beforeEach(() => {
// ReportIssues = Vue.extend(reportIssues);
// });
// afterEach(() => {
// vm.$destroy();
// });
// // TODO
// });
import Vue from 'vue';
import component from '~/vue_shared/components/reports/report_link.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('report link', () => {
let vm;
const Component = Vue.extend(component);
afterEach(() => {
vm.$destroy();
});
describe('With url', () => {
it('renders link', () => {
vm = mountComponent(Component, {
issue: {
path: 'Gemfile.lock',
urlPath: '/Gemfile.lock',
},
});
expect(vm.$el.textContent.trim()).toContain('in');
expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/Gemfile.lock');
expect(vm.$el.querySelector('a').textContent.trim()).toEqual('Gemfile.lock');
});
});
describe('Without url', () => {
it('does not render link', () => {
vm = mountComponent(Component, {
issue: {
path: 'Gemfile.lock',
},
});
expect(vm.$el.querySelector('a')).toBeNull();
expect(vm.$el.textContent.trim()).toContain('in');
expect(vm.$el.textContent.trim()).toContain('Gemfile.lock');
});
});
describe('with line', () => {
it('renders line number', () => {
vm = mountComponent(Component, {
issue: {
path: 'Gemfile.lock',
urlPath:
'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
line: 22,
},
});
expect(vm.$el.querySelector('a').textContent.trim()).toContain('Gemfile.lock:22');
});
});
describe('without line', () => {
it('does not render line number', () => {
vm = mountComponent(Component, {
issue: {
path: 'Gemfile.lock',
urlPath:
'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
},
});
expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(':22');
});
});
});
import Vue from 'vue';
import reportSection from '~/vue_shared/components/reports/report_section.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Report section', () => {
let vm;
const ReportSection = Vue.extend(reportSection);
const resolvedIssues = [
{
name: 'Insecure Dependency',
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
path: 'Gemfile.lock',
line: 12,
urlPath: 'foo/Gemfile.lock',
},
];
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'SUCCESS',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues,
hasIssues: false,
alwaysOpen: false,
});
});
describe('isCollapsible', () => {
const testMatrix = [
{ hasIssues: false, alwaysOpen: false, isCollapsible: false },
{ hasIssues: false, alwaysOpen: true, isCollapsible: false },
{ hasIssues: true, alwaysOpen: false, isCollapsible: true },
{ hasIssues: true, alwaysOpen: true, isCollapsible: false },
];
testMatrix.forEach(({ hasIssues, alwaysOpen, isCollapsible }) => {
const issues = hasIssues ? 'has issues' : 'has no issues';
const open = alwaysOpen ? 'is always open' : 'is not always open';
it(`is ${isCollapsible}, if the report ${issues} and ${open}`, done => {
vm.hasIssues = hasIssues;
vm.alwaysOpen = alwaysOpen;
Vue.nextTick()
.then(() => {
expect(vm.isCollapsible).toBe(isCollapsible);
})
.then(done)
.catch(done.fail);
});
});
});
describe('isExpanded', () => {
const testMatrix = [
{ isCollapsed: false, alwaysOpen: false, isExpanded: true },
{ isCollapsed: false, alwaysOpen: true, isExpanded: true },
{ isCollapsed: true, alwaysOpen: false, isExpanded: false },
{ isCollapsed: true, alwaysOpen: true, isExpanded: true },
];
testMatrix.forEach(({ isCollapsed, alwaysOpen, isExpanded }) => {
const issues = isCollapsed ? 'is collapsed' : 'is not collapsed';
const open = alwaysOpen ? 'is always open' : 'is not always open';
it(`is ${isExpanded}, if the report ${issues} and ${open}`, done => {
vm.isCollapsed = isCollapsed;
vm.alwaysOpen = alwaysOpen;
Vue.nextTick()
.then(() => {
expect(vm.isExpanded).toBe(isExpanded);
})
.then(done)
.catch(done.fail);
});
});
});
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'LOADING',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
hasIssues: false,
});
expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report');
});
});
describe('with success status', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'SUCCESS',
loadingText: 'Loading codeclimate report',
errorText: 'foo',
successText: 'Code quality improved on 1 point and degraded on 1 point',
resolvedIssues,
hasIssues: true,
});
});
it('should render provided data', () => {
expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
'Code quality improved on 1 point and degraded on 1 point',
);
expect(vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length).toEqual(
resolvedIssues.length,
);
});
describe('toggleCollapsed', () => {
const hiddenCss = { display: 'none' };
it('toggles issues', done => {
vm.$el.querySelector('button').click();
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Collapse');
vm.$el.querySelector('button').click();
})
.then(Vue.nextTick)
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Expand');
})
.then(done)
.catch(done.fail);
});
it('is always expanded, if always-open is set to true', done => {
vm.alwaysOpen = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
expect(vm.$el.querySelector('button')).toBeNull();
})
.then(done)
.catch(done.fail);
});
});
});
describe('with failed request', () => {
it('should render error indicator', () => {
vm = mountComponent(ReportSection, {
type: 'codequality',
status: 'ERROR',
loadingText: 'Loading codeclimate report',
errorText: 'Failed to load codeclimate report',
successText: 'Code quality improved on 1 point and degraded on 1 point',
hasIssues: false,
});
expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report');
});
});
});
import Vue from 'vue';
import component from '~/vue_shared/components/reports/summary_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Summary row', () => {
const Component = Vue.extend(component);
let vm;
const props = {
summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability',
popoverOptions: {
title: 'Static Application Security Testing (SAST)',
content: '<a>Learn more about SAST</a>',
},
statusIcon: 'warning',
};
beforeEach(() => {
vm = mountComponent(Component, props);
});
afterEach(() => {
vm.$destroy();
});
it('renders provided summary', () => {
expect(
vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(),
).toEqual(props.summary);
});
it('renders provided icon', () => {
expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain(
'js-ci-status-icon-warning',
);
});
});
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