Commit 89a10646 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'auto-remediation' into 'master'

First pass at auto remediation changes

Closes #10404

See merge request gitlab-org/gitlab-ee!12010
parents 086aa2eb c79ce32b
...@@ -108,6 +108,7 @@ export default { ...@@ -108,6 +108,7 @@ export default {
'setVulnerabilitiesEndpoint', 'setVulnerabilitiesEndpoint',
'setVulnerabilitiesHistoryEndpoint', 'setVulnerabilitiesHistoryEndpoint',
'undoDismiss', 'undoDismiss',
'downloadPatch',
]), ]),
...mapActions('projects', ['setProjectsEndpoint', 'fetchProjects']), ...mapActions('projects', ['setProjectsEndpoint', 'fetchProjects']),
...mapActions('filters', ['lockFilter']), ...mapActions('filters', ['lockFilter']),
...@@ -141,6 +142,7 @@ export default { ...@@ -141,6 +142,7 @@ export default {
@dismissVulnerability="dismissVulnerability({ vulnerability, comment: $event })" @dismissVulnerability="dismissVulnerability({ vulnerability, comment: $event })"
@openDismissalCommentBox="openDismissalCommentBox()" @openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="undoDismiss({ vulnerability })" @revertDismissVulnerability="undoDismiss({ vulnerability })"
@downloadPatch="downloadPatch({ vulnerability })"
/> />
</div> </div>
</template> </template>
import $ from 'jquery'; import $ from 'jquery';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import downloadPatchHelper from 'ee/vue_shared/security_reports/store/utils/download_patch_helper';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
...@@ -217,6 +218,20 @@ export const receiveUndoDismissError = ({ commit }, { flashError }) => { ...@@ -217,6 +218,20 @@ export const receiveUndoDismissError = ({ commit }, { flashError }) => {
} }
}; };
export const downloadPatch = ({ state }) => {
/*
This action doesn't actually mutate the Vuex state and is a dirty
workaround to modifying the dom. We do this because gl-split-button
relies on a old version of vue-bootstrap and it doesn't allow us to
set a href for a file download.
https://gitlab.com/gitlab-org/gitlab-ui/issues/188#note_165808493
*/
const { vulnerability } = state.modal;
downloadPatchHelper(vulnerability.remediations[0].diff);
$('#modal-mrwidget-security-issue').modal('hide');
};
export const createMergeRequest = ({ dispatch }, { vulnerability, flashError }) => { export const createMergeRequest = ({ dispatch }, { vulnerability, flashError }) => {
const { const {
report_type, report_type,
......
...@@ -58,6 +58,17 @@ export default { ...@@ -58,6 +58,17 @@ export default {
dismissalCommentErrorMessage: '', dismissalCommentErrorMessage: '',
}), }),
computed: { computed: {
canDownloadPatch() {
const remediationDiff = this.remediation && this.remediation.diff;
return Boolean(
remediationDiff &&
remediationDiff.length > 0 &&
(!this.vulnerability.hasMergeRequest && this.remediation),
);
},
hasRemediation() {
return Boolean(this.remediation);
},
hasDismissedBy() { hasDismissedBy() {
return ( return (
this.vulnerability && this.vulnerability &&
...@@ -77,9 +88,6 @@ export default { ...@@ -77,9 +88,6 @@ export default {
this.vulnerability && this.vulnerability.remediations && this.vulnerability.remediations[0] this.vulnerability && this.vulnerability.remediations && this.vulnerability.remediations[0]
); );
}, },
renderSolutionCard() {
return this.solution || this.remediation;
},
/** /**
* The slot for the footer should be rendered if any of the conditions is true. * The slot for the footer should be rendered if any of the conditions is true.
*/ */
...@@ -170,8 +178,14 @@ export default { ...@@ -170,8 +178,14 @@ export default {
<slot> <slot>
<vulnerability-details :details="valuedFields" class="js-vulnerability-details" /> <vulnerability-details :details="valuedFields" class="js-vulnerability-details" />
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" /> <solution-card
<hr v-else /> :solution="solution"
:remediation="remediation"
:has-mr="vulnerability.hasMergeRequest"
:has-remediation="hasRemediation"
:has-download="canDownloadPatch"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
/>
<ul v-if="issueFeedback || mergeRequestFeedback" class="notes card my-4"> <ul v-if="issueFeedback || mergeRequestFeedback" class="notes card my-4">
<li v-if="issueFeedback" class="note"> <li v-if="issueFeedback" class="note">
...@@ -199,18 +213,6 @@ export default { ...@@ -199,18 +213,6 @@ export default {
</div> </div>
</div> </div>
<div class="prepend-top-20 append-bottom-10">
<div class="col-sm-12 text-secondary">
<a
v-if="vulnerabilityFeedbackHelpPath"
:href="vulnerabilityFeedbackHelpPath"
class="js-link-vulnerabilityFeedbackHelpPath"
>
{{ s__('ciReport|Learn more about interacting with security reports (Alpha).') }}
</a>
</div>
</div>
<div v-if="modal.error" class="alert alert-danger">{{ modal.error }}</div> <div v-if="modal.error" class="alert alert-danger">{{ modal.error }}</div>
</slot> </slot>
<div slot="footer"> <div slot="footer">
...@@ -225,6 +227,7 @@ export default { ...@@ -225,6 +227,7 @@ export default {
:vulnerability="vulnerability" :vulnerability="vulnerability"
:can-create-issue="Boolean(!vulnerability.hasIssue && canCreateIssue)" :can-create-issue="Boolean(!vulnerability.hasIssue && canCreateIssue)"
:can-create-merge-request="Boolean(!vulnerability.hasMergeRequest && remediation)" :can-create-merge-request="Boolean(!vulnerability.hasMergeRequest && remediation)"
:can-download-patch="canDownloadPatch"
:can-dismiss-vulnerability="canDismissVulnerability" :can-dismiss-vulnerability="canDismissVulnerability"
:is-dismissed="vulnerability.isDismissed" :is-dismissed="vulnerability.isDismissed"
@createMergeRequest="$emit('createMergeRequest')" @createMergeRequest="$emit('createMergeRequest')"
...@@ -232,6 +235,7 @@ export default { ...@@ -232,6 +235,7 @@ export default {
@dismissVulnerability="$emit('dismissVulnerability')" @dismissVulnerability="$emit('dismissVulnerability')"
@openDismissalCommentBox="$emit('openDismissalCommentBox')" @openDismissalCommentBox="$emit('openDismissalCommentBox')"
@revertDismissVulnerability="$emit('revertDismissVulnerability')" @revertDismissVulnerability="$emit('revertDismissVulnerability')"
@downloadPatch="$emit('downloadPatch')"
/> />
</div> </div>
</modal> </modal>
......
...@@ -33,6 +33,11 @@ export default { ...@@ -33,6 +33,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
canDownloadPatch: {
type: Boolean,
required: false,
default: false,
},
canDismissVulnerability: { canDismissVulnerability: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -49,16 +54,25 @@ export default { ...@@ -49,16 +54,25 @@ export default {
action: 'createNewIssue', action: 'createNewIssue',
}; };
const MRButton = { const MRButton = {
name: s__('ciReport|Create merge request'), name: s__('ciReport|Resolve with merge request'),
tagline: s__('ciReport|Implement this solution by creating a merge request'), tagline: s__('ciReport|Automatically apply the patch in a new branch'),
isLoading: this.modal.isCreatingMergeRequest, isLoading: this.modal.isCreatingMergeRequest,
action: 'createMergeRequest', action: 'createMergeRequest',
}; };
const DownloadButton = {
name: s__('ciReport|Download patch to resolve'),
tagline: s__('ciReport|Download the patch to apply it manually'),
action: 'downloadPatch',
};
if (this.canCreateMergeRequest) { if (this.canCreateMergeRequest) {
buttons.push(MRButton); buttons.push(MRButton);
} }
if (this.canDownloadPatch) {
buttons.push(DownloadButton);
}
if (this.canCreateIssue) { if (this.canCreateIssue) {
buttons.push(issueButton); buttons.push(issueButton);
} }
...@@ -87,8 +101,10 @@ export default { ...@@ -87,8 +101,10 @@ export default {
<split-button <split-button
v-if="actionButtons.length > 1" v-if="actionButtons.length > 1"
:buttons="actionButtons" :buttons="actionButtons"
class="js-split-button"
@createMergeRequest="$emit('createMergeRequest')" @createMergeRequest="$emit('createMergeRequest')"
@createNewIssue="$emit('createNewIssue')" @createNewIssue="$emit('createNewIssue')"
@downloadPatch="$emit('downloadPatch')"
/> />
<loading-button <loading-button
...@@ -97,6 +113,7 @@ export default { ...@@ -97,6 +113,7 @@ export default {
:disabled="actionButtons[0].isLoading" :disabled="actionButtons[0].isLoading"
:label="actionButtons[0].name" :label="actionButtons[0].name"
container-class="btn btn-success btn-inverted" container-class="btn btn-success btn-inverted"
class="js-action-button"
@click="$emit(actionButtons[0].action)" @click="$emit(actionButtons[0].action)"
/> />
</div> </div>
......
...@@ -9,44 +9,89 @@ export default { ...@@ -9,44 +9,89 @@ export default {
solution: { solution: {
type: String, type: String,
default: '', default: '',
required: false,
}, },
remediation: { remediation: {
type: Object, type: Object,
default: null, default: null,
required: false,
},
hasDownload: {
type: Boolean,
default: false,
required: false,
},
hasMr: {
type: Boolean,
default: false,
required: false,
},
hasRemediation: {
type: Boolean,
default: false,
required: false,
},
vulnerabilityFeedbackHelpPath: {
type: String,
default: '',
required: false,
}, },
}, },
computed: { computed: {
solutionText() { solutionText() {
return (this.remediation && this.remediation.summary) || this.solution; return (this.remediation && this.remediation.summary) || this.solution;
}, },
remediationDiff() { helpPath() {
return this.remediation && this.remediation.diff; return `${this.vulnerabilityFeedbackHelpPath}#solutions-for-vulnerabilities`;
}, },
downloadUrl() { showCreateMergeRequestMsg() {
return `data:text/plain;base64,${this.remediationDiff}`; return !this.hasMr && this.hasRemediation && this.hasDownload;
}, },
hasDiff() { showLearnAboutRemedationMsg() {
return (this.remediationDiff && this.remediationDiff.length > 0) || false; if (this.hasMr) {
return false;
}
return true;
},
showMsg() {
return (
this.vulnerabilityFeedbackHelpPath &&
(this.showCreateMergeRequestMsg || this.showLearnAboutRemedationMsg)
);
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="card js-solution-card my-4"> <div class="card js-solution-card my-4">
<div class="card-body d-flex align-items-center"> <div v-if="solutionText" class="card-body d-flex align-items-center">
<div class="col-2 d-flex align-items-center pl-0"> <div class="col-2 d-flex align-items-center pl-0">
<div class="circle-icon-container" aria-hidden="true"><icon name="bulb" /></div> <div class="circle-icon-container" aria-hidden="true"><icon name="bulb" /></div>
<strong class="text-right flex-grow-1">{{ s__('ciReport|Solution') }}:</strong> <strong class="text-right flex-grow-1">{{ s__('ciReport|Solution') }}:</strong>
</div> </div>
<span class="col-10 flex-shrink-1 pl-0">{{ solutionText }}</span> <span class="col-10 flex-shrink-1 pl-0">{{ solutionText }}</span>
<gl-button v-if="hasDiff" :href="downloadUrl" download="remediation.patch">
<icon name="download" /> {{ s__('ciReport|Download patch') }}
</gl-button>
</div> </div>
<div v-if="hasDiff" class="card-footer"> <template v-if="showMsg">
<div class="card-footer" :class="{ 'border-0': !solutionText }">
<em class="text-secondary"> <em class="text-secondary">
{{ s__('ciReport|Download and apply the patch to fix this vulnerability.') }} <template v-if="showCreateMergeRequestMsg">
{{
s__(
'ciReport|Create a merge request to implement this solution, or download and apply the patch manually.',
)
}}
</template>
<a
v-if="showLearnAboutRemedationMsg"
:href="helpPath"
class="js-link-vulnerabilityFeedbackHelpPath"
>
{{ s__('ciReport|Learn more about interacting with security reports') }}
<icon :size="16" name="external-link" class="align-text-top" />
</a>
</em> </em>
</div> </div>
</template>
</div> </div>
</template> </template>
...@@ -256,6 +256,7 @@ export default { ...@@ -256,6 +256,7 @@ export default {
'createMergeRequest', 'createMergeRequest',
'openDismissalCommentBox', 'openDismissalCommentBox',
'closeDismissalCommentBox', 'closeDismissalCommentBox',
'downloadPatch',
]), ]),
}, },
}; };
...@@ -361,6 +362,7 @@ export default { ...@@ -361,6 +362,7 @@ export default {
@dismissVulnerability="dismissVulnerability" @dismissVulnerability="dismissVulnerability"
@openDismissalCommentBox="openDismissalCommentBox()" @openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="revertDismissVulnerability" @revertDismissVulnerability="revertDismissVulnerability"
@downloadPatch="downloadPatch"
/> />
</div> </div>
</report-section> </report-section>
......
...@@ -236,6 +236,7 @@ export default { ...@@ -236,6 +236,7 @@ export default {
'createMergeRequest', 'createMergeRequest',
'openDismissalCommentBox', 'openDismissalCommentBox',
'closeDismissalCommentBox', 'closeDismissalCommentBox',
'downloadPatch',
]), ]),
summaryTextBuilder(reportType, issuesCount = 0) { summaryTextBuilder(reportType, issuesCount = 0) {
if (issuesCount === 0) { if (issuesCount === 0) {
...@@ -325,6 +326,7 @@ export default { ...@@ -325,6 +326,7 @@ export default {
@dismissVulnerability="dismissVulnerability" @dismissVulnerability="dismissVulnerability"
@openDismissalCommentBox="openDismissalCommentBox()" @openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="revertDismissVulnerability" @revertDismissVulnerability="revertDismissVulnerability"
@downloadPatch="downloadPatch"
/> />
</div> </div>
</template> </template>
...@@ -3,6 +3,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,6 +3,7 @@ import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import * as types from './mutation_types'; import * as types from './mutation_types';
import downloadPatchHelper from './utils/download_patch_helper';
export const setHeadBlobPath = ({ commit }, blobPath) => commit(types.SET_HEAD_BLOB_PATH, blobPath); export const setHeadBlobPath = ({ commit }, blobPath) => commit(types.SET_HEAD_BLOB_PATH, blobPath);
...@@ -377,6 +378,20 @@ export const createMergeRequest = ({ state, dispatch }) => { ...@@ -377,6 +378,20 @@ export const createMergeRequest = ({ state, dispatch }) => {
}); });
}; };
export const downloadPatch = ({ state }) => {
/*
This action doesn't actually mutate the Vuex state and is a dirty
workaround to modifying the dom. We do this because gl-split-button
relies on a old version of vue-bootstrap and it doesn't allow us to
set a href for a file download.
https://gitlab.com/gitlab-org/gitlab-ui/issues/188#note_165808493
*/
const { vulnerability } = state.modal;
downloadPatchHelper(vulnerability.remediations[0].diff);
$('#modal-mrwidget-security-issue').modal('hide');
};
export const requestCreateMergeRequest = ({ commit }) => { export const requestCreateMergeRequest = ({ commit }) => {
commit(types.REQUEST_CREATE_MERGE_REQUEST); commit(types.REQUEST_CREATE_MERGE_REQUEST);
}; };
......
const downloadPatchHelper = (patch, opts = {}) => {
const mergedOpts = Object.assign(
{
isEncoded: true,
},
opts,
);
const url = `data:text/plain;base64,${mergedOpts.isEncoded ? patch : btoa(patch)}`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'remediation.patch');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export { downloadPatchHelper as default };
---
title: First pass at auto remediation changes
merge_request: 12010
author:
type: changed
...@@ -42,8 +42,8 @@ describe('Security Reports modal footer', () => { ...@@ -42,8 +42,8 @@ describe('Security Reports modal footer', () => {
}); });
it('only renders the create merge request button', () => { it('only renders the create merge request button', () => {
expect(wrapper.vm.actionButtons[0].name).toBe('Create merge request'); expect(wrapper.vm.actionButtons[0].name).toBe('Resolve with merge request');
expect(wrapper.find(LoadingButton).props('label')).toBe('Create merge request'); expect(wrapper.find(LoadingButton).props('label')).toBe('Resolve with merge request');
}); });
it('emits createMergeRequest when create merge request button is clicked', () => { it('emits createMergeRequest when create merge request button is clicked', () => {
...@@ -52,6 +52,26 @@ describe('Security Reports modal footer', () => { ...@@ -52,6 +52,26 @@ describe('Security Reports modal footer', () => {
}); });
}); });
describe('can download download patch', () => {
beforeEach(() => {
const propsData = {
modal: createState().modal,
canDownloadPatch: true,
};
wrapper = mount(component, { propsData });
});
it('renders the download patch button', () => {
expect(wrapper.vm.actionButtons[0].name).toBe('Download patch to resolve');
expect(wrapper.find(LoadingButton).props('label')).toBe('Download patch to resolve');
});
it('emits downloadPatch when download patch button is clicked', () => {
wrapper.find(LoadingButton).trigger('click');
expect(wrapper.emitted().downloadPatch).toBeTruthy();
});
});
describe('can create merge request and issue', () => { describe('can create merge request and issue', () => {
beforeEach(() => { beforeEach(() => {
const propsData = { const propsData = {
...@@ -62,9 +82,32 @@ describe('Security Reports modal footer', () => { ...@@ -62,9 +82,32 @@ describe('Security Reports modal footer', () => {
wrapper = mount(component, { propsData }); wrapper = mount(component, { propsData });
}); });
it('renders the split button', () => { it('renders create merge request and issue button as a split button', () => {
expect(wrapper.contains('.js-split-button')).toBe(true);
expect(wrapper.vm.actionButtons.length).toBe(2); expect(wrapper.vm.actionButtons.length).toBe(2);
expect(wrapper.find(SplitButton).exists()).toBe(true); expect(wrapper.find(SplitButton).exists()).toBe(true);
expect(wrapper.find('.js-split-button').text()).toContain('Resolve with merge request');
expect(wrapper.find('.js-split-button').text()).toContain('Create issue');
});
});
describe('can create merge request, issue, and download patch', () => {
beforeEach(() => {
const propsData = {
modal: createState().modal,
canCreateIssue: true,
canCreateMergeRequest: true,
canDownloadPatch: true,
};
wrapper = mount(component, { propsData });
});
it('renders the split button', () => {
expect(wrapper.vm.actionButtons.length).toBe(3);
expect(wrapper.find(SplitButton).exists()).toBe(true);
expect(wrapper.find('.js-split-button').text()).toContain('Resolve with merge request');
expect(wrapper.find('.js-split-button').text()).toContain('Create issue');
expect(wrapper.find('.js-split-button').text()).toContain('Download patch to resolve');
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/modal.vue'; import component from 'ee/vue_shared/security_reports/components/modal.vue';
import createState from 'ee/vue_shared/security_reports/store/state'; import createState from 'ee/vue_shared/security_reports/store/state';
import mountComponent from 'helpers/vue_mount_component_helper'; import { mount, shallowMount } from '@vue/test-utils';
describe('Security Reports modal', () => { describe('Security Reports modal', () => {
const Component = Vue.extend(component); const Component = Vue.extend(component);
let vm; let wrapper;
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('with permissions', () => { describe('with permissions', () => {
describe('with dismissed issue', () => { describe('with dismissed issue', () => {
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
canDismissVulnerability: true, canDismissVulnerability: true,
}; };
props.modal.vulnerability.isDismissed = true; propsData.modal.vulnerability.isDismissed = true;
props.modal.vulnerability.dismissalFeedback = { propsData.modal.vulnerability.dismissalFeedback = {
author: { username: 'jsmith', name: 'John Smith' }, author: { username: 'jsmith', name: 'John Smith' },
pipeline: { id: '123', path: '#' }, pipeline: { id: '123', path: '#' },
}; };
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
}); });
it('renders dismissal author and associated pipeline', () => { it('renders dismissal author and associated pipeline', () => {
expect(vm.$el.textContent.trim()).toContain('John Smith'); expect(wrapper.text().trim()).toContain('John Smith');
expect(vm.$el.textContent.trim()).toContain('@jsmith'); expect(wrapper.text().trim()).toContain('@jsmith');
expect(vm.$el.textContent.trim()).toContain('#123'); expect(wrapper.text().trim()).toContain('#123');
}); });
}); });
describe('with not dismissed issue', () => { describe('with not dismissed issue', () => {
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
canDismissVulnerability: true, canDismissVulnerability: true,
}; };
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
}); });
it('renders the footer', () => { it('renders the footer', () => {
expect(vm.$el.classList.contains('modal-hide-footer')).toEqual(false); expect(wrapper.classes('modal-hide-footer')).toBe(false);
});
});
describe('with merge request available', () => {
beforeEach(() => {
const propsData = {
modal: createState().modal,
canCreateIssue: true,
canCreateMergeRequest: true,
};
const summary = 'Upgrade to 123';
const diff = 'abc123';
propsData.modal.vulnerability.remediations = [{ summary, diff }];
wrapper = mount(Component, { propsData, sync: true });
});
it('renders create merge request and issue button as a split button', () => {
expect(wrapper.contains('.js-split-button')).toBe(true);
expect(wrapper.find('.js-split-button').text()).toContain('Resolve with merge request');
expect(wrapper.find('.js-split-button').text()).toContain('Create issue');
});
describe('with merge request created', () => {
it('renders the issue button as a single button', done => {
const propsData = {
modal: createState().modal,
canCreateIssue: true,
canCreateMergeRequest: true,
};
propsData.modal.vulnerability.hasMergeRequest = true;
wrapper.setProps(propsData);
Vue.nextTick()
.then(() => {
expect(wrapper.contains('.js-split-button')).toBe(false);
expect(wrapper.contains('.js-action-button')).toBe(true);
expect(wrapper.find('.js-action-button').text()).not.toContain(
'Resolve with merge request',
);
expect(wrapper.find('.js-action-button').text()).toContain('Create issue');
done();
})
.catch(done.fail);
});
}); });
}); });
describe('data', () => { describe('data', () => {
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath', vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath',
}; };
props.modal.title = 'Arbitrary file existence disclosure in Action Pack'; propsData.modal.title = 'Arbitrary file existence disclosure in Action Pack';
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
}); });
it('renders title', () => { it('renders title', () => {
expect(vm.$el.textContent).toContain('Arbitrary file existence disclosure in Action Pack'); expect(wrapper.text()).toContain('Arbitrary file existence disclosure in Action Pack');
}); });
it('renders help link', () => { it('renders help link', () => {
expect( expect(wrapper.find('.js-link-vulnerabilityFeedbackHelpPath').attributes('href')).toBe(
vm.$el.querySelector('.js-link-vulnerabilityFeedbackHelpPath').getAttribute('href'), 'feedbacksHelpPath#solutions-for-vulnerabilities',
).toEqual('feedbacksHelpPath'); );
}); });
}); });
}); });
describe('without permissions', () => { describe('without permissions', () => {
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
}); });
it('does not display the footer', () => { it('does not display the footer', () => {
expect(vm.$el.classList.contains('modal-hide-footer')).toEqual(true); expect(wrapper.classes('modal-hide-footer')).toBe(true);
}); });
}); });
describe('with a resolved issue', () => { describe('with a resolved issue', () => {
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
props.modal.isResolved = true; propsData.modal.isResolved = true;
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
}); });
it('does not display the footer', () => { it('does not display the footer', () => {
expect(vm.$el.classList.contains('modal-hide-footer')).toBeTruthy(); expect(wrapper.classes('modal-hide-footer')).toBe(true);
}); });
}); });
...@@ -102,24 +148,24 @@ describe('Security Reports modal', () => { ...@@ -102,24 +148,24 @@ describe('Security Reports modal', () => {
const fileValue = '/some/file.path'; const fileValue = '/some/file.path';
beforeEach(() => { beforeEach(() => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
props.modal.vulnerability.blob_path = blobPath; propsData.modal.vulnerability.blob_path = blobPath;
props.modal.data.namespace.value = namespaceValue; propsData.modal.data.namespace.value = namespaceValue;
props.modal.data.file.value = fileValue; propsData.modal.data.file.value = fileValue;
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
}); });
it('is rendered', () => { it('is rendered', () => {
const vulnerabilityDetails = vm.$el.querySelector('.js-vulnerability-details'); const vulnerabilityDetails = wrapper.find('.js-vulnerability-details');
expect(vulnerabilityDetails).not.toBeNull(); expect(vulnerabilityDetails.exists()).toBe(true);
expect(vulnerabilityDetails.textContent).toContain('foobar'); expect(vulnerabilityDetails.text()).toContain('foobar');
}); });
it('computes valued fields properly', () => { it('computes valued fields properly', () => {
expect(vm.valuedFields).toMatchObject({ expect(wrapper.vm.valuedFields).toMatchObject({
file: { file: {
value: fileValue, value: fileValue,
url: blobPath, url: blobPath,
...@@ -137,46 +183,46 @@ describe('Security Reports modal', () => { ...@@ -137,46 +183,46 @@ describe('Security Reports modal', () => {
describe('Solution Card', () => { describe('Solution Card', () => {
it('is rendered if the vulnerability has a solution', () => { it('is rendered if the vulnerability has a solution', () => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
const solution = 'Upgrade to XYZ'; const solution = 'Upgrade to XYZ';
props.modal.vulnerability.solution = solution; propsData.modal.vulnerability.solution = solution;
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
const solutionCard = vm.$el.querySelector('.js-solution-card'); const solutionCard = wrapper.find('.js-solution-card');
expect(solutionCard).not.toBeNull(); expect(solutionCard.exists()).toBe(true);
expect(solutionCard.textContent).toContain(solution); expect(solutionCard.text()).toContain(solution);
expect(vm.$el.querySelector('hr')).toBeNull(); expect(wrapper.contains('hr')).toBe(false);
}); });
it('is rendered if the vulnerability has a remediation', () => { it('is rendered if the vulnerability has a remediation', () => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
const summary = 'Upgrade to 123'; const summary = 'Upgrade to 123';
props.modal.vulnerability.remediations = [{ summary }]; propsData.modal.vulnerability.remediations = [{ summary }];
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
const solutionCard = vm.$el.querySelector('.js-solution-card'); const solutionCard = wrapper.find('.js-solution-card');
expect(solutionCard).not.toBeNull(); expect(solutionCard.exists()).toBe(true);
expect(solutionCard.textContent).toContain(summary); expect(solutionCard.text()).toContain(summary);
expect(vm.$el.querySelector('hr')).toBeNull(); expect(wrapper.contains('hr')).toBe(false);
}); });
it('is not rendered if the vulnerability has neither a remediation nor a solution but renders a HR instead.', () => { it('is rendered if the vulnerability has neither a remediation nor a solution', () => {
const props = { const propsData = {
modal: createState().modal, modal: createState().modal,
}; };
vm = mountComponent(Component, props); wrapper = mount(Component, { propsData });
const solutionCard = vm.$el.querySelector('.js-solution-card'); const solutionCard = wrapper.find('.js-solution-card');
expect(solutionCard).toBeNull(); expect(solutionCard.exists()).toBe(true);
expect(vm.$el.querySelector('hr')).not.toBeNull(); expect(wrapper.contains('hr')).toBe(false);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/solution_card.vue'; import component from 'ee/vue_shared/security_reports/components/solution_card.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import { shallowMount } from '@vue/test-utils';
import { s__ } from '~/locale';
describe('Solution Card', () => { describe('Solution Card', () => {
const Component = Vue.extend(component); const Component = Vue.extend(component);
const solution = 'Upgrade to XYZ'; const solution = 'Upgrade to XYZ';
const remediation = { summary: 'Update to 123', fixes: [], diff: 'SGVsbG8gR2l0TGFi' }; const remediation = { summary: 'Update to 123', fixes: [], diff: 'SGVsbG8gR2l0TGFi' };
let vm; const vulnerabilityFeedbackHelpPath = '/foo';
let wrapper;
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('computed properties', () => { describe('computed properties', () => {
describe('solutionText', () => { describe('solutionText', () => {
it('takes the value of solution', () => { it('takes the value of solution', () => {
const props = { solution }; const propsData = { solution };
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
expect(vm.solutionText).toEqual(solution); expect(wrapper.vm.solutionText).toEqual(solution);
}); });
it('takes the summary from a remediation', () => { it('takes the summary from a remediation', () => {
const props = { remediation }; const propsData = { remediation };
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
expect(vm.solutionText).toEqual(remediation.summary); expect(wrapper.vm.solutionText).toEqual(remediation.summary);
}); });
it('takes the summary from a remediation, if both are defined', () => { it('takes the summary from a remediation, if both are defined', () => {
const props = { remediation, solution }; const propsData = { remediation, solution };
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
expect(vm.solutionText).toEqual(remediation.summary); expect(wrapper.vm.solutionText).toEqual(remediation.summary);
}); });
}); });
describe('remediationDiff', () => {
it('returns the base64 diff from a remediation', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.remediationDiff).toEqual(remediation.diff);
}); });
});
describe('hasDiff', () => {
it('is false if only the solution is defined', () => {
const props = { solution };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(false); describe('rendering', () => {
describe('with solution', () => {
beforeEach(() => {
const propsData = { solution };
wrapper = shallowMount(Component, { propsData });
}); });
it('is false if remediation misses a diff', () => { it('renders the solution text and label', () => {
const props = { remediation: { summary: 'XYZ' } }; expect(trimText(wrapper.find('.card-body').text())).toContain(`Solution: ${solution}`);
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(false);
}); });
it('is true if remediation has a diff', () => { it('does not render the card footer', () => {
const props = { remediation }; expect(wrapper.contains('.card-footer')).toBe(false);
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(true);
});
}); });
describe('downloadUrl', () => { it('does not render the download link', () => {
it('returns dataUrl for a remediation diff ', () => { expect(wrapper.contains('a')).toBe(false);
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.downloadUrl).toBe('data:text/plain;base64,SGVsbG8gR2l0TGFi');
});
}); });
}); });
describe('rendering', () => { describe('with remediation', () => {
describe('with solution', () => {
beforeEach(() => { beforeEach(() => {
const props = { solution }; const propsData = { remediation, vulnerabilityFeedbackHelpPath, hasRemediation: true };
vm = mountComponent(Component, props); wrapper = shallowMount(Component, { propsData });
}); });
it('renders the solution text and label', () => { it('renders the solution text and label', () => {
expect(trimText(vm.$el.querySelector('.card-body').textContent)).toContain( expect(trimText(wrapper.find('.card-body').text())).toContain(
`Solution: ${solution}`, `Solution: ${remediation.summary}`,
); );
}); });
it('does not render the card footer', () => { it('renders the card footer', () => {
expect(vm.$el.querySelector('.card-footer')).toBeNull(); expect(wrapper.contains('.card-footer')).toBe(true);
}); });
it('does not render the download link', () => { describe('with download patch', () => {
expect(vm.$el.querySelector('a')).toBeNull(); beforeEach(() => {
wrapper.setProps({ hasDownload: true });
}); });
it('renders the learn more about remediation solutions', () => {
expect(wrapper.find('.card-footer').text()).toContain(
s__('ciReport|Learn more about interacting with security reports'),
);
}); });
describe('with remediation', () => { it('does not render the download and apply solution message when there is a file download and a merge request already exists', () => {
beforeEach(() => { wrapper.setProps({ hasMr: true });
const props = { remediation }; expect(wrapper.contains('.card-footer')).toBe(false);
vm = mountComponent(Component, props);
}); });
it('renders the solution text and label', () => { it('renders the create a merge request to implement this solution message', () => {
expect(trimText(vm.$el.querySelector('.card-body').textContent)).toContain( expect(wrapper.find('.card-footer').text()).toContain(
`Solution: ${remediation.summary}`, s__(
'ciReport|Create a merge request to implement this solution, or download and apply the patch manually.',
),
); );
}); });
});
it('renders the card footer', () => { describe('without download patch', () => {
expect(vm.$el.querySelector('.card-footer')).not.toBeNull(); it('renders the learn more about remediation solutions', () => {
expect(wrapper.find('.card-footer').text()).toContain(
s__('ciReport|Learn more about interacting with security reports'),
);
}); });
it('renders the download link', () => { it('does not render the download and apply solution message', () => {
const linkEl = vm.$el.querySelector('a'); expect(wrapper.find('.card-footer').text()).not.toContain(
s__('ciReport|Download and apply the patch manually to resolve.'),
);
});
expect(linkEl).not.toBeNull(); it('does not render the create a merge request to implement this solution message', () => {
expect(linkEl.getAttribute('href')).toEqual(vm.downloadUrl); expect(wrapper.find('.card-footer').text()).not.toContain(
expect(linkEl.getAttribute('download')).toEqual('remediation.patch'); s__(
'ciReport|Create a merge request to implement this solution, or download and apply the patch manually.',
),
);
});
}); });
}); });
}); });
......
...@@ -369,6 +369,32 @@ describe('openModal', () => { ...@@ -369,6 +369,32 @@ describe('openModal', () => {
}); });
}); });
describe('downloadPatch', () => {
it('creates a download link and clicks on it to download the file', () => {
spyOn(document, 'createElement').and.callThrough();
spyOn(document.body, 'appendChild').and.callThrough();
spyOn(document.body, 'removeChild').and.callThrough();
actions.downloadPatch({
state: {
modal: {
vulnerability: {
remediations: [
{
diff: 'abcdef',
},
],
},
},
},
});
expect(document.createElement).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledTimes(1);
expect(document.body.removeChild).toHaveBeenCalledTimes(1);
});
});
describe('issue creation', () => { describe('issue creation', () => {
describe('createIssue', () => { describe('createIssue', () => {
const vulnerability = mockDataVulnerabilities[0]; const vulnerability = mockDataVulnerabilities[0];
......
...@@ -43,6 +43,7 @@ import actions, { ...@@ -43,6 +43,7 @@ import actions, {
receiveCreateIssue, receiveCreateIssue,
receiveCreateIssueError, receiveCreateIssueError,
createNewIssue, createNewIssue,
downloadPatch,
requestCreateMergeRequest, requestCreateMergeRequest,
receiveCreateMergeRequestSuccess, receiveCreateMergeRequestSuccess,
receiveCreateMergeRequestError, receiveCreateMergeRequestError,
...@@ -1569,6 +1570,32 @@ describe('security reports actions', () => { ...@@ -1569,6 +1570,32 @@ describe('security reports actions', () => {
}); });
}); });
describe('downloadPatch', () => {
it('creates a download link and clicks on it to download the file', () => {
spyOn(document, 'createElement').and.callThrough();
spyOn(document.body, 'appendChild').and.callThrough();
spyOn(document.body, 'removeChild').and.callThrough();
downloadPatch({
state: {
modal: {
vulnerability: {
remediations: [
{
diff: 'abcdef',
},
],
},
},
},
});
expect(document.createElement).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledTimes(1);
expect(document.body.removeChild).toHaveBeenCalledTimes(1);
});
});
describe('requestCreateMergeRequest', () => { describe('requestCreateMergeRequest', () => {
it('commits request create merge request', done => { it('commits request create merge request', done => {
testAction( testAction(
......
import downloadPatchHelper from 'ee/vue_shared/security_reports/store/utils/download_patch_helper';
describe('downloadPatchHelper', () => {
beforeAll(() => {
spyOn(document, 'createElement').and.callThrough();
spyOn(document.body, 'appendChild').and.callThrough();
spyOn(document.body, 'removeChild').and.callThrough();
});
afterEach(() => {
document.createElement.calls.reset();
document.body.appendChild.calls.reset();
document.body.removeChild.calls.reset();
});
describe('with a base64 encoded string', () => {
it('creates a download link and clicks on it to download the file', done => {
const base64String = btoa('abcdef');
document.onclick = e => {
expect(e.target.download).toBe('remediation.patch');
expect(e.target.href).toBe('data:text/plain;base64,YWJjZGVm');
done();
};
downloadPatchHelper(base64String);
expect(document.createElement).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledTimes(1);
expect(document.body.removeChild).toHaveBeenCalledTimes(1);
});
});
describe('without a base64 encoded string', () => {
it('creates a download link and clicks on it to download the file', done => {
const unencodedString = 'abcdef';
document.onclick = e => {
expect(e.target.download).toBe('remediation.patch');
expect(e.target.href).toBe('data:text/plain;base64,YWJjZGVm');
done();
};
downloadPatchHelper(unencodedString, { isEncoded: false });
expect(document.createElement).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledTimes(1);
expect(document.body.removeChild).toHaveBeenCalledTimes(1);
});
});
});
...@@ -16239,6 +16239,9 @@ msgstr "" ...@@ -16239,6 +16239,9 @@ msgstr ""
msgid "ciReport|All severities" msgid "ciReport|All severities"
msgstr "" msgstr ""
msgid "ciReport|Automatically apply the patch in a new branch"
msgstr ""
msgid "ciReport|Class" msgid "ciReport|Class"
msgstr "" msgstr ""
...@@ -16257,10 +16260,10 @@ msgstr "" ...@@ -16257,10 +16260,10 @@ msgstr ""
msgid "ciReport|Container scanning detects known vulnerabilities in your docker images." msgid "ciReport|Container scanning detects known vulnerabilities in your docker images."
msgstr "" msgstr ""
msgid "ciReport|Create issue" msgid "ciReport|Create a merge request to implement this solution, or download and apply the patch manually."
msgstr "" msgstr ""
msgid "ciReport|Create merge request" msgid "ciReport|Create issue"
msgstr "" msgstr ""
msgid "ciReport|DAST" msgid "ciReport|DAST"
...@@ -16278,10 +16281,10 @@ msgstr "" ...@@ -16278,10 +16281,10 @@ msgstr ""
msgid "ciReport|Description" msgid "ciReport|Description"
msgstr "" msgstr ""
msgid "ciReport|Download and apply the patch to fix this vulnerability." msgid "ciReport|Download patch to resolve"
msgstr "" msgstr ""
msgid "ciReport|Download patch" msgid "ciReport|Download the patch to apply it manually"
msgstr "" msgstr ""
msgid "ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application." msgid "ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application."
...@@ -16302,16 +16305,13 @@ msgstr "" ...@@ -16302,16 +16305,13 @@ msgstr ""
msgid "ciReport|Image" msgid "ciReport|Image"
msgstr "" msgstr ""
msgid "ciReport|Implement this solution by creating a merge request"
msgstr ""
msgid "ciReport|Instances" msgid "ciReport|Instances"
msgstr "" msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue" msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr "" msgstr ""
msgid "ciReport|Learn more about interacting with security reports (Alpha)." msgid "ciReport|Learn more about interacting with security reports"
msgstr "" msgstr ""
msgid "ciReport|License management detected %d license for the source branch only" msgid "ciReport|License management detected %d license for the source branch only"
...@@ -16354,6 +16354,9 @@ msgstr "" ...@@ -16354,6 +16354,9 @@ msgstr ""
msgid "ciReport|Performance metrics" msgid "ciReport|Performance metrics"
msgstr "" msgstr ""
msgid "ciReport|Resolve with merge request"
msgstr ""
msgid "ciReport|SAST" msgid "ciReport|SAST"
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