diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index fd713a7a50439a0f6287ac42aa611797e83f31f2..da213b0ed43072c07eff8a79704e53e17761d1c9 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -1,5 +1,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { __, s__ } from '~/locale'; +import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; import { REPORT_TYPE_SAST, @@ -210,6 +211,7 @@ export const securityFeatures = [ configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, type: REPORT_TYPE_DEPENDENCY_SCANNING, anchor: 'dependency-scanning', + slotComponent: ContinuousVulnerabilityScan, }, { name: CONTAINER_SCANNING_NAME, diff --git a/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue b/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue new file mode 100644 index 0000000000000000000000000000000000000000..61cbde2107c0a90a2e9d4e8b52764088d82a3447 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/continuous_vulnerability_scan.vue @@ -0,0 +1,127 @@ +<script> +import { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + name: 'ContinuousVulnerabilityscan', + components: { GlBadge, GlIcon, GlToggle, GlLink, GlSprintf, GlAlert }, + mixins: [glFeatureFlagsMixin()], + inject: ['continuousVulnerabilityScansEnabled', 'projectFullPath'], + i18n: { + badgeLabel: __('Experiment'), + title: s__('CVS|Continuous Vulnerability Scan'), + description: s__( + 'CVS|Detect vulnerabilities outside a pipeline as new data is added to the GitLab Advisory Database.', + ), + learnMore: __('Learn more'), + testingAgreementMessage: s__( + 'CVS|By enabling this feature, you accept the %{linkStart}Testing Terms of Use%{linkEnd}', + ), + }, + props: { + feature: { + type: Object, + required: true, + }, + }, + data() { + return { + toggleValue: this.continuousVulnerabilityScansEnabled, + errorMessage: '', + isAlertDismissed: false, + }; + }, + computed: { + isFeatureConfigured() { + return this.feature.available && this.feature.configured; + }, + shouldShowAlert() { + return this.errorMessage && !this.isAlertDismissed; + }, + }, + methods: { + reportError(error) { + this.errorMessage = error; + this.isAlertDismissed = false; + }, + async toggleCVS(checked) { + try { + const { data } = await this.$apollo.mutate({ + mutation: ProjectSetContinuousVulnerabilityScanning, + variables: { + input: { + projectPath: this.projectFullPath, + enable: checked, + }, + }, + }); + + const { errors } = data.projectSetContinuousVulnerabilityScanning; + + if (errors.length > 0) { + this.reportError(errors[0].message); + } + if (data.projectSetContinuousVulnerabilityScanning !== null) { + this.toggleValue = checked; + } + } catch (error) { + this.reportError(error); + } + }, + }, + CVSHelpPagePath: helpPagePath( + 'user/application_security/continuous_vulnerability_scanning/index', + ), + experimentHelpPagePath: helpPagePath('policy/experiment-beta-support', { anchor: 'experiment' }), +}; +</script> + +<template> + <div v-if="glFeatures.dependencyScanningOnAdvisoryIngestion"> + <h4 class="gl-font-base gl-m-0 gl-mt-6"> + {{ $options.i18n.title }} + <gl-badge + ref="badge" + :href="$options.experimentHelpPagePath" + target="_blank" + size="sm" + variant="neutral" + class="gl-cursor-pointer" + >{{ $options.i18n.badgeLabel }}</gl-badge + > + </h4> + <gl-alert + v-if="shouldShowAlert" + class="gl-mb-5 gl-mt-2" + variant="danger" + @dismiss="isAlertDismissed = true" + >{{ errorMessage }}</gl-alert + > + <gl-toggle + class="gl-mt-5" + :disabled="!isFeatureConfigured" + :value="toggleValue" + :label="s__('CVS|Toggle CVS')" + label-position="hidden" + @change="toggleCVS" + /> + + <p class="gl-mb-0 gl-mt-5"> + {{ $options.i18n.description }} + <gl-link :href="$options.CVSHelpPagePath" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + <br /> + <gl-sprintf :message="$options.i18n.testingAgreementMessage"> + <template #link="{ content }"> + <gl-link href="https://about.gitlab.com/handbook/legal/testing-agreement" target="_blank"> + {{ content }} <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index a757657339b6ee89a84073c83e1a0a3a3e9ac91c..7f0a049a6adcd3dd85560571896846faa149542b 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -73,6 +73,9 @@ export default { hasSecondary() { return Boolean(this.feature.secondary); }, + hasSlotComponent() { + return Boolean(this.feature.slotComponent); + }, // This condition is a temporary hack to not display any wrong information // until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307. // More Information: https://gitlab.com/gitlab-org/gitlab/-/issues/350307#note_825447417 @@ -215,5 +218,9 @@ export default { {{ $options.i18n.configurationGuide }} </gl-button> </div> + + <div v-if="hasSlotComponent"> + <component :is="feature.slotComponent" :feature="feature" /> + </div> </gl-card> </template> diff --git a/ee/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql b/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql similarity index 100% rename from ee/app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql rename to app/assets/javascripts/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index aa3c9c876221d46d0253ad46378c2716428fdac6..4b4980911342cca896fdb371c7d9c47295bbebcb 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -26,6 +26,7 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + continuousVulnerabilityScansEnabled, } = el.dataset; const { augmentedSecurityFeatures } = augmentFeatures( @@ -43,6 +44,7 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + continuousVulnerabilityScansEnabled, }, render(createElement) { return createElement(SecurityConfigurationApp, { diff --git a/ee/app/controllers/ee/projects/security/configuration_controller.rb b/ee/app/controllers/ee/projects/security/configuration_controller.rb index e8e1d556ef961a2f5e31fa78082b001c8642632c..e3988390624fb27bf1181d2643782828c6305955 100644 --- a/ee/app/controllers/ee/projects/security/configuration_controller.rb +++ b/ee/app/controllers/ee/projects/security/configuration_controller.rb @@ -14,6 +14,7 @@ module ConfigurationController before_action only: [:show] do push_frontend_feature_flag(:security_auto_fix, project) + push_frontend_feature_flag(:dependency_scanning_on_advisory_ingestion, project) end before_action only: [:auto_fix] do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 681e93ed8dade46662f722da476c11140c63e101..912ecb353bc5591a3f5eb262ed361d237fe1895d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9172,6 +9172,18 @@ msgstr "" msgid "CVE|Why Request a CVE ID?" msgstr "" +msgid "CVS|By enabling this feature, you accept the %{linkStart}Testing Terms of Use%{linkEnd}" +msgstr "" + +msgid "CVS|Continuous Vulnerability Scan" +msgstr "" + +msgid "CVS|Detect vulnerabilities outside a pipeline as new data is added to the GitLab Advisory Database." +msgstr "" + +msgid "CVS|Toggle CVS" +msgstr "" + msgid "Cadence is not automated" msgstr "" diff --git a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..84a468e4dd832cf1a554a4b1ece310f1205e9c88 --- /dev/null +++ b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBadge, GlToggle } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; +import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +Vue.use(VueApollo); + +const setCVSMockResponse = { + data: { + projectSetContinuousVulnerabilityScanning: { + continuousVulnerabilityScanningEnabled: true, + errors: [], + }, + }, +}; + +const defaultProvide = { + continuousVulnerabilityScansEnabled: true, + projectFullPath: 'project/full/path', +}; + +describe('ContinuousVulnerabilityScan', () => { + let wrapper; + let apolloProvider; + let requestHandlers; + + const createComponent = (options) => { + requestHandlers = { + setCVSMutationHandler: jest.fn().mockResolvedValue(setCVSMockResponse), + }; + + apolloProvider = createMockApollo([ + [ProjectSetContinuousVulnerabilityScanning, requestHandlers.setCVSMutationHandler], + ]); + + wrapper = shallowMount(ContinuousVulnerabilityScan, { + propsData: { + feature: { + available: true, + configured: true, + }, + }, + provide: { + glFeatures: { + dependencyScanningOnAdvisoryIngestion: true, + }, + ...defaultProvide, + }, + apolloProvider, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + apolloProvider = null; + }); + + const findBadge = () => wrapper.findComponent(GlBadge); + const findToggle = () => wrapper.findComponent(GlToggle); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toContain('Continuous Vulnerability Scan'); + }); + + it('renders the badge and toggle component with correct values', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Experiment'); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props('value')).toBe(defaultProvide.continuousVulnerabilityScansEnabled); + }); + + it('should disable toggle when feature is not configured', () => { + createComponent({ + propsData: { + feature: { + available: true, + configured: false, + }, + }, + }); + expect(findToggle().props('disabled')).toBe(true); + }); + + it('calls mutation on toggle change with correct payload', () => { + findToggle().vm.$emit('change', true); + + expect(requestHandlers.setCVSMutationHandler).toHaveBeenCalledWith({ + input: { + projectPath: 'project/full/path', + enable: true, + }, + }); + }); + + describe('when feature flag is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + dependencyScanningOnAdvisoryIngestion: false, + }, + ...defaultProvide, + }, + }); + }); + + it('should not render toggle and badge', () => { + expect(findToggle().exists()).toBe(false); + expect(findBadge().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 983a66a7fd3dfdb9b127269ce502b13710385435..c715d01dd581e9f2be26ac2de4e19d98680f92d3 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,5 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { securityFeatures } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; @@ -13,6 +14,10 @@ import { import { manageViaMRErrorMessage } from '../constants'; import { makeFeature } from './utils'; +const MockComponent = Vue.component('MockComponent', { + render: (createElement) => createElement('span'), +}); + describe('FeatureCard component', () => { let feature; let wrapper; @@ -389,4 +394,17 @@ describe('FeatureCard component', () => { }); }); }); + + describe('when a slot component is passed', () => { + beforeEach(() => { + feature = makeFeature({ + slotComponent: MockComponent, + }); + createComponent({ feature }); + }); + + it('renders the component properly', () => { + expect(wrapper.findComponent(MockComponent).exists()).toBe(true); + }); + }); });