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 {
'setVulnerabilitiesEndpoint',
'setVulnerabilitiesHistoryEndpoint',
'undoDismiss',
'downloadPatch',
]),
...mapActions('projects', ['setProjectsEndpoint', 'fetchProjects']),
...mapActions('filters', ['lockFilter']),
......@@ -141,6 +142,7 @@ export default {
@dismissVulnerability="dismissVulnerability({ vulnerability, comment: $event })"
@openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="undoDismiss({ vulnerability })"
@downloadPatch="downloadPatch({ vulnerability })"
/>
</div>
</template>
import $ from 'jquery';
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 { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
......@@ -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 }) => {
const {
report_type,
......
......@@ -58,6 +58,17 @@ export default {
dismissalCommentErrorMessage: '',
}),
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() {
return (
this.vulnerability &&
......@@ -77,9 +88,6 @@ export default {
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.
*/
......@@ -170,8 +178,14 @@ export default {
<slot>
<vulnerability-details :details="valuedFields" class="js-vulnerability-details" />
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" />
<hr v-else />
<solution-card
: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">
<li v-if="issueFeedback" class="note">
......@@ -199,18 +213,6 @@ export default {
</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>
</slot>
<div slot="footer">
......@@ -225,6 +227,7 @@ export default {
:vulnerability="vulnerability"
:can-create-issue="Boolean(!vulnerability.hasIssue && canCreateIssue)"
:can-create-merge-request="Boolean(!vulnerability.hasMergeRequest && remediation)"
:can-download-patch="canDownloadPatch"
:can-dismiss-vulnerability="canDismissVulnerability"
:is-dismissed="vulnerability.isDismissed"
@createMergeRequest="$emit('createMergeRequest')"
......@@ -232,6 +235,7 @@ export default {
@dismissVulnerability="$emit('dismissVulnerability')"
@openDismissalCommentBox="$emit('openDismissalCommentBox')"
@revertDismissVulnerability="$emit('revertDismissVulnerability')"
@downloadPatch="$emit('downloadPatch')"
/>
</div>
</modal>
......
......@@ -33,6 +33,11 @@ export default {
required: false,
default: false,
},
canDownloadPatch: {
type: Boolean,
required: false,
default: false,
},
canDismissVulnerability: {
type: Boolean,
required: false,
......@@ -49,16 +54,25 @@ export default {
action: 'createNewIssue',
};
const MRButton = {
name: s__('ciReport|Create merge request'),
tagline: s__('ciReport|Implement this solution by creating a merge request'),
name: s__('ciReport|Resolve with merge request'),
tagline: s__('ciReport|Automatically apply the patch in a new branch'),
isLoading: this.modal.isCreatingMergeRequest,
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) {
buttons.push(MRButton);
}
if (this.canDownloadPatch) {
buttons.push(DownloadButton);
}
if (this.canCreateIssue) {
buttons.push(issueButton);
}
......@@ -87,8 +101,10 @@ export default {
<split-button
v-if="actionButtons.length > 1"
:buttons="actionButtons"
class="js-split-button"
@createMergeRequest="$emit('createMergeRequest')"
@createNewIssue="$emit('createNewIssue')"
@downloadPatch="$emit('downloadPatch')"
/>
<loading-button
......@@ -97,6 +113,7 @@ export default {
:disabled="actionButtons[0].isLoading"
:label="actionButtons[0].name"
container-class="btn btn-success btn-inverted"
class="js-action-button"
@click="$emit(actionButtons[0].action)"
/>
</div>
......
......@@ -9,44 +9,89 @@ export default {
solution: {
type: String,
default: '',
required: false,
},
remediation: {
type: Object,
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: {
solutionText() {
return (this.remediation && this.remediation.summary) || this.solution;
},
remediationDiff() {
return this.remediation && this.remediation.diff;
helpPath() {
return `${this.vulnerabilityFeedbackHelpPath}#solutions-for-vulnerabilities`;
},
showCreateMergeRequestMsg() {
return !this.hasMr && this.hasRemediation && this.hasDownload;
},
downloadUrl() {
return `data:text/plain;base64,${this.remediationDiff}`;
showLearnAboutRemedationMsg() {
if (this.hasMr) {
return false;
}
return true;
},
hasDiff() {
return (this.remediationDiff && this.remediationDiff.length > 0) || false;
showMsg() {
return (
this.vulnerabilityFeedbackHelpPath &&
(this.showCreateMergeRequestMsg || this.showLearnAboutRemedationMsg)
);
},
},
};
</script>
<template>
<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="circle-icon-container" aria-hidden="true"><icon name="bulb" /></div>
<strong class="text-right flex-grow-1">{{ s__('ciReport|Solution') }}:</strong>
</div>
<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 v-if="hasDiff" class="card-footer">
<em class="text-secondary">
{{ s__('ciReport|Download and apply the patch to fix this vulnerability.') }}
</em>
</div>
<template v-if="showMsg">
<div class="card-footer" :class="{ 'border-0': !solutionText }">
<em class="text-secondary">
<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>
</div>
</template>
</div>
</template>
......@@ -256,6 +256,7 @@ export default {
'createMergeRequest',
'openDismissalCommentBox',
'closeDismissalCommentBox',
'downloadPatch',
]),
},
};
......@@ -361,6 +362,7 @@ export default {
@dismissVulnerability="dismissVulnerability"
@openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="revertDismissVulnerability"
@downloadPatch="downloadPatch"
/>
</div>
</report-section>
......
......@@ -236,6 +236,7 @@ export default {
'createMergeRequest',
'openDismissalCommentBox',
'closeDismissalCommentBox',
'downloadPatch',
]),
summaryTextBuilder(reportType, issuesCount = 0) {
if (issuesCount === 0) {
......@@ -325,6 +326,7 @@ export default {
@dismissVulnerability="dismissVulnerability"
@openDismissalCommentBox="openDismissalCommentBox()"
@revertDismissVulnerability="revertDismissVulnerability"
@downloadPatch="downloadPatch"
/>
</div>
</template>
......@@ -3,6 +3,7 @@ import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
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);
......@@ -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 }) => {
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', () => {
});
it('only renders the create merge request button', () => {
expect(wrapper.vm.actionButtons[0].name).toBe('Create merge request');
expect(wrapper.find(LoadingButton).props('label')).toBe('Create merge request');
expect(wrapper.vm.actionButtons[0].name).toBe('Resolve with merge request');
expect(wrapper.find(LoadingButton).props('label')).toBe('Resolve with merge request');
});
it('emits createMergeRequest when create merge request button is clicked', () => {
......@@ -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', () => {
beforeEach(() => {
const propsData = {
......@@ -62,9 +82,32 @@ describe('Security Reports modal footer', () => {
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.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 component from 'ee/vue_shared/security_reports/components/modal.vue';
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', () => {
const Component = Vue.extend(component);
let vm;
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('with permissions', () => {
describe('with dismissed issue', () => {
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
canDismissVulnerability: true,
};
props.modal.vulnerability.isDismissed = true;
props.modal.vulnerability.dismissalFeedback = {
propsData.modal.vulnerability.isDismissed = true;
propsData.modal.vulnerability.dismissalFeedback = {
author: { username: 'jsmith', name: 'John Smith' },
pipeline: { id: '123', path: '#' },
};
vm = mountComponent(Component, props);
wrapper = mount(Component, { propsData });
});
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');
expect(wrapper.text().trim()).toContain('John Smith');
expect(wrapper.text().trim()).toContain('@jsmith');
expect(wrapper.text().trim()).toContain('#123');
});
});
describe('with not dismissed issue', () => {
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
canDismissVulnerability: true,
};
vm = mountComponent(Component, props);
wrapper = mount(Component, { propsData });
});
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', () => {
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
vulnerabilityFeedbackHelpPath: 'feedbacksHelpPath',
};
props.modal.title = 'Arbitrary file existence disclosure in Action Pack';
vm = mountComponent(Component, props);
propsData.modal.title = 'Arbitrary file existence disclosure in Action Pack';
wrapper = mount(Component, { propsData });
});
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', () => {
expect(
vm.$el.querySelector('.js-link-vulnerabilityFeedbackHelpPath').getAttribute('href'),
).toEqual('feedbacksHelpPath');
expect(wrapper.find('.js-link-vulnerabilityFeedbackHelpPath').attributes('href')).toBe(
'feedbacksHelpPath#solutions-for-vulnerabilities',
);
});
});
});
describe('without permissions', () => {
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
};
vm = mountComponent(Component, props);
wrapper = shallowMount(Component, { propsData });
});
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', () => {
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
};
props.modal.isResolved = true;
vm = mountComponent(Component, props);
propsData.modal.isResolved = true;
wrapper = shallowMount(Component, { propsData });
});
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', () => {
const fileValue = '/some/file.path';
beforeEach(() => {
const props = {
const propsData = {
modal: createState().modal,
};
props.modal.vulnerability.blob_path = blobPath;
props.modal.data.namespace.value = namespaceValue;
props.modal.data.file.value = fileValue;
vm = mountComponent(Component, props);
propsData.modal.vulnerability.blob_path = blobPath;
propsData.modal.data.namespace.value = namespaceValue;
propsData.modal.data.file.value = fileValue;
wrapper = mount(Component, { propsData });
});
it('is rendered', () => {
const vulnerabilityDetails = vm.$el.querySelector('.js-vulnerability-details');
const vulnerabilityDetails = wrapper.find('.js-vulnerability-details');
expect(vulnerabilityDetails).not.toBeNull();
expect(vulnerabilityDetails.textContent).toContain('foobar');
expect(vulnerabilityDetails.exists()).toBe(true);
expect(vulnerabilityDetails.text()).toContain('foobar');
});
it('computes valued fields properly', () => {
expect(vm.valuedFields).toMatchObject({
expect(wrapper.vm.valuedFields).toMatchObject({
file: {
value: fileValue,
url: blobPath,
......@@ -137,46 +183,46 @@ describe('Security Reports modal', () => {
describe('Solution Card', () => {
it('is rendered if the vulnerability has a solution', () => {
const props = {
const propsData = {
modal: createState().modal,
};
const solution = 'Upgrade to XYZ';
props.modal.vulnerability.solution = solution;
vm = mountComponent(Component, props);
propsData.modal.vulnerability.solution = solution;
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.textContent).toContain(solution);
expect(vm.$el.querySelector('hr')).toBeNull();
expect(solutionCard.exists()).toBe(true);
expect(solutionCard.text()).toContain(solution);
expect(wrapper.contains('hr')).toBe(false);
});
it('is rendered if the vulnerability has a remediation', () => {
const props = {
const propsData = {
modal: createState().modal,
};
const summary = 'Upgrade to 123';
props.modal.vulnerability.remediations = [{ summary }];
vm = mountComponent(Component, props);
propsData.modal.vulnerability.remediations = [{ summary }];
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.textContent).toContain(summary);
expect(vm.$el.querySelector('hr')).toBeNull();
expect(solutionCard.exists()).toBe(true);
expect(solutionCard.text()).toContain(summary);
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.', () => {
const props = {
it('is rendered if the vulnerability has neither a remediation nor a solution', () => {
const propsData = {
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(vm.$el.querySelector('hr')).not.toBeNull();
expect(solutionCard.exists()).toBe(true);
expect(wrapper.contains('hr')).toBe(false);
});
});
});
import Vue from '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 { shallowMount } from '@vue/test-utils';
import { s__ } from '~/locale';
describe('Solution Card', () => {
const Component = Vue.extend(component);
const solution = 'Upgrade to XYZ';
const remediation = { summary: 'Update to 123', fixes: [], diff: 'SGVsbG8gR2l0TGFi' };
let vm;
const vulnerabilityFeedbackHelpPath = '/foo';
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('computed properties', () => {
describe('solutionText', () => {
it('takes the value of solution', () => {
const props = { solution };
vm = mountComponent(Component, props);
const propsData = { solution };
wrapper = shallowMount(Component, { propsData });
expect(vm.solutionText).toEqual(solution);
expect(wrapper.vm.solutionText).toEqual(solution);
});
it('takes the summary from a remediation', () => {
const props = { remediation };
vm = mountComponent(Component, props);
const propsData = { remediation };
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', () => {
const props = { remediation, solution };
vm = mountComponent(Component, props);
expect(vm.solutionText).toEqual(remediation.summary);
});
});
describe('remediationDiff', () => {
it('returns the base64 diff from a remediation', () => {
const props = { remediation };
vm = mountComponent(Component, props);
const propsData = { remediation, solution };
wrapper = shallowMount(Component, { propsData });
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);
});
it('is false if remediation misses a diff', () => {
const props = { remediation: { summary: 'XYZ' } };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(false);
});
it('is true if remediation has a diff', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.hasDiff).toBe(true);
});
});
describe('downloadUrl', () => {
it('returns dataUrl for a remediation diff ', () => {
const props = { remediation };
vm = mountComponent(Component, props);
expect(vm.downloadUrl).toBe('data:text/plain;base64,SGVsbG8gR2l0TGFi');
expect(wrapper.vm.solutionText).toEqual(remediation.summary);
});
});
});
......@@ -82,47 +44,84 @@ describe('Solution Card', () => {
describe('rendering', () => {
describe('with solution', () => {
beforeEach(() => {
const props = { solution };
vm = mountComponent(Component, props);
const propsData = { solution };
wrapper = shallowMount(Component, { propsData });
});
it('renders the solution text and label', () => {
expect(trimText(vm.$el.querySelector('.card-body').textContent)).toContain(
`Solution: ${solution}`,
);
expect(trimText(wrapper.find('.card-body').text())).toContain(`Solution: ${solution}`);
});
it('does not render the card footer', () => {
expect(vm.$el.querySelector('.card-footer')).toBeNull();
expect(wrapper.contains('.card-footer')).toBe(false);
});
it('does not render the download link', () => {
expect(vm.$el.querySelector('a')).toBeNull();
expect(wrapper.contains('a')).toBe(false);
});
});
describe('with remediation', () => {
beforeEach(() => {
const props = { remediation };
vm = mountComponent(Component, props);
const propsData = { remediation, vulnerabilityFeedbackHelpPath, hasRemediation: true };
wrapper = shallowMount(Component, { propsData });
});
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: ${remediation.summary}`,
);
});
it('renders the card footer', () => {
expect(vm.$el.querySelector('.card-footer')).not.toBeNull();
});
it('renders the download link', () => {
const linkEl = vm.$el.querySelector('a');
expect(linkEl).not.toBeNull();
expect(linkEl.getAttribute('href')).toEqual(vm.downloadUrl);
expect(linkEl.getAttribute('download')).toEqual('remediation.patch');
expect(wrapper.contains('.card-footer')).toBe(true);
});
describe('with download patch', () => {
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'),
);
});
it('does not render the download and apply solution message when there is a file download and a merge request already exists', () => {
wrapper.setProps({ hasMr: true });
expect(wrapper.contains('.card-footer')).toBe(false);
});
it('renders the create a merge request to implement this solution message', () => {
expect(wrapper.find('.card-footer').text()).toContain(
s__(
'ciReport|Create a merge request to implement this solution, or download and apply the patch manually.',
),
);
});
});
describe('without download patch', () => {
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('does not render the download and apply solution message', () => {
expect(wrapper.find('.card-footer').text()).not.toContain(
s__('ciReport|Download and apply the patch manually to resolve.'),
);
});
it('does not render the create a merge request to implement this solution message', () => {
expect(wrapper.find('.card-footer').text()).not.toContain(
s__(
'ciReport|Create a merge request to implement this solution, or download and apply the patch manually.',
),
);
});
});
});
});
......
......@@ -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('createIssue', () => {
const vulnerability = mockDataVulnerabilities[0];
......
......@@ -43,6 +43,7 @@ import actions, {
receiveCreateIssue,
receiveCreateIssueError,
createNewIssue,
downloadPatch,
requestCreateMergeRequest,
receiveCreateMergeRequestSuccess,
receiveCreateMergeRequestError,
......@@ -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', () => {
it('commits request create merge request', done => {
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 ""
msgid "ciReport|All severities"
msgstr ""
msgid "ciReport|Automatically apply the patch in a new branch"
msgstr ""
msgid "ciReport|Class"
msgstr ""
......@@ -16257,10 +16260,10 @@ msgstr ""
msgid "ciReport|Container scanning detects known vulnerabilities in your docker images."
msgstr ""
msgid "ciReport|Create issue"
msgid "ciReport|Create a merge request to implement this solution, or download and apply the patch manually."
msgstr ""
msgid "ciReport|Create merge request"
msgid "ciReport|Create issue"
msgstr ""
msgid "ciReport|DAST"
......@@ -16278,10 +16281,10 @@ msgstr ""
msgid "ciReport|Description"
msgstr ""
msgid "ciReport|Download and apply the patch to fix this vulnerability."
msgid "ciReport|Download patch to resolve"
msgstr ""
msgid "ciReport|Download patch"
msgid "ciReport|Download the patch to apply it manually"
msgstr ""
msgid "ciReport|Dynamic Application Security Testing (DAST) detects known vulnerabilities in your web application."
......@@ -16302,16 +16305,13 @@ msgstr ""
msgid "ciReport|Image"
msgstr ""
msgid "ciReport|Implement this solution by creating a merge request"
msgstr ""
msgid "ciReport|Instances"
msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr ""
msgid "ciReport|Learn more about interacting with security reports (Alpha)."
msgid "ciReport|Learn more about interacting with security reports"
msgstr ""
msgid "ciReport|License management detected %d license for the source branch only"
......@@ -16354,6 +16354,9 @@ msgstr ""
msgid "ciReport|Performance metrics"
msgstr ""
msgid "ciReport|Resolve with merge request"
msgstr ""
msgid "ciReport|SAST"
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