Commit c79ce32b authored by Fernando's avatar Fernando

Move download button into the split button

First pass at auto remediation changes

Additional progress

Working download patch split button

Working download patch in project security dashboard

Add icon to more info

Run pretteir and linter

refactor template

Remove old comment

Update vuln modal messages

* Update state logic based on updated edge cases

Run prettier and linter

Adjust dropdown and solutions card display logic

Run prettier, linter, and add changelog

Update POT file

Reafactor security report modal spec to use vue utils

Refactor required props and spec file for solution card

* Tweak required props and set default prop values
* Refactor solution card specs to use vue utils

Add docs anchor for solutions for vulnerabilties

Run prettier and linter

Update spec

* Update feedback path to account for url anchor

Update solution card specs

Remove no longer needed code path

* As per UX revisions remove no longer needed message
* Update solution card specs
* Update pot files

Tweak specs

Add specs for merge request button

Add unit test for download patch button

Run Prettier

Run linter and prettier

Fix issues after rebase

resolve spec file issues from rebase

Tweak modal footer specs

Run prettier and linter

Fix broken spec

Fix broken merge requests from vuln modal

Refactor download patch action into util function

* Use util function in both actions

Make code review changes for solution card

* Move string to computed property
* Remove unused prop

Run linter and prettier

Run prettier

First batch of code review changes

Make downloadPatch unit tests more specific

* Verify string properly base64 encoded
* Verify click event
parent 857eb8dd
......@@ -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);
});
});
});
......@@ -15962,6 +15962,9 @@ msgstr ""
msgid "ciReport|All severities"
msgstr ""
msgid "ciReport|Automatically apply the patch in a new branch"
msgstr ""
msgid "ciReport|Class"
msgstr ""
......@@ -15980,10 +15983,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"
......@@ -16001,10 +16004,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."
......@@ -16025,16 +16028,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"
......@@ -16077,6 +16077,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