Commit 21ce652e authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Andrew Fontaine

Add DAST URLS modal in MR Widget

This adds a new modal to show the scanned urls
for DAST in the MR Widget, and it also provides
a way to download them as CSV
parent 6c97d4ed
import Vue from 'vue';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import Translate from '../vue_shared/translate';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
if (gl.mrWidget) return;
......@@ -10,7 +17,7 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
const vm = new Vue(MrWidgetOptions);
const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
window.gl.mrWidget = {
checkStatus: vm.checkStatus,
......
......@@ -431,6 +431,8 @@ export default {
:create-vulnerability-feedback-dismissal-path="mr.createVulnerabilityFeedbackDismissalPath"
:pipeline-path="mr.pipeline.path"
:pipeline-id="mr.securityReportsPipelineId"
:pipeline-iid="mr.securityReportsPipelineIid"
:project-full-path="mr.sourceProjectFullPath"
:diverged-commits-count="mr.divergedCommitsCount"
:mr-state="mr.state"
:target-branch-tree-path="mr.targetBranchTreePath"
......
......@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.approvalsHelpPath = data.approvals_help_path;
this.codequalityHelpPath = data.codequality_help_path;
this.securityReportsPipelineId = data.pipeline_id;
this.securityReportsPipelineIid = data.pipeline_iid;
this.createVulnerabilityFeedbackIssuePath = data.create_vulnerability_feedback_issue_path;
this.createVulnerabilityFeedbackMergeRequestPath =
data.create_vulnerability_feedback_merge_request_path;
......
<script>
import { GlModal, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__, __ } from '~/locale';
export default {
components: { GlModal, GlIcon, GlSprintf },
props: {
scannedUrls: {
required: true,
type: Array,
},
scannedResourcesCount: {
required: true,
type: Number,
},
downloadLink: {
required: true,
type: String,
},
},
modal: {
modalId: 'dastUrl',
actionPrimary: {
text: __('Close'),
attributes: { variant: 'success' },
},
},
computed: {
title() {
return n__('%d Scanned URL', '%d Scanned URLs', this.scannedResourcesCount);
},
limitedScannedUrls() {
// show only 15 scanned urls
return this.scannedUrls.slice(0, 15);
},
downloadButton() {
const buttonAttrs = {
text: __('Download as CSV'),
attributes: {
variant: 'success',
class: 'btn-secondary gl-button',
href: this.downloadLink,
download: true,
'data-testid': 'download-button',
},
};
return this.downloadLink ? buttonAttrs : null;
},
},
};
</script>
<template>
<gl-modal
:title="title"
title-tag="h5"
v-bind="$options.modal"
:action-secondary="downloadButton"
>
<div class="gl-px-3">
<!-- heading -->
<div class="gl-display-flex gl-text-gray-600">
<div class="gl-w-11">{{ __('Method') }}</div>
<div class="gl-flex-fill-1">{{ __('URL') }}</div>
</div>
<hr class="gl-my-3" />
<!-- rows -->
<div v-for="(url, index) in limitedScannedUrls" :key="index" class="gl-display-flex gl-my-2">
<div class="gl-w-11">{{ url.requestMethod.toUpperCase() }}</div>
<div
class="gl-flex-fill-1 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis"
data-testid="dast-scanned-url"
>
{{ url.url }}
</div>
</div>
<!-- banner -->
<div
v-if="downloadLink"
class="gl-display-inline-block gl-bg-gray-50 gl-my-3 gl-pl-3 gl-pr-7 gl-py-5"
>
<gl-icon name="bulb" class="gl-vertical-align-middle gl-mr-5" />
<b class="gl-vertical-align-middle">
<gl-sprintf
:message="
__('To view all %{scannedResourcesCount} scanned URLs, please download the CSV file')
"
>
<template #scannedResourcesCount>
{{ scannedResourcesCount }}
</template>
</gl-sprintf>
</b>
</div>
</div>
</gl-modal>
</template>
query($fullPath: ID!, $pipelineIid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $pipelineIid) {
securityReportSummary {
dast {
scannedResourcesCsvPath
scannedResourcesCount
scannedResources {
nodes {
requestMethod
url
}
}
}
}
}
}
}
......@@ -9,11 +9,14 @@ import Tracking from '~/tracking';
import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import IssueModal from './components/modal.vue';
import DastModal from './components/dast_modal.vue';
import securityReportsMixin from './mixins/security_report_mixin';
import createStore from './store';
import { GlSprintf, GlLink } from '@gitlab/ui';
import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
import { mrStates } from '~/mr_popover/constants';
import { trackMrSecurityReportDetails } from 'ee/vue_shared/security_reports/store/constants';
import { fetchPolicies } from '~/lib/graphql';
import securityReportSummaryQuery from './graphql/mr_security_report_summary.graphql';
export default {
store: createStore(),
......@@ -25,8 +28,28 @@ export default {
Icon,
GlSprintf,
GlLink,
DastModal,
},
directives: {
'gl-modal': GlModalDirective,
},
mixins: [securityReportsMixin, glFeatureFlagsMixin()],
apollo: {
dastSummary: {
query: securityReportSummaryQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
fullPath: this.projectFullPath,
pipelineIid: this.pipelineIid,
};
},
update(data) {
const dast = data?.project?.pipeline?.securityReportSummary?.dast;
return dast && Object.keys(dast).length ? dast : null;
},
},
},
props: {
enabledReports: {
type: Object,
......@@ -112,6 +135,11 @@ export default {
required: false,
default: null,
},
pipelineIid: {
type: Number,
required: false,
default: null,
},
pipelinePath: {
type: String,
required: false,
......@@ -137,6 +165,10 @@ export default {
required: false,
default: '',
},
projectFullPath: {
type: String,
required: true,
},
},
componentNames,
computed: {
......@@ -202,6 +234,9 @@ export default {
Tracking.event(category, action);
});
},
dastDownloadLink() {
return this.dastSummary?.scannedResourcesCsvPath || '';
},
},
created() {
......@@ -420,16 +455,17 @@ export default {
<div class="text-nowrap">
{{ n__('%d URL scanned', '%d URLs scanned', dastScans[0].scanned_resources_count) }}
</div>
<gl-link
class="ml-2"
data-qa-selector="dast-ci-job-link"
:href="dastScans[0].job_path"
>
<gl-link v-gl-modal.dastUrl class="ml-2" data-qa-selector="dast-ci-job-link">
{{ __('View details') }}
</gl-link>
<dast-modal
v-if="dastSummary"
:scanned-urls="dastSummary.scannedResources.nodes"
:scanned-resources-count="dastSummary.scannedResourcesCount"
:download-link="dastDownloadLink"
/>
</template>
</summary-row>
<grouped-issues-list
v-if="dast.newIssues.length || dast.resolvedIssues.length"
:unresolved-issues="dast.newIssues"
......
......@@ -81,6 +81,10 @@ module EE
merge_request.head_pipeline.id
end
expose :pipeline_iid, if: -> (mr, _) { mr.head_pipeline } do |merge_request|
merge_request.head_pipeline.iid
end
expose :can_read_vulnerability_feedback do |merge_request|
can?(current_user, :read_vulnerability_feedback, merge_request.project)
end
......
---
title: Add DAST URL MR Widget modal
merge_request: 35945
author:
type: added
import { shallowMount } from '@vue/test-utils';
import Component from 'ee/vue_shared/security_reports/components/dast_modal.vue';
import { GlModal } from '@gitlab/ui';
describe('DAST Modal', () => {
let wrapper;
const defaultProps = {
scannedUrls: [{ requestMethod: 'POST', url: 'https://gitlab.com' }],
scannedResourcesCount: 1,
downloadLink: 'https://gitlab.com',
};
const findDownloadButton = () => wrapper.find('[data-testid="download-button"]');
const createWrapper = propsData => {
wrapper = shallowMount(Component, {
propsData: {
...defaultProps,
...propsData,
},
stubs: {
GlModal,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has the download button with required attrs', () => {
expect(findDownloadButton().exists()).toBe(true);
expect(findDownloadButton().attributes('href')).toBe(defaultProps.downloadLink);
expect(findDownloadButton().attributes('download')).toBeDefined();
});
it('should contain the dynamic title', () => {
createWrapper({ scannedResourcesCount: 20 });
expect(wrapper.attributes('title')).toBe('20 Scanned URLs');
});
it('should not show download button when link is not present', () => {
createWrapper({ downloadLink: '' });
expect(findDownloadButton().exists()).toBe(false);
});
it('scanned urls should be limited to 15', () => {
createWrapper({
scannedUrls: Array(20).fill(defaultProps.scannedUrls[0]),
});
expect(wrapper.findAll('[data-testid="dast-scanned-url"]')).toHaveLength(15);
});
});
......@@ -49,12 +49,27 @@ describe('Grouped security reports app', () => {
vulnerabilityFeedbackPath: 'vulnerability_feedback_path.json',
vulnerabilityFeedbackHelpPath: 'path',
pipelineId: 123,
projectFullPath: 'path',
};
const glModalDirective = jest.fn();
const createWrapper = (propsData, provide = {}) => {
wrapper = mount(GroupedSecurityReportsApp, {
propsData,
data() {
return {
dastSummary: null,
};
},
provide,
directives: {
glModal: {
bind(el, { value }) {
glModalDirective(value);
},
},
},
});
};
......@@ -263,6 +278,7 @@ describe('Grouped security reports app', () => {
createWrapper({
headBlobPath: 'path',
pipelinePath,
projectFullPath: 'path',
});
});
......@@ -365,13 +381,17 @@ describe('Grouped security reports app', () => {
expect(wrapper.vm.$el.textContent).toContain('DAST detected 1 vulnerability');
});
it('shows the scanned URLs count and a link to the CI job if available', () => {
it('shows the scanned URLs count and opens a modal', async () => {
const jobLink = wrapper.find('[data-qa-selector="dast-ci-job-link"]');
expect(wrapper.text()).toContain('211 URLs scanned');
expect(jobLink.exists()).toBe(true);
expect(jobLink.text()).toBe('View details');
expect(jobLink.attributes('href')).toBe(scanUrl);
jobLink.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(glModalDirective).toHaveBeenCalled();
});
it('does not show scanned resources info if there is 0 scanned URL', () => {
......
......@@ -71,6 +71,11 @@ msgstr ""
msgid "\"%{path}\" did not exist on \"%{ref}\""
msgstr ""
msgid "%d Scanned URL"
msgid_plural "%d Scanned URLs"
msgstr[0] ""
msgstr[1] ""
msgid "%d URL scanned"
msgid_plural "%d URLs scanned"
msgstr[0] ""
......@@ -8305,6 +8310,9 @@ msgstr ""
msgid "Download as"
msgstr ""
msgid "Download as CSV"
msgstr ""
msgid "Download asset"
msgstr ""
......@@ -24518,6 +24526,9 @@ msgstr ""
msgid "To this GitLab instance"
msgstr ""
msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
msgstr ""
msgid "To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown."
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