Commit 815a177b authored by Mark Florian's avatar Mark Florian

Add Auto DevOps alert to Security Configuration

This alert is displayed only when all the following is true:

1. The project has no existing .gitlab-ci.yml file
2. Auto DevOps is not enabled for the project
3. The user has the necessary permissions to enable Auto DevOps for the
   project
4. The `sast_configuration_by_click` feature flag is enabled (currently
   disabled by default)

Part of https://gitlab.com/gitlab-org/gitlab/-/issues/220573, which
itself is part of a larger SAST Configuration UI [epic].

[epic]: https://gitlab.com/groups/gitlab-org/-/epics/3262
parent 4fd0ea05
import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils';
import { dasherize, convertToSnakeCase } from './text_utility';
export const addClassIfElementExists = (element, className) => { export const addClassIfElementExists = (element, className) => {
if (element) { if (element) {
...@@ -25,3 +26,25 @@ export const toggleContainerClasses = (containerEl, classList) => { ...@@ -25,3 +26,25 @@ export const toggleContainerClasses = (containerEl, classList) => {
}); });
} }
}; };
/**
* Return a object mapping element dataset names to booleans.
*
* This is useful for data- attributes whose presense represent
* a truthiness, no matter the value of the attribute. The absense of the
* attribute represents falsiness.
*
* This can be useful when Rails-provided boolean-like values are passed
* directly to the HAML template, rather than cast to a string.
*
* @param {HTMLElement} element - The DOM element to inspect
* @param {string[]} names - The dataset (i.e., camelCase) names to inspect
* @returns {Object.<string, boolean>}
*/
export const parseBooleanDataAttributes = (element, names) =>
names.reduce((acc, name) => {
const attributeName = `data-${dasherize(convertToSnakeCase(name))}`;
acc[name] = element.hasAttribute(attributeName);
return acc;
}, {});
<script> <script>
import { GlLink, GlSprintf, GlTable } from '@gitlab/ui'; import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale'; import { s__, __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AutoFixSettings from './auto_fix_settings.vue'; import AutoFixSettings from './auto_fix_settings.vue';
export default { export default {
components: { components: {
GlAlert,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlTable, GlTable,
...@@ -39,6 +40,21 @@ export default { ...@@ -39,6 +40,21 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
gitlabCiPresent: {
type: Boolean,
required: false,
default: false,
},
autoDevopsPath: {
type: String,
required: false,
default: '',
},
canEnableAutoDevops: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
devopsMessage() { devopsMessage() {
...@@ -68,6 +84,14 @@ export default { ...@@ -68,6 +84,14 @@ export default {
}, },
]; ];
}, },
shouldShowAutoDevopsAlert() {
return Boolean(
this.glFeatures.sastConfigurationByClick &&
!this.autoDevopsEnabled &&
!this.gitlabCiPresent &&
this.canEnableAutoDevops,
);
},
}, },
methods: { methods: {
getStatusText(value) { getStatusText(value) {
...@@ -81,6 +105,9 @@ export default { ...@@ -81,6 +105,9 @@ export default {
}); });
}, },
}, },
autoDevopsAlertMessage: s__(`
SecurityConfiguration|You can quickly enable all security scanning tools by
enabling %{linkStart}Auto DevOps%{linkEnd}.`),
}; };
</script> </script>
...@@ -98,6 +125,21 @@ export default { ...@@ -98,6 +125,21 @@ export default {
</p> </p>
</header> </header>
<gl-alert
v-if="shouldShowAutoDevopsAlert"
:title="__('Auto DevOps')"
:primary-button-text="__('Enable Auto DevOps')"
:primary-button-link="autoDevopsPath"
:dismissible="false"
class="gl-mb-5"
>
<gl-sprintf :message="$options.autoDevopsAlertMessage">
<template #link="{ content }">
<gl-link :href="autoDevopsHelpPagePath" v-text="content" />
</template>
</gl-sprintf>
</gl-alert>
<gl-table ref="securityControlTable" :items="features" :fields="fields" stacked="md"> <gl-table ref="securityControlTable" :items="features" :fields="fields" stacked="md">
<template #cell(feature)="{ item }"> <template #cell(feature)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div> <div class="gl-text-gray-900">{{ item.name }}</div>
......
import Vue from 'vue'; import Vue from 'vue';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue'; import SecurityConfigurationApp from './components/app.vue';
export default function init() { export default function init() {
const el = document.getElementById('js-security-configuration'); const el = document.getElementById('js-security-configuration');
const { const {
autoDevopsEnabled,
autoDevopsHelpPagePath, autoDevopsHelpPagePath,
autoDevopsPath,
features, features,
helpPagePath, helpPagePath,
latestPipelinePath, latestPipelinePath,
...@@ -17,11 +18,6 @@ export default function init() { ...@@ -17,11 +18,6 @@ export default function init() {
toggleAutofixSettingEndpoint, toggleAutofixSettingEndpoint,
} = el.dataset; } = el.dataset;
// When canToggleAutoFixSettings is false in the backend, it is undefined in the frontend,
// and when it's true in the backend, it comes in as an empty string in the frontend. The next
// line ensures that we cast it to a boolean.
const canToggleAutoFixSettings = el.dataset.canToggleAutoFixSettings !== undefined;
return new Vue({ return new Vue({
el, el,
components: { components: {
...@@ -30,19 +26,24 @@ export default function init() { ...@@ -30,19 +26,24 @@ export default function init() {
render(createElement) { render(createElement) {
return createElement(SecurityConfigurationApp, { return createElement(SecurityConfigurationApp, {
props: { props: {
autoDevopsEnabled,
autoDevopsHelpPagePath, autoDevopsHelpPagePath,
autoDevopsPath,
features: JSON.parse(features), features: JSON.parse(features),
helpPagePath, helpPagePath,
latestPipelinePath, latestPipelinePath,
...parseBooleanDataAttributes(el, [
'autoDevopsEnabled',
'canEnableAutoDevops',
'gitlabCiPresent',
]),
autoFixSettingsProps: { autoFixSettingsProps: {
autoFixEnabled: JSON.parse(autoFixEnabled), autoFixEnabled: JSON.parse(autoFixEnabled),
autoFixHelpPath, autoFixHelpPath,
autoFixUserPath, autoFixUserPath,
containerScanningHelpPath, containerScanningHelpPath,
dependencyScanningHelpPath, dependencyScanningHelpPath,
canToggleAutoFixSettings,
toggleAutofixSettingEndpoint, toggleAutofixSettingEndpoint,
...parseBooleanDataAttributes(el, ['canToggleAutoFixSettings']),
}, },
}, },
}); });
......
...@@ -9,6 +9,7 @@ module Projects ...@@ -9,6 +9,7 @@ module Projects
before_action only: [:show] do before_action only: [:show] do
push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false) push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false)
push_frontend_feature_flag(:sast_configuration_by_click, project, default_enabled: false)
end end
before_action only: [:auto_fix] do before_action only: [:auto_fix] do
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { merge } from 'lodash';
import { GlAlert, GlLink } from '@gitlab/ui';
import SecurityConfigurationApp from 'ee/security_configuration/components/app.vue'; import SecurityConfigurationApp from 'ee/security_configuration/components/app.vue';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
const propsData = {
features: [],
autoDevopsEnabled: false,
latestPipelinePath: 'http://latestPipelinePath',
autoDevopsHelpPagePath: 'http://autoDevopsHelpPagePath',
autoDevopsPath: 'http://autoDevopsPath',
helpPagePath: 'http://helpPagePath',
autoFixSettingsProps: {},
};
describe('Security Configuration App', () => { describe('Security Configuration App', () => {
let wrapper; let wrapper;
const createComponent = (props = {}) => { const createComponent = (options = {}) => {
wrapper = mount(SecurityConfigurationApp, { wrapper = mount(
stubs: { SecurityConfigurationApp,
...stubChildren(SecurityConfigurationApp), merge(
GlTable: false, {},
GlSprintf: false, {
}, stubs: {
propsData: { ...stubChildren(SecurityConfigurationApp),
features: [], GlTable: false,
autoDevopsEnabled: false, GlSprintf: false,
latestPipelinePath: 'http://latestPipelinePath', },
autoDevopsHelpPagePath: 'http://autoDevopsHelpPagePath', propsData,
helpPagePath: 'http://helpPagePath', },
autoFixSettingsProps: {}, options,
...props, ),
}, );
});
}; };
afterEach(() => { afterEach(() => {
...@@ -39,16 +49,17 @@ describe('Security Configuration App', () => { ...@@ -39,16 +49,17 @@ describe('Security Configuration App', () => {
const getPipelinesLink = () => wrapper.find({ ref: 'pipelinesLink' }); const getPipelinesLink = () => wrapper.find({ ref: 'pipelinesLink' });
const getFeaturesTable = () => wrapper.find({ ref: 'securityControlTable' }); const getFeaturesTable = () => wrapper.find({ ref: 'securityControlTable' });
const getAlert = () => wrapper.find(GlAlert);
describe('header', () => { describe('header', () => {
it.each` it.each`
autoDevopsEnabled | expectedUrl autoDevopsEnabled | expectedUrl
${true} | ${'http://autoDevopsHelpPagePath'} ${true} | ${propsData.autoDevopsHelpPagePath}
${false} | ${'http://latestPipelinePath'} ${false} | ${propsData.latestPipelinePath}
`( `(
'displays a link to "$expectedUrl" when autoDevops is "$autoDevopsEnabled"', 'displays a link to "$expectedUrl" when autoDevops is "$autoDevopsEnabled"',
({ autoDevopsEnabled, expectedUrl }) => { ({ autoDevopsEnabled, expectedUrl }) => {
createComponent({ autoDevopsEnabled }); createComponent({ propsData: { autoDevopsEnabled } });
expect(getPipelinesLink().attributes('href')).toBe(expectedUrl); expect(getPipelinesLink().attributes('href')).toBe(expectedUrl);
expect(getPipelinesLink().attributes('target')).toBe('_blank'); expect(getPipelinesLink().attributes('target')).toBe('_blank');
...@@ -56,11 +67,68 @@ describe('Security Configuration App', () => { ...@@ -56,11 +67,68 @@ describe('Security Configuration App', () => {
); );
}); });
describe('Auto DevOps alert', () => {
describe.each`
gitlabCiPresent | autoDevopsEnabled | canEnableAutoDevops | sastConfigurationByClick | shouldShowAlert
${false} | ${false} | ${true} | ${true} | ${true}
${true} | ${false} | ${true} | ${true} | ${false}
${false} | ${true} | ${true} | ${true} | ${false}
${false} | ${false} | ${false} | ${true} | ${false}
${false} | ${false} | ${true} | ${false} | ${false}
`(
'given gitlabCiPresent is $gitlabCiPresent, autoDevopsEnabled is $autoDevopsEnabled, canEnableAutoDevops is $canEnableAutoDevops, sastConfigurationByClick is $sastConfigurationByClick',
({
gitlabCiPresent,
autoDevopsEnabled,
canEnableAutoDevops,
sastConfigurationByClick,
shouldShowAlert,
}) => {
beforeEach(() => {
createComponent({
propsData: {
gitlabCiPresent,
autoDevopsEnabled,
canEnableAutoDevops,
},
provide: { glFeatures: { sastConfigurationByClick } },
});
});
it(`is${shouldShowAlert ? '' : ' not'} rendered`, () => {
expect(getAlert().exists()).toBe(shouldShowAlert);
});
if (shouldShowAlert) {
it('has the expected text', () => {
expect(getAlert().text()).toMatchInterpolatedText(
SecurityConfigurationApp.autoDevopsAlertMessage,
);
});
it('has a link to the Auto DevOps docs', () => {
const link = getAlert().find(GlLink);
expect(link.attributes().href).toBe(propsData.autoDevopsHelpPagePath);
});
it('has the correct primary button', () => {
expect(getAlert().props()).toMatchObject({
title: 'Auto DevOps',
primaryButtonText: 'Enable Auto DevOps',
primaryButtonLink: propsData.autoDevopsPath,
dismissible: false,
});
});
}
},
);
});
describe('features table', () => { describe('features table', () => {
it('passes the expected data to the GlTable', () => { it('passes the expected data to the GlTable', () => {
const features = generateFeatures(5); const features = generateFeatures(5);
createComponent({ features }); createComponent({ propsData: { features } });
expect(getFeaturesTable().classes('b-table-stacked-md')).toBeTruthy(); expect(getFeaturesTable().classes('b-table-stacked-md')).toBeTruthy();
const rows = getFeaturesTable().findAll('tbody tr'); const rows = getFeaturesTable().findAll('tbody tr');
......
...@@ -20307,6 +20307,9 @@ msgstr "" ...@@ -20307,6 +20307,9 @@ msgstr ""
msgid "SecurityConfiguration|Testing & Compliance" msgid "SecurityConfiguration|Testing & Compliance"
msgstr "" msgstr ""
msgid "SecurityConfiguration|You can quickly enable all security scanning tools by enabling %{linkStart}Auto DevOps%{linkEnd}."
msgstr ""
msgid "SecurityReports|%{firstProject} and %{secondProject}" msgid "SecurityReports|%{firstProject} and %{secondProject}"
msgstr "" msgstr ""
......
import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils'; import {
addClassIfElementExists,
canScrollUp,
canScrollDown,
parseBooleanDataAttributes,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5; const TEST_MARGIN = 5;
...@@ -112,4 +117,37 @@ describe('DOM Utils', () => { ...@@ -112,4 +117,37 @@ describe('DOM Utils', () => {
expect(canScrollDown(element, TEST_MARGIN)).toBe(false); expect(canScrollDown(element, TEST_MARGIN)).toBe(false);
}); });
}); });
describe('parseBooleanDataAttributes', () => {
let element;
beforeEach(() => {
setFixtures('<div data-foo-bar data-baz data-qux="">');
element = document.querySelector('[data-foo-bar]');
});
it('throws if not given an element', () => {
expect(() => parseBooleanDataAttributes(null, ['baz'])).toThrow();
});
it('throws if not given an array of dataset names', () => {
expect(() => parseBooleanDataAttributes(element)).toThrow();
});
it('returns an empty object if given an empty array of names', () => {
expect(parseBooleanDataAttributes(element, [])).toEqual({});
});
it('correctly parses boolean-like data attributes', () => {
expect(
parseBooleanDataAttributes(element, ['fooBar', 'foobar', 'baz', 'qux', 'doesNotExist']),
).toEqual({
fooBar: true,
foobar: false,
baz: true,
qux: true,
doesNotExist: false,
});
});
});
}); });
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