Commit 1bb4b4af authored by David O'Regan's avatar David O'Regan

Merge branch '273425-security-mr-widget-upgrade-popover' into 'master'

Implement security reports upgrade popover

See merge request gitlab-org/gitlab!49613
parents fc75b95b b2bb5691
<script>
import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlButton,
GlIcon,
GlLink,
GlPopover,
},
props: {
helpPath: {
type: String,
required: true,
},
discoverProjectSecurityPath: {
type: String,
required: false,
default: '',
},
},
i18n: {
securityReportsHelp: s__('SecurityReports|Security reports help page link'),
upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'),
upgradeToInteract: s__(
'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.',
),
},
};
</script>
<template>
<span v-if="discoverProjectSecurityPath">
<gl-button
ref="discoverProjectSecurity"
icon="information-o"
category="tertiary"
:aria-label="$options.i18n.upgradeToManageVulnerabilities"
/>
<gl-popover
:target="() => $refs.discoverProjectSecurity.$el"
:title="$options.i18n.upgradeToManageVulnerabilities"
placement="top"
triggers="click blur"
>
{{ $options.i18n.upgradeToInteract }}
<gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{
__('Learn more')
}}</gl-link>
</gl-popover>
</span>
<gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp">
<gl-icon name="question" />
</gl-link>
</template>
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { GlLink, GlSprintf } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReportSection from '~/reports/components/report_section.vue'; import ReportSection from '~/reports/components/report_section.vue';
import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants';
...@@ -8,6 +8,7 @@ import { s__ } from '~/locale'; ...@@ -8,6 +8,7 @@ import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import Api from '~/api'; import Api from '~/api';
import HelpIcon from './components/help_icon.vue';
import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue';
import SecuritySummary from './components/security_summary.vue'; import SecuritySummary from './components/security_summary.vue';
import store from './store'; import store from './store';
...@@ -23,10 +24,10 @@ import { extractSecurityReportArtifacts } from './utils'; ...@@ -23,10 +24,10 @@ import { extractSecurityReportArtifacts } from './utils';
export default { export default {
store, store,
components: { components: {
GlIcon,
GlLink, GlLink,
GlSprintf, GlSprintf,
ReportSection, ReportSection,
HelpIcon,
SecurityReportDownloadDropdown, SecurityReportDownloadDropdown,
SecuritySummary, SecuritySummary,
}, },
...@@ -44,6 +45,11 @@ export default { ...@@ -44,6 +45,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
discoverProjectSecurityPath: {
type: String,
required: false,
default: '',
},
sastComparisonPath: { sastComparisonPath: {
type: String, type: String,
required: false, required: false,
...@@ -64,6 +70,11 @@ export default { ...@@ -64,6 +70,11 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
canDiscoverProjectSecurity: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -231,7 +242,6 @@ export default { ...@@ -231,7 +242,6 @@ export default {
downloadFromPipelineTab: s__( downloadFromPipelineTab: s__(
'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
), ),
securityReportsHelp: s__('SecurityReports|Security reports help page link'),
}, },
summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR],
}; };
...@@ -248,14 +258,10 @@ export default { ...@@ -248,14 +258,10 @@ export default {
<span :key="slot"> <span :key="slot">
<security-summary :message="groupedSummaryText" /> <security-summary :message="groupedSummaryText" />
<gl-link <help-icon
target="_blank" :help-path="securityReportsDocsPath"
data-testid="help" :discover-project-security-path="discoverProjectSecurityPath"
:href="securityReportsDocsPath" />
:aria-label="$options.i18n.securityReportsHelp"
>
<gl-icon name="question" />
</gl-link>
</span> </span>
</template> </template>
...@@ -300,14 +306,10 @@ export default { ...@@ -300,14 +306,10 @@ export default {
</template> </template>
</gl-sprintf> </gl-sprintf>
<gl-link <help-icon
target="_blank" :help-path="securityReportsDocsPath"
data-testid="help" :discover-project-security-path="discoverProjectSecurityPath"
:href="securityReportsDocsPath" />
:aria-label="$options.i18n.securityReportsHelp"
>
<gl-icon name="question" />
</gl-link>
</template> </template>
<template v-if="canShowDownloads" #action-buttons> <template v-if="canShowDownloads" #action-buttons>
......
---
title: Show upgrade popover in security widget in merge requests when the user is able to upgrade
merge_request: 49613
author:
type: added
...@@ -318,6 +318,7 @@ export default { ...@@ -318,6 +318,7 @@ export default {
:secret-scanning-comparison-path="mr.secretScanningComparisonPath" :secret-scanning-comparison-path="mr.secretScanningComparisonPath"
:target-project-full-path="mr.targetProjectFullPath" :target-project-full-path="mr.targetProjectFullPath"
:mr-iid="mr.iid" :mr-iid="mr.iid"
:discover-project-security-path="mr.discoverProjectSecurityPath"
/> />
<grouped-security-reports-app <grouped-security-reports-app
v-else-if="shouldRenderExtendedSecurityReport" v-else-if="shouldRenderExtendedSecurityReport"
......
...@@ -54,6 +54,8 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -54,6 +54,8 @@ export default class MergeRequestStore extends CEMergeRequestStore {
// Paths are set on the first load of the page and not auto-refreshed // Paths are set on the first load of the page and not auto-refreshed
super.setPaths(data); super.setPaths(data);
this.discoverProjectSecurityPath = data.discover_project_security_path;
// Security scan diff paths // Security scan diff paths
this.containerScanningComparisonPath = data.container_scanning_comparison_path; this.containerScanningComparisonPath = data.container_scanning_comparison_path;
this.coverageFuzzingComparisonPath = data.coverage_fuzzing_comparison_path; this.coverageFuzzingComparisonPath = data.coverage_fuzzing_comparison_path;
......
...@@ -52,6 +52,10 @@ module EE ...@@ -52,6 +52,10 @@ module EE
merge_request.missing_security_scan_types if expose_missing_security_scan_types? merge_request.missing_security_scan_types if expose_missing_security_scan_types?
end end
def discover_project_security_path
project_security_discover_path(project) if show_discover_project_security?(project)
end
private private
def expose_mr_approval_path? def expose_mr_approval_path?
......
...@@ -89,6 +89,10 @@ module EE ...@@ -89,6 +89,10 @@ module EE
presenter(merge_request).create_vulnerability_feedback_dismissal_path(merge_request.project) presenter(merge_request).create_vulnerability_feedback_dismissal_path(merge_request.project)
end end
expose :discover_project_security_path do |merge_request|
presenter(merge_request).discover_project_security_path
end
expose :has_approvals_available do |merge_request| expose :has_approvals_available do |merge_request|
merge_request.approval_feature_available? merge_request.approval_feature_available?
end end
......
...@@ -11,6 +11,7 @@ export default { ...@@ -11,6 +11,7 @@ export default {
license_management: false, license_management: false,
secret_detection: false, secret_detection: false,
}, },
discover_project_security_path: '/discover_project_security',
container_scanning_comparison_path: '/container_scanning_comparison_path', container_scanning_comparison_path: '/container_scanning_comparison_path',
dependency_scanning_comparison_path: '/dependency_scanning_comparison_path', dependency_scanning_comparison_path: '/dependency_scanning_comparison_path',
dast_comparison_path: '/dast_comparison_path', dast_comparison_path: '/dast_comparison_path',
......
...@@ -70,6 +70,7 @@ describe('MergeRequestStore', () => { ...@@ -70,6 +70,7 @@ describe('MergeRequestStore', () => {
describe('setPaths', () => { describe('setPaths', () => {
it.each([ it.each([
'discover_project_security_path',
'container_scanning_comparison_path', 'container_scanning_comparison_path',
'dependency_scanning_comparison_path', 'dependency_scanning_comparison_path',
'sast_comparison_path', 'sast_comparison_path',
......
...@@ -131,4 +131,29 @@ RSpec.describe MergeRequestPresenter do ...@@ -131,4 +131,29 @@ RSpec.describe MergeRequestPresenter do
it { is_expected.to eq(attribute_value) } it { is_expected.to eq(attribute_value) }
end end
end end
describe '#discover_project_security_path' do
let(:presenter) { described_class.new(merge_request, current_user: user) }
let(:can_discover_project_security) { true }
subject { presenter.discover_project_security_path }
before do
allow(presenter).to receive(:show_discover_project_security?) { can_discover_project_security }
end
context 'when project security is discoverable' do
it 'returns path' do
is_expected.to eq(presenter.project_security_discover_path(project))
end
end
context 'when project security is not discoverable' do
let(:can_discover_project_security) { false }
it 'returns nil' do
is_expected.to be_nil
end
end
end
end end
...@@ -294,6 +294,10 @@ RSpec.describe MergeRequestWidgetEntity do ...@@ -294,6 +294,10 @@ RSpec.describe MergeRequestWidgetEntity do
expect(subject.as_json).to include(:can_read_vulnerability_feedback) expect(subject.as_json).to include(:can_read_vulnerability_feedback)
end end
it 'has discover project security path' do
expect(subject.as_json).to include(:discover_project_security_path)
end
it 'has pipeline id' do it 'has pipeline id' do
allow(merge_request).to receive(:head_pipeline).and_return(pipeline) allow(merge_request).to receive(:head_pipeline).and_return(pipeline)
......
...@@ -24634,6 +24634,12 @@ msgstr "" ...@@ -24634,6 +24634,12 @@ msgstr ""
msgid "SecurityReports|Undo dismiss" msgid "SecurityReports|Undo dismiss"
msgstr "" msgstr ""
msgid "SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI."
msgstr ""
msgid "SecurityReports|Upgrade to manage vulnerabilities"
msgstr ""
msgid "SecurityReports|Vulnerability Report" msgid "SecurityReports|Vulnerability Report"
msgstr "" msgstr ""
......
import { GlLink, GlPopover } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue';
const helpPath = '/docs';
const discoverProjectSecurityPath = '/discoverProjectSecurityPath';
describe('HelpIcon component', () => {
let wrapper;
const createWrapper = props => {
wrapper = shallowMount(HelpIcon, {
propsData: {
helpPath,
...props,
},
});
};
const findLink = () => wrapper.find(GlLink);
const findPopover = () => wrapper.find(GlPopover);
const findPopoverTarget = () => wrapper.find({ ref: 'discoverProjectSecurity' });
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('given a help path only', () => {
beforeEach(() => {
createWrapper();
});
it('does not render a popover', () => {
expect(findPopover().exists()).toBe(false);
});
it('renders a help link', () => {
expect(findLink().attributes()).toMatchObject({
href: helpPath,
target: '_blank',
});
});
});
describe('given a help path and discover project security path', () => {
beforeEach(() => {
createWrapper({ discoverProjectSecurityPath });
});
it('renders a popover', () => {
const popover = findPopover();
expect(popover.props('target')()).toBe(findPopoverTarget().element);
expect(popover.attributes()).toMatchObject({
title: HelpIcon.i18n.upgradeToManageVulnerabilities,
triggers: 'click blur',
});
expect(popover.text()).toContain(HelpIcon.i18n.upgradeToInteract);
});
it('renders a link to the discover path', () => {
expect(findLink().attributes()).toMatchObject({
href: discoverProjectSecurityPath,
target: '_blank',
});
});
});
});
...@@ -19,6 +19,7 @@ import { ...@@ -19,6 +19,7 @@ import {
REPORT_TYPE_SAST, REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants'; } from '~/vue_shared/security_reports/constants';
import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql'; import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
...@@ -38,6 +39,7 @@ describe('Security reports app', () => { ...@@ -38,6 +39,7 @@ describe('Security reports app', () => {
pipelineId: 123, pipelineId: 123,
projectId: 456, projectId: 456,
securityReportsDocsPath: '/docs', securityReportsDocsPath: '/docs',
discoverProjectSecurityPath: '/discoverProjectSecurityPath',
}; };
const createComponent = options => { const createComponent = options => {
...@@ -47,6 +49,9 @@ describe('Security reports app', () => { ...@@ -47,6 +49,9 @@ describe('Security reports app', () => {
{ {
localVue, localVue,
propsData: { ...props }, propsData: { ...props },
stubs: {
HelpIcon: true,
},
}, },
options, options,
), ),
...@@ -68,7 +73,7 @@ describe('Security reports app', () => { ...@@ -68,7 +73,7 @@ describe('Security reports app', () => {
const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown); const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]'); const findHelpIconComponent = () => wrapper.find(HelpIcon);
const setupMockJobArtifact = reportType => { const setupMockJobArtifact = reportType => {
jest jest
.spyOn(Api, 'pipelineJobs') .spyOn(Api, 'pipelineJobs')
...@@ -133,8 +138,9 @@ describe('Security reports app', () => { ...@@ -133,8 +138,9 @@ describe('Security reports app', () => {
}); });
it('renders a help link', () => { it('renders a help link', () => {
expect(findHelpLink().attributes()).toMatchObject({ expect(findHelpIconComponent().props()).toEqual({
href: props.securityReportsDocsPath, helpPath: props.securityReportsDocsPath,
discoverProjectSecurityPath: props.discoverProjectSecurityPath,
}); });
}); });
}); });
......
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