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 { dasherize, convertToSnakeCase } from './text_utility';
export const addClassIfElementExists = (element, className) => {
if (element) {
......@@ -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>
import { GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AutoFixSettings from './auto_fix_settings.vue';
export default {
components: {
GlAlert,
GlLink,
GlSprintf,
GlTable,
......@@ -39,6 +40,21 @@ export default {
type: Object,
required: true,
},
gitlabCiPresent: {
type: Boolean,
required: false,
default: false,
},
autoDevopsPath: {
type: String,
required: false,
default: '',
},
canEnableAutoDevops: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
devopsMessage() {
......@@ -68,6 +84,14 @@ export default {
},
];
},
shouldShowAutoDevopsAlert() {
return Boolean(
this.glFeatures.sastConfigurationByClick &&
!this.autoDevopsEnabled &&
!this.gitlabCiPresent &&
this.canEnableAutoDevops,
);
},
},
methods: {
getStatusText(value) {
......@@ -81,6 +105,9 @@ export default {
});
},
},
autoDevopsAlertMessage: s__(`
SecurityConfiguration|You can quickly enable all security scanning tools by
enabling %{linkStart}Auto DevOps%{linkEnd}.`),
};
</script>
......@@ -98,6 +125,21 @@ export default {
</p>
</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">
<template #cell(feature)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div>
......
import Vue from 'vue';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
export default function init() {
const el = document.getElementById('js-security-configuration');
const {
autoDevopsEnabled,
autoDevopsHelpPagePath,
autoDevopsPath,
features,
helpPagePath,
latestPipelinePath,
......@@ -17,11 +18,6 @@ export default function init() {
toggleAutofixSettingEndpoint,
} = 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({
el,
components: {
......@@ -30,19 +26,24 @@ export default function init() {
render(createElement) {
return createElement(SecurityConfigurationApp, {
props: {
autoDevopsEnabled,
autoDevopsHelpPagePath,
autoDevopsPath,
features: JSON.parse(features),
helpPagePath,
latestPipelinePath,
...parseBooleanDataAttributes(el, [
'autoDevopsEnabled',
'canEnableAutoDevops',
'gitlabCiPresent',
]),
autoFixSettingsProps: {
autoFixEnabled: JSON.parse(autoFixEnabled),
autoFixHelpPath,
autoFixUserPath,
containerScanningHelpPath,
dependencyScanningHelpPath,
canToggleAutoFixSettings,
toggleAutofixSettingEndpoint,
...parseBooleanDataAttributes(el, ['canToggleAutoFixSettings']),
},
},
});
......
......@@ -9,6 +9,7 @@ module Projects
before_action only: [:show] do
push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false)
push_frontend_feature_flag(:sast_configuration_by_click, project, default_enabled: false)
end
before_action only: [:auto_fix] do
......
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 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', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(SecurityConfigurationApp, {
stubs: {
...stubChildren(SecurityConfigurationApp),
GlTable: false,
GlSprintf: false,
},
propsData: {
features: [],
autoDevopsEnabled: false,
latestPipelinePath: 'http://latestPipelinePath',
autoDevopsHelpPagePath: 'http://autoDevopsHelpPagePath',
helpPagePath: 'http://helpPagePath',
autoFixSettingsProps: {},
...props,
},
});
const createComponent = (options = {}) => {
wrapper = mount(
SecurityConfigurationApp,
merge(
{},
{
stubs: {
...stubChildren(SecurityConfigurationApp),
GlTable: false,
GlSprintf: false,
},
propsData,
},
options,
),
);
};
afterEach(() => {
......@@ -39,16 +49,17 @@ describe('Security Configuration App', () => {
const getPipelinesLink = () => wrapper.find({ ref: 'pipelinesLink' });
const getFeaturesTable = () => wrapper.find({ ref: 'securityControlTable' });
const getAlert = () => wrapper.find(GlAlert);
describe('header', () => {
it.each`
autoDevopsEnabled | expectedUrl
${true} | ${'http://autoDevopsHelpPagePath'}
${false} | ${'http://latestPipelinePath'}
${true} | ${propsData.autoDevopsHelpPagePath}
${false} | ${propsData.latestPipelinePath}
`(
'displays a link to "$expectedUrl" when autoDevops is "$autoDevopsEnabled"',
({ autoDevopsEnabled, expectedUrl }) => {
createComponent({ autoDevopsEnabled });
createComponent({ propsData: { autoDevopsEnabled } });
expect(getPipelinesLink().attributes('href')).toBe(expectedUrl);
expect(getPipelinesLink().attributes('target')).toBe('_blank');
......@@ -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', () => {
it('passes the expected data to the GlTable', () => {
const features = generateFeatures(5);
createComponent({ features });
createComponent({ propsData: { features } });
expect(getFeaturesTable().classes('b-table-stacked-md')).toBeTruthy();
const rows = getFeaturesTable().findAll('tbody tr');
......
......@@ -20307,6 +20307,9 @@ msgstr ""
msgid "SecurityConfiguration|Testing & Compliance"
msgstr ""
msgid "SecurityConfiguration|You can quickly enable all security scanning tools by enabling %{linkStart}Auto DevOps%{linkEnd}."
msgstr ""
msgid "SecurityReports|%{firstProject} and %{secondProject}"
msgstr ""
......
import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
import {
addClassIfElementExists,
canScrollUp,
canScrollDown,
parseBooleanDataAttributes,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
......@@ -112,4 +117,37 @@ describe('DOM Utils', () => {
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