Commit 49358f73 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '354874-available-badge-security-page' into 'master'

Add "Available on-demand" badge on Security Configuration Page

See merge request gitlab-org/gitlab!83723
parents 3edd2456 a510f49a
...@@ -50,11 +50,17 @@ export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( ...@@ -50,11 +50,17 @@ export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath(
export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); export const DAST_NAME = __('Dynamic Application Security Testing (DAST)');
export const DAST_SHORT_NAME = s__('ciReport|DAST'); export const DAST_SHORT_NAME = s__('ciReport|DAST');
export const DAST_DESCRIPTION = __('Analyze a review version of your web application.'); export const DAST_DESCRIPTION = s__(
'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.',
);
export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index');
export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'enable-dast', anchor: 'enable-dast',
}); });
export const DAST_BADGE_TEXT = __('Available on-demand');
export const DAST_BADGE_TOOLTIP = __(
'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects',
);
export const DAST_PROFILES_NAME = __('DAST profiles'); export const DAST_PROFILES_NAME = __('DAST profiles');
export const DAST_PROFILES_DESCRIPTION = s__( export const DAST_PROFILES_DESCRIPTION = s__(
...@@ -171,18 +177,23 @@ export const securityFeatures = [ ...@@ -171,18 +177,23 @@ export const securityFeatures = [
type: REPORT_TYPE_SAST_IAC, type: REPORT_TYPE_SAST_IAC,
}, },
{ {
name: DAST_NAME, badge: {
shortName: DAST_SHORT_NAME, text: DAST_BADGE_TEXT,
description: DAST_DESCRIPTION, tooltipText: DAST_BADGE_TOOLTIP,
helpPath: DAST_HELP_PATH, variant: 'info',
configurationHelpPath: DAST_CONFIG_HELP_PATH, },
type: REPORT_TYPE_DAST,
secondary: { secondary: {
type: REPORT_TYPE_DAST_PROFILES, type: REPORT_TYPE_DAST_PROFILES,
name: DAST_PROFILES_NAME, name: DAST_PROFILES_NAME,
description: DAST_PROFILES_DESCRIPTION, description: DAST_PROFILES_DESCRIPTION,
configurationText: DAST_PROFILES_CONFIG_TEXT, configurationText: DAST_PROFILES_CONFIG_TEXT,
}, },
name: DAST_NAME,
shortName: DAST_SHORT_NAME,
description: DAST_DESCRIPTION,
helpPath: DAST_HELP_PATH,
configurationHelpPath: DAST_CONFIG_HELP_PATH,
type: REPORT_TYPE_DAST,
}, },
{ {
name: DEPENDENCY_SCANNING_NAME, name: DEPENDENCY_SCANNING_NAME,
......
<script> <script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui'; import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants'; import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import FeatureCardBadge from './feature_card_badge.vue';
export default { export default {
components: { components: {
...@@ -10,6 +11,7 @@ export default { ...@@ -10,6 +11,7 @@ export default {
GlCard, GlCard,
GlIcon, GlIcon,
GlLink, GlLink,
FeatureCardBadge,
ManageViaMr, ManageViaMr,
}, },
props: { props: {
...@@ -37,11 +39,14 @@ export default { ...@@ -37,11 +39,14 @@ export default {
text: this.$options.i18n.enableFeature, text: this.$options.i18n.enableFeature,
}; };
button.category = 'secondary'; button.category = this.feature.category || 'secondary';
button.text = sprintf(button.text, { feature: this.shortName }); button.text = sprintf(button.text, { feature: this.shortName });
return button; return button;
}, },
manageViaMrButtonCategory() {
return this.feature.category || 'secondary';
},
showManageViaMr() { showManageViaMr() {
return ManageViaMr.canRender(this.feature); return ManageViaMr.canRender(this.feature);
}, },
...@@ -49,13 +54,17 @@ export default { ...@@ -49,13 +54,17 @@ export default {
return { 'gl-bg-gray-10': !this.available }; return { 'gl-bg-gray-10': !this.available };
}, },
statusClasses() { statusClasses() {
const { enabled } = this; const { enabled, hasBadge } = this;
return { return {
'gl-ml-auto': true, 'gl-ml-auto': true,
'gl-flex-shrink-0': true, 'gl-flex-shrink-0': true,
'gl-text-gray-500': !enabled, 'gl-text-gray-500': !enabled,
'gl-text-green-500': enabled, 'gl-text-green-500': enabled,
'gl-w-full': hasBadge,
'gl-justify-content-space-between': hasBadge,
'gl-display-flex': hasBadge,
'gl-mb-4': hasBadge,
}; };
}, },
hasSecondary() { hasSecondary() {
...@@ -68,6 +77,9 @@ export default { ...@@ -68,6 +77,9 @@ export default {
isNotSastIACTemporaryHack() { isNotSastIACTemporaryHack() {
return this.feature.type !== REPORT_TYPE_SAST_IAC; return this.feature.type !== REPORT_TYPE_SAST_IAC;
}, },
hasBadge() {
return Boolean(this.available && this.feature.badge?.text);
},
}, },
methods: { methods: {
onError(message) { onError(message) {
...@@ -88,7 +100,10 @@ export default { ...@@ -88,7 +100,10 @@ export default {
<template> <template>
<gl-card :class="cardClasses"> <gl-card :class="cardClasses">
<div class="gl-display-flex gl-align-items-baseline"> <div
class="gl-display-flex gl-align-items-baseline"
:class="{ 'gl-flex-direction-column-reverse': hasBadge }"
>
<h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3> <h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3>
<div <div
...@@ -97,13 +112,19 @@ export default { ...@@ -97,13 +112,19 @@ export default {
data-testid="feature-status" data-testid="feature-status"
:data-qa-selector="`${feature.type}_status`" :data-qa-selector="`${feature.type}_status`"
> >
<feature-card-badge
v-if="hasBadge"
:badge="feature.badge"
:badge-href="feature.badge.badgeHref"
/>
<template v-if="enabled"> <template v-if="enabled">
<gl-icon name="check-circle-filled" /> <gl-icon name="check-circle-filled" />
<span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
</template> </template>
<template v-else-if="available"> <template v-else-if="available">
{{ $options.i18n.notEnabled }} <span>{{ $options.i18n.notEnabled }}</span>
</template> </template>
<template v-else> <template v-else>
...@@ -133,7 +154,7 @@ export default { ...@@ -133,7 +154,7 @@ export default {
v-else-if="showManageViaMr" v-else-if="showManageViaMr"
:feature="feature" :feature="feature"
variant="confirm" variant="confirm"
category="secondary" :category="manageViaMrButtonCategory"
class="gl-mt-5" class="gl-mt-5"
:data-qa-selector="`${feature.type}_mr_button`" :data-qa-selector="`${feature.type}_mr_button`"
@error="onError" @error="onError"
......
<script>
import { GlBadge, GlTooltip } from '@gitlab/ui';
export default {
components: {
GlBadge,
GlTooltip,
},
props: {
badge: {
type: Object,
required: true,
},
badgeHref: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<span>
<gl-tooltip
v-if="badge.tooltipText"
placement="top"
boundary="window"
title="Tooltip title"
:target="() => $refs.badge"
>
{{ badge.tooltipText }}
</gl-tooltip>
<span ref="badge">
<gl-badge size="sm" :href="badgeHref" :variant="badge.variant">
{{ badge.text }}
</gl-badge>
</span>
</span>
</template>
...@@ -30,6 +30,10 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features = ...@@ -30,6 +30,10 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features =
augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] }; augmented.secondary = { ...augmented.secondary, ...featuresByType[feature.secondary.type] };
} }
if (augmented.badge && augmented.metaInfoPath) {
augmented.badge.badgeHref = augmented.metaInfoPath;
}
return augmented; return augmented;
}; };
......
...@@ -81,7 +81,8 @@ module Projects ...@@ -81,7 +81,8 @@ module Projects
configured: scan.configured?, configured: scan.configured?,
configuration_path: scan.configuration_path, configuration_path: scan.configuration_path,
available: scan.available?, available: scan.available?,
can_enable_by_merge_request: scan.can_enable_by_merge_request? can_enable_by_merge_request: scan.can_enable_by_merge_request?,
meta_info_path: scan.meta_info_path
} }
end end
......
...@@ -16,12 +16,21 @@ module EE ...@@ -16,12 +16,21 @@ module EE
configurable_scans[type] if can_configure_scan_in_ui? configurable_scans[type] if can_configure_scan_in_ui?
end end
override :meta_info_path
def meta_info_path
scans_with_meta_info[type] if can_access_security_on_demand_scans? && can_configure_scan_in_ui?
end
private private
def can_configure_scan_in_ui? def can_configure_scan_in_ui?
project.licensed_feature_available?(:security_configuration_in_ui) project.licensed_feature_available?(:security_configuration_in_ui)
end end
def can_access_security_on_demand_scans?
project.licensed_feature_available?(:security_on_demand_scans)
end
def configurable_scans def configurable_scans
strong_memoize(:configurable_scans) do strong_memoize(:configurable_scans) do
{ {
...@@ -34,6 +43,12 @@ module EE ...@@ -34,6 +43,12 @@ module EE
end end
end end
def scans_with_meta_info
{
dast: project_on_demand_scans_path(project)
}
end
override :scans_configurable_in_merge_request override :scans_configurable_in_merge_request
def scans_configurable_in_merge_request def scans_configurable_in_merge_request
super.concat(%i[dependency_scanning container_scanning]) super.concat(%i[dependency_scanning container_scanning])
......
...@@ -90,6 +90,74 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do ...@@ -90,6 +90,74 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do
end end
end end
describe '#meta_info_path' do
subject { scan.meta_info_path }
context 'when configuration in UI and security on demand is not available' do
before do
stub_licensed_features(security_on_demand_scans: false, security_configuration_in_ui: false)
end
context 'with a scanner with meta path' do
let(:type) { :dast }
it { is_expected.to be_nil }
end
end
context 'when configuration in UI and security on demand is available' do
before do
stub_licensed_features(security_on_demand_scans: true, security_configuration_in_ui: true)
end
context 'with a scanner without meta path' do
let(:type) { :other_scanner }
it { is_expected.to be_nil }
end
context 'with a scanner with meta path' do
let(:type) { :dast }
let(:meta_info_path) { "/#{project.namespace.path}/#{project.name}/-/on_demand_scans" }
it { is_expected.to eq(meta_info_path) }
end
end
context 'when configuration in UI is not available and security on demand is available' do
before do
stub_licensed_features(security_on_demand_scans: true, security_configuration_in_ui: false)
end
context 'with a scanner without meta path' do
let(:type) { :sast }
it { is_expected.to be_nil }
end
context 'with a scanner with meta path' do
let(:type) { :dast }
it { is_expected.to be_nil }
end
end
context 'when configuration in UI is available and security on demand is not available' do
before do
stub_licensed_features(security_on_demand_scans: false, security_configuration_in_ui: true)
end
where(:type, :meta_info_path) do
:sast | nil
:dast | nil
end
with_them do
it { is_expected.to eq(meta_info_path) }
end
end
end
describe '#can_enable_by_merge_request?' do describe '#can_enable_by_merge_request?' do
subject { scan.can_enable_by_merge_request? } subject { scan.can_enable_by_merge_request? }
......
...@@ -22,4 +22,29 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -22,4 +22,29 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
expect(result[:can_toggle_auto_fix_settings]).to be_truthy expect(result[:can_toggle_auto_fix_settings]).to be_truthy
end end
end end
describe '#to_html_data_attribute' do
subject(:result) { described_class.new(project, auto_fix_permission: true, current_user: current_user).to_h }
before do
stub_licensed_features(security_on_demand_scans: true, security_configuration_in_ui: true)
end
let(:meta_info_path) { "/#{project.namespace.path}/#{project.name}/-/on_demand_scans" }
let(:features) { result[:features] }
it 'includes feature meta information for dast scanner' do
feature = features.find { |scan| scan[:type].to_s == 'dast' }
expect(feature[:type].to_s).to eq('dast')
expect(feature[:meta_info_path]).to eq(meta_info_path)
end
it 'does not include feature meta information for other scanner' do
feature = features.find { |scan| scan[:type].to_s == 'sast' }
expect(feature[:type].to_s).to eq('sast')
expect(feature[:meta_info_path]).to be_nil
end
end
end end
...@@ -31,6 +31,8 @@ module Gitlab ...@@ -31,6 +31,8 @@ module Gitlab
def configuration_path; end def configuration_path; end
def meta_info_path; end
private private
attr_reader :project, :configured attr_reader :project, :configured
......
...@@ -4220,9 +4220,6 @@ msgstr "" ...@@ -4220,9 +4220,6 @@ msgstr ""
msgid "Analytics" msgid "Analytics"
msgstr "" msgstr ""
msgid "Analyze a review version of your web application."
msgstr ""
msgid "Analyze your dependencies for known vulnerabilities." msgid "Analyze your dependencies for known vulnerabilities."
msgstr "" msgstr ""
...@@ -5455,6 +5452,9 @@ msgstr "" ...@@ -5455,6 +5452,9 @@ msgstr ""
msgid "Available group runners: %{runners}" msgid "Available group runners: %{runners}"
msgstr "" msgstr ""
msgid "Available on-demand"
msgstr ""
msgid "Available runners: %{runners}" msgid "Available runners: %{runners}"
msgstr "" msgstr ""
...@@ -25989,6 +25989,9 @@ msgstr "" ...@@ -25989,6 +25989,9 @@ msgstr ""
msgid "On-call schedules" msgid "On-call schedules"
msgstr "" msgstr ""
msgid "On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects"
msgstr ""
msgid "OnCallScheduless|Any escalation rules that are using this schedule will also be deleted." msgid "OnCallScheduless|Any escalation rules that are using this schedule will also be deleted."
msgstr "" msgstr ""
...@@ -44120,6 +44123,9 @@ msgstr "" ...@@ -44120,6 +44123,9 @@ msgstr ""
msgid "ciReport|All tools" msgid "ciReport|All tools"
msgstr "" msgstr ""
msgid "ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running."
msgstr ""
msgid "ciReport|Automatically apply the patch in a new branch" msgid "ciReport|Automatically apply the patch in a new branch"
msgstr "" msgstr ""
......
import { mount } from '@vue/test-utils';
import { GlBadge, GlTooltip } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
describe('Feature card badge component', () => {
let wrapper;
const createComponent = (propsData) => {
wrapper = extendedWrapper(
mount(FeatureCardBadge, {
propsData,
}),
);
};
const findTooltip = () => wrapper.findComponent(GlTooltip);
const findBadge = () => wrapper.findComponent(GlBadge);
describe('tooltip render', () => {
describe.each`
context | badge | badgeHref
${'href on a badge object'} | ${{ tooltipText: 'test', badgeHref: 'href' }} | ${undefined}
${'href as property '} | ${{ tooltipText: null, badgeHref: '' }} | ${'link'}
${'default href no property on badge or component'} | ${{ tooltipText: null, badgeHref: '' }} | ${undefined}
`('given $context', ({ badge, badgeHref }) => {
beforeEach(() => {
createComponent({ badge, badgeHref });
});
it('should show badge when badge given in configuration and available', () => {
expect(findTooltip().exists()).toBe(Boolean(badge && badge.tooltipText));
});
it('should render correct link if link is provided', () => {
expect(findBadge().attributes().href).toEqual(badgeHref);
});
});
});
});
...@@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FeatureCard from '~/security_configuration/components/feature_card.vue'; import FeatureCard from '~/security_configuration/components/feature_card.vue';
import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { makeFeature } from './utils'; import { makeFeature } from './utils';
...@@ -16,6 +17,7 @@ describe('FeatureCard component', () => { ...@@ -16,6 +17,7 @@ describe('FeatureCard component', () => {
propsData, propsData,
stubs: { stubs: {
ManageViaMr: true, ManageViaMr: true,
FeatureCardBadge: true,
}, },
}), }),
); );
...@@ -24,6 +26,8 @@ describe('FeatureCard component', () => { ...@@ -24,6 +26,8 @@ describe('FeatureCard component', () => {
const findLinks = ({ text, href }) => const findLinks = ({ text, href }) =>
wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text); wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text);
const findBadge = () => wrapper.findComponent(FeatureCardBadge);
const findEnableLinks = () => const findEnableLinks = () =>
findLinks({ findLinks({
text: `Enable ${feature.shortName ?? feature.name}`, text: `Enable ${feature.shortName ?? feature.name}`,
...@@ -262,5 +266,28 @@ describe('FeatureCard component', () => { ...@@ -262,5 +266,28 @@ describe('FeatureCard component', () => {
}); });
}); });
}); });
describe('information badge', () => {
describe.each`
context | available | badge
${'available feature with badge'} | ${true} | ${{ text: 'test' }}
${'unavailable feature without badge'} | ${false} | ${null}
${'available feature without badge'} | ${true} | ${null}
${'unavailable feature with badge'} | ${false} | ${{ text: 'test' }}
${'available feature with empty badge'} | ${false} | ${{}}
`('given $context', ({ available, badge }) => {
beforeEach(() => {
feature = makeFeature({
available,
badge,
});
createComponent({ feature });
});
it('should show badge when badge given in configuration and available', () => {
expect(findBadge().exists()).toBe(Boolean(available && badge && badge.text));
});
});
});
}); });
}); });
...@@ -47,6 +47,16 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do ...@@ -47,6 +47,16 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do
it { is_expected.to be_nil } it { is_expected.to be_nil }
end end
describe '#meta_info_path' do
subject { scan.meta_info_path }
let(:configured) { true }
let(:available) { true }
let(:type) { :dast }
it { is_expected.to be_nil }
end
describe '#can_enable_by_merge_request?' do describe '#can_enable_by_merge_request?' do
subject { scan.can_enable_by_merge_request? } subject { scan.can_enable_by_merge_request? }
......
...@@ -87,6 +87,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -87,6 +87,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
expect(feature['configuration_path']).to be_nil expect(feature['configuration_path']).to be_nil
expect(feature['available']).to eq(true) expect(feature['available']).to eq(true)
expect(feature['can_enable_by_merge_request']).to eq(true) expect(feature['can_enable_by_merge_request']).to eq(true)
expect(feature['meta_info_path']).to be_nil
end end
context 'when checking features configured status' do context 'when checking features configured status' do
......
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