diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue new file mode 100644 index 0000000000000000000000000000000000000000..7192108f7c53c1bc491726f76b20c2dc3afeaeb0 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_enabled_alert.vue @@ -0,0 +1,30 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['autoDevopsHelpPagePath'], + i18n: { + body: s__( + 'AutoDevopsAlert|Security testing tools enabled with %{linkStart}Auto DevOps%{linkEnd}', + ), + }, +}; +</script> + +<template> + <gl-alert variant="success" @dismiss="$emit('dismiss')"> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="autoDevopsHelpPagePath"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index d80c67da8b12c7b5ed3a5c5331b30711aea20f1f..fda18fb8009cc3a15378222b950241d2cbc1a13a 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -305,3 +305,6 @@ export const featureToMutationMap = { }), }, }; + +export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = + 'security_configuration_auto_devops_enabled_dismissed_projects'; diff --git a/app/assets/javascripts/security_configuration/components/redesigned_app.vue b/app/assets/javascripts/security_configuration/components/redesigned_app.vue index 915da378a4f1823668406ae38d299e40ca88acc8..6c70a8c33dbae1230740e4da9f5773a212fabe1f 100644 --- a/app/assets/javascripts/security_configuration/components/redesigned_app.vue +++ b/app/assets/javascripts/security_configuration/components/redesigned_app.vue @@ -1,8 +1,11 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; +import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; +import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; import FeatureCard from './feature_card.vue'; import SectionLayout from './section_layout.vue'; import UpgradeBanner from './upgrade_banner.vue'; @@ -25,16 +28,19 @@ export const i18n = { export default { i18n, components: { - GlTab, + AutoDevOpsAlert, + AutoDevOpsEnabledAlert, + FeatureCard, GlLink, - GlTabs, GlSprintf, - FeatureCard, + GlTab, + GlTabs, + LocalStorageSync, SectionLayout, UpgradeBanner, - AutoDevOpsAlert, UserCalloutDismisser, }, + inject: ['projectPath'], props: { augmentedSecurityFeatures: { type: Array, @@ -70,6 +76,11 @@ export default { default: '', }, }, + data() { + return { + autoDevopsEnabledAlertDismissedProjects: [], + }; + }, computed: { canUpgrade() { return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some( @@ -82,12 +93,32 @@ export default { shouldShowDevopsAlert() { return !this.autoDevopsEnabled && !this.gitlabCiPresent && this.canEnableAutoDevops; }, + shouldShowAutoDevopsEnabledAlert() { + return ( + this.autoDevopsEnabled && + !this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectPath) + ); + }, }, + methods: { + dismissAutoDevopsEnabledAlert() { + const dismissedProjects = new Set(this.autoDevopsEnabledAlertDismissedProjects); + dismissedProjects.add(this.projectPath); + this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects); + }, + }, + autoDevopsEnabledAlertStorageKey: AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, }; </script> <template> <article> + <local-storage-sync + v-model="autoDevopsEnabledAlertDismissedProjects" + :storage-key="$options.autoDevopsEnabledAlertStorageKey" + as-json + /> + <user-callout-dismisser v-if="shouldShowDevopsAlert" feature-name="security_configuration_devops_alert" @@ -105,8 +136,14 @@ export default { </template> </user-callout-dismisser> - <gl-tabs content-class="gl-pt-6"> + <gl-tabs content-class="gl-pt-0"> <gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting"> + <auto-dev-ops-enabled-alert + v-if="shouldShowAutoDevopsEnabledAlert" + class="gl-mt-3" + @dismiss="dismissAutoDevopsEnabledAlert" + /> + <section-layout :heading="$options.i18n.securityTesting"> <template #description> <p> diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue index e351f9b9d8da74634d042b900e810255bb262e7e..1fe8dd862a0185a51f9a7b22db0670e44afb5897 100644 --- a/app/assets/javascripts/security_configuration/components/section_layout.vue +++ b/app/assets/javascripts/security_configuration/components/section_layout.vue @@ -11,7 +11,7 @@ export default { </script> <template> - <div class="row gl-line-height-20"> + <div class="row gl-line-height-20 gl-pt-6"> <div class="col-lg-4"> <h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2> <slot name="description"></slot> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c851f9ac14a1265ac26c913d28663495807280c4..8a7f4c1c0754c959dada8ded50ade3654522c07d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4769,6 +4769,9 @@ msgstr "" msgid "AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found." msgstr "" +msgid "AutoDevopsAlert|Security testing tools enabled with %{linkStart}Auto DevOps%{linkEnd}" +msgstr "" + msgid "AutoRemediation| 1 Merge Request" msgstr "" diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..778fea2896a1201dc530e7143d3193350cf51da9 --- /dev/null +++ b/spec/frontend/security_configuration/components/auto_dev_ops_enabled_alert_spec.js @@ -0,0 +1,46 @@ +import { GlAlert } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; + +const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; + +describe('AutoDevopsEnabledAlert component', () => { + let wrapper; + + const createComponent = () => { + wrapper = mount(AutoDevopsEnabledAlert, { + provide: { + autoDevopsHelpPagePath, + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains correct body text', () => { + expect(wrapper.text()).toMatchInterpolatedText(AutoDevopsEnabledAlert.i18n.body); + }); + + it('renders the link correctly', () => { + const link = wrapper.find('a[href]'); + + expect(link.attributes('href')).toBe(autoDevopsHelpPagePath); + expect(link.text()).toBe('Auto DevOps'); + }); + + it('bubbles up dismiss events from the GlAlert', () => { + expect(wrapper.emitted('dismiss')).toBe(undefined); + + findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/redesigned_app_spec.js index 119a25a77c16aa02f0b479ab2823e23f7f68d26c..92e0443552c94265ed23ad52556176350a514fbf 100644 --- a/spec/frontend/security_configuration/components/redesigned_app_spec.js +++ b/spec/frontend/security_configuration/components/redesigned_app_spec.js @@ -1,8 +1,11 @@ import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; +import stubChildren from 'helpers/stub_children'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; +import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; import { SAST_NAME, SAST_SHORT_NAME, @@ -12,6 +15,7 @@ import { LICENSE_COMPLIANCE_NAME, LICENSE_COMPLIANCE_DESCRIPTION, LICENSE_COMPLIANCE_HELP_PATH, + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; @@ -28,6 +32,9 @@ const upgradePath = '/upgrade'; const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; +const projectPath = 'namespace/project'; + +useLocalStorageSpy(); describe('redesigned App component', () => { let wrapper; @@ -43,8 +50,14 @@ describe('redesigned App component', () => { upgradePath, autoDevopsHelpPagePath, autoDevopsPath, + projectPath, }, stubs: { + ...stubChildren(RedesignedSecurityConfigurationApp), + GlLink: false, + GlSprintf: false, + LocalStorageSync: false, + SectionLayout: false, UserCalloutDismisser: makeMockUserCalloutDismisser({ dismiss: userCalloutDismissSpy, shouldShowCallout, @@ -83,6 +96,7 @@ describe('redesigned App component', () => { }); const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); + const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); const securityFeaturesMock = [ { @@ -161,7 +175,7 @@ describe('redesigned App component', () => { }); }); - describe('autoDevOpsAlert', () => { + describe('Auto DevOps hint alert', () => { describe('given the right props', () => { beforeEach(() => { createComponent({ @@ -199,6 +213,76 @@ describe('redesigned App component', () => { }); }); + describe('Auto DevOps enabled alert', () => { + describe.each` + context | autoDevopsEnabled | localStorageValue | shouldRender + ${'enabled'} | ${true} | ${null} | ${true} + ${'enabled, alert dismissed on other project'} | ${true} | ${['foo/bar']} | ${true} + ${'enabled, alert dismissed on this project'} | ${true} | ${[projectPath]} | ${false} + ${'not enabled'} | ${false} | ${null} | ${false} + `('given Auto DevOps is $context', ({ autoDevopsEnabled, localStorageValue, shouldRender }) => { + beforeEach(() => { + if (localStorageValue !== null) { + window.localStorage.setItem( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(localStorageValue), + ); + } + + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + autoDevopsEnabled, + }); + }); + + it(shouldRender ? 'renders' : 'does not render', () => { + expect(findAutoDevopsEnabledAlert().exists()).toBe(shouldRender); + }); + }); + + describe('dismissing', () => { + describe.each` + dismissedProjects | expectedWrittenValue + ${null} | ${[projectPath]} + ${[]} | ${[projectPath]} + ${['foo/bar']} | ${['foo/bar', projectPath]} + ${[projectPath]} | ${[projectPath]} + `( + 'given dismissed projects $dismissedProjects', + ({ dismissedProjects, expectedWrittenValue }) => { + beforeEach(() => { + if (dismissedProjects !== null) { + window.localStorage.setItem( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(dismissedProjects), + ); + } + + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + autoDevopsEnabled: true, + }); + + findAutoDevopsEnabledAlert().vm.$emit('dismiss'); + }); + + it('adds current project to localStorage value', () => { + expect(window.localStorage.setItem).toHaveBeenLastCalledWith( + AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, + JSON.stringify(expectedWrittenValue), + ); + }); + + it('hides the alert', () => { + expect(findAutoDevopsEnabledAlert().exists()).toBe(false); + }); + }, + ); + }); + }); + describe('upgrade banner', () => { const makeAvailable = (available) => (feature) => ({ ...feature, available });