Commit feb51cbe authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch...

Merge branch '220573-frontend-modify-secure-configuration-page-to-provide-entry-point-to-sast-config-ui-page-2' into 'master'

Add button to Security Configuration to enable SAST scanning

See merge request gitlab-org/gitlab!36432
parents ca7aa622 4d9f4a7a
...@@ -14,7 +14,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -14,7 +14,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
The security configuration page displays the configuration state of each of the security The security configuration page displays the configuration state of each of the security
features and can be accessed through a project's sidebar nav. features and can be accessed through a project's sidebar nav.
![Screenshot of security configuration page](../img/security_configuration_page_v13_1.png) ![Screenshot of security configuration page](../img/security_configuration_page_v13_2.png)
The page uses the project's latest default branch [CI pipeline](../../../ci/pipelines/index.md) to determine the configuration The page uses the project's latest default branch [CI pipeline](../../../ci/pipelines/index.md) to determine the configuration
state of each feature. If a job with the expected security report artifact exists in the pipeline, state of each feature. If a job with the expected security report artifact exists in the pipeline,
......
...@@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ 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';
import CreateMergeRequestButton from './create_merge_request_button.vue';
export default { export default {
components: { components: {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
GlSprintf, GlSprintf,
GlTable, GlTable,
AutoFixSettings, AutoFixSettings,
CreateMergeRequestButton,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -55,6 +57,12 @@ export default { ...@@ -55,6 +57,12 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
// TODO: Remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/227575
createSastMergeRequestPath: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
devopsMessage() { devopsMessage() {
...@@ -70,18 +78,26 @@ export default { ...@@ -70,18 +78,26 @@ export default {
return this.autoDevopsEnabled ? this.autoDevopsHelpPagePath : this.latestPipelinePath; return this.autoDevopsEnabled ? this.autoDevopsHelpPagePath : this.latestPipelinePath;
}, },
fields() { fields() {
const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!';
const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
return [ return [
{ {
key: 'feature', key: 'feature',
label: s__('SecurityConfiguration|Security Control'), label: s__('SecurityConfiguration|Security Control'),
thClass: 'gl-text-gray-900 bg-transparent border-bottom', thClass,
}, },
{ {
key: 'configured', key: 'configured',
label: s__('SecurityConfiguration|Status'), label: s__('SecurityConfiguration|Status'),
thClass: 'gl-text-gray-900 bg-transparent border-bottom', thClass,
formatter: this.getStatusText, formatter: this.getStatusText,
}, },
{
key: 'manage',
label: s__('SecurityConfiguration|Manage'),
thClass,
},
]; ];
}, },
shouldShowAutoDevopsAlert() { shouldShowAutoDevopsAlert() {
...@@ -95,15 +111,28 @@ export default { ...@@ -95,15 +111,28 @@ export default {
}, },
methods: { methods: {
getStatusText(value) { getStatusText(value) {
return value if (value) {
? s__('SecurityConfiguration|Enabled') return this.autoDevopsEnabled
: s__('SecurityConfiguration|Not yet enabled'); ? s__('SecurityConfiguration|Enabled with Auto DevOps')
: s__('SecurityConfiguration|Enabled');
}
return s__('SecurityConfiguration|Not enabled');
}, },
getFeatureDocumentationLinkLabel(featureName) { getFeatureDocumentationLinkLabel(featureName) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), { return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), {
featureName, featureName,
}); });
}, },
// TODO: Remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/227575
canCreateSASTMergeRequest(feature) {
return Boolean(
this.glFeatures.sastConfigurationByClick &&
feature.type === 'sast' &&
this.createSastMergeRequestPath &&
!this.gitlabCiPresent,
);
},
}, },
autoDevopsAlertMessage: s__(` autoDevopsAlertMessage: s__(`
SecurityConfiguration|You can quickly enable all security scanning tools by SecurityConfiguration|You can quickly enable all security scanning tools by
...@@ -145,15 +174,25 @@ export default { ...@@ -145,15 +174,25 @@ export default {
<div class="gl-text-gray-900">{{ item.name }}</div> <div class="gl-text-gray-900">{{ item.name }}</div>
<div> <div>
{{ item.description }} {{ item.description }}
<gl-link
target="_blank"
:href="item.link"
:aria-label="getFeatureDocumentationLinkLabel(item.name)"
>
{{ __('More information') }}
</gl-link>
</div> </div>
</template> </template>
<template #cell(manage)="{ item }">
<create-merge-request-button
v-if="canCreateSASTMergeRequest(item)"
:auto-devops-enabled="autoDevopsEnabled"
:endpoint="createSastMergeRequestPath"
/>
<gl-link
v-else
target="_blank"
:href="item.link"
:aria-label="getFeatureDocumentationLinkLabel(item.name)"
>
{{ s__('SecurityConfiguration|See documentation') }}
</gl-link>
</template>
</gl-table> </gl-table>
<auto-fix-settings v-if="glFeatures.securityAutoFix" v-bind="autoFixSettingsProps" /> <auto-fix-settings v-if="glFeatures.securityAutoFix" v-bind="autoFixSettingsProps" />
</article> </article>
......
<script>
import * as Sentry from '@sentry/browser';
import { GlButton } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
export default {
components: {
GlButton,
},
props: {
autoDevopsEnabled: {
type: Boolean,
required: true,
},
endpoint: {
type: String,
required: true,
},
},
data() {
return {
isCreatingMergeRequest: false,
};
},
computed: {
buttonProps() {
if (this.autoDevopsEnabled) {
return {
text: this.$options.i18n.autoDevOps,
};
}
return {
text: this.$options.i18n.noAutoDevOps,
category: 'primary',
variant: 'success',
};
},
},
methods: {
createMergeRequest() {
this.isCreatingMergeRequest = true;
return axios
.post(this.endpoint)
.then(({ data }) => {
const { filePath } = data;
if (!filePath) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('SAST merge request creation failed');
}
redirectTo(filePath);
})
.catch(error => {
this.isCreatingMergeRequest = false;
createFlash(
s__('SecurityConfiguration|An error occurred while creating the merge request.'),
);
Sentry.captureException(error);
});
},
},
i18n: {
autoDevOps: s__('SecurityConfiguration|Configure'),
noAutoDevOps: s__('SecurityConfiguration|Enable via Merge Request'),
},
};
</script>
<template>
<gl-button :loading="isCreatingMergeRequest" v-bind="buttonProps" @click="createMergeRequest">{{
buttonProps.text
}}</gl-button>
</template>
...@@ -16,6 +16,7 @@ export default function init() { ...@@ -16,6 +16,7 @@ export default function init() {
containerScanningHelpPath, containerScanningHelpPath,
dependencyScanningHelpPath, dependencyScanningHelpPath,
toggleAutofixSettingEndpoint, toggleAutofixSettingEndpoint,
createSastMergeRequestPath,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -31,6 +32,7 @@ export default function init() { ...@@ -31,6 +32,7 @@ export default function init() {
features: JSON.parse(features), features: JSON.parse(features),
helpPagePath, helpPagePath,
latestPipelinePath, latestPipelinePath,
createSastMergeRequestPath,
...parseBooleanDataAttributes(el, [ ...parseBooleanDataAttributes(el, [
'autoDevopsEnabled', 'autoDevopsEnabled',
'canEnableAutoDevops', 'canEnableAutoDevops',
......
...@@ -8,7 +8,7 @@ module Projects ...@@ -8,7 +8,7 @@ module Projects
alias_method :vulnerable, :project alias_method :vulnerable, :project
before_action :ensure_sast_configuration_enabled! before_action :ensure_sast_configuration_enabled!, except: [:create]
before_action :authorize_edit_tree!, only: [:create] before_action :authorize_edit_tree!, only: [:create]
def show def show
......
...@@ -142,6 +142,7 @@ module Projects ...@@ -142,6 +142,7 @@ module Projects
def scan(type, configured: false) def scan(type, configured: false)
{ {
type: type,
configured: configured, configured: configured,
description: self.class.localized_scan_descriptions[type], description: self.class.localized_scan_descriptions[type],
link: help_page_path(SCAN_DOCS[type]), link: help_page_path(SCAN_DOCS[type]),
......
---
title: Add "Manage" column and tweak "Status" wording in the Security Configuration page
merge_request: 36432
author:
type: changed
...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import { GlAlert, GlLink } from '@gitlab/ui'; 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 CreateMergeRequestButton from 'ee/security_configuration/components/create_merge_request_button.vue';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
const propsData = { const propsData = {
...@@ -12,6 +13,7 @@ const propsData = { ...@@ -12,6 +13,7 @@ const propsData = {
autoDevopsPath: 'http://autoDevopsPath', autoDevopsPath: 'http://autoDevopsPath',
helpPagePath: 'http://helpPagePath', helpPagePath: 'http://helpPagePath',
autoFixSettingsProps: {}, autoFixSettingsProps: {},
createSastMergeRequestPath: 'http://createSastMergeRequestPath',
}; };
describe('Security Configuration App', () => { describe('Security Configuration App', () => {
...@@ -36,20 +38,29 @@ describe('Security Configuration App', () => { ...@@ -36,20 +38,29 @@ describe('Security Configuration App', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
const generateFeatures = n => { const generateFeatures = (n, overrides = {}) => {
return [...Array(n).keys()].map(i => ({ return [...Array(n).keys()].map(i => ({
type: `scan-type-${i}`,
name: `name-feature-${i}`, name: `name-feature-${i}`,
description: `description-feature-${i}`, description: `description-feature-${i}`,
link: `link-feature-${i}`, link: `link-feature-${i}`,
configured: i % 2 === 0, configured: i % 2 === 0,
...overrides,
})); }));
}; };
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 getFeaturesRows = () => getFeaturesTable().findAll('tbody tr');
const getAlert = () => wrapper.find(GlAlert); const getAlert = () => wrapper.find(GlAlert);
const getCreateMergeRequestButton = () => wrapper.find(CreateMergeRequestButton);
const getRowCells = row => {
const [feature, status, manage] = row.findAll('td').wrappers;
return { feature, status, manage };
};
describe('header', () => { describe('header', () => {
it.each` it.each`
...@@ -131,16 +142,70 @@ describe('Security Configuration App', () => { ...@@ -131,16 +142,70 @@ describe('Security Configuration App', () => {
createComponent({ propsData: { 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 = getFeaturesRows();
expect(rows).toHaveLength(5); expect(rows).toHaveLength(5);
for (let i = 0; i < features.length; i += 1) { for (let i = 0; i < features.length; i += 1) {
const [feature, status] = rows.at(i).findAll('td').wrappers; const { feature, status, manage } = getRowCells(rows.at(i));
expect(feature.text()).toMatch(features[i].name); expect(feature.text()).toMatch(features[i].name);
expect(feature.text()).toMatch(features[i].description); expect(feature.text()).toMatch(features[i].description);
expect(feature.find(GlLink).attributes('href')).toBe(features[i].link); expect(status.text()).toMatch(features[i].configured ? 'Enabled' : 'Not enabled');
expect(status.text()).toMatch(features[i].configured ? 'Enabled' : 'Not yet enabled'); expect(manage.find(GlLink).attributes('href')).toBe(features[i].link);
} }
}); });
describe('given a feature enabled by Auto DevOps', () => {
it('displays the expected status text', () => {
const features = generateFeatures(1, { configured: true });
createComponent({ propsData: { features, autoDevopsEnabled: true } });
const { status } = getRowCells(getFeaturesRows().at(0));
expect(status.text()).toMatch('Enabled with Auto DevOps');
});
});
});
describe('enabling SAST by merge request', () => {
describe.each`
sastConfigurationByClick | gitlabCiPresent | autoDevopsEnabled | buttonExpected
${true} | ${false} | ${false} | ${true}
${true} | ${false} | ${true} | ${true}
${true} | ${true} | ${false} | ${false}
${false} | ${false} | ${false} | ${false}
`(
'given sastConfigurationByClick is $sastConfigurationByClick, gitlabCiPresent is $gitlabCiPresent, autoDevopsEnabled is $autoDevopsEnabled',
({ sastConfigurationByClick, gitlabCiPresent, autoDevopsEnabled, buttonExpected }) => {
beforeEach(() => {
const features = generateFeatures(1, { type: 'sast', configured: false });
createComponent({
propsData: { features, gitlabCiPresent, autoDevopsEnabled },
provide: { glFeatures: { sastConfigurationByClick } },
});
});
if (buttonExpected) {
it('renders the CreateMergeRequestButton component', () => {
const button = getCreateMergeRequestButton();
expect(button.exists()).toBe(true);
expect(button.props()).toMatchObject({
endpoint: propsData.createSastMergeRequestPath,
autoDevopsEnabled,
});
});
it('does not render the documentation link', () => {
const { manage } = getRowCells(getFeaturesRows().at(0));
expect(manage.contains(GlLink)).toBe(false);
});
} else {
it('does not render the CreateMergeRequestButton component', () => {
expect(getCreateMergeRequestButton().exists()).toBe(false);
});
}
},
);
}); });
}); });
import * as Sentry from '@sentry/browser';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import CreateMergeRequestButton from 'ee/security_configuration/components/create_merge_request_button.vue';
import { redirectTo } from '~/lib/utils/url_utility';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/flash.js');
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
const endpoint = '/endpoint';
const { i18n } = CreateMergeRequestButton;
const DEFAULT_BUTTON_PROPS = {
category: 'tertiary',
variant: 'default',
};
const SUCCESS_BUTTON_PROPS = {
category: 'primary',
variant: 'success',
};
const MERGE_REQUEST_PATH = '/merge_requests/new';
describe('CreateMergeRequestButton component', () => {
let axiosMock;
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(CreateMergeRequestButton, {
propsData: {
endpoint,
autoDevopsEnabled: false,
...props,
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
axiosMock.restore();
});
const findButton = () => wrapper.find(GlButton);
describe.each`
autoDevopsEnabled | buttonText | buttonProps
${false} | ${i18n.noAutoDevOps} | ${SUCCESS_BUTTON_PROPS}
${true} | ${i18n.autoDevOps} | ${DEFAULT_BUTTON_PROPS}
`(
'when autoDevopsEnabled is $autoDevopsEnabled',
({ autoDevopsEnabled, buttonText, buttonProps }) => {
beforeEach(() => {
createComponent({ autoDevopsEnabled });
});
it('uses the right button label', () => {
expect(findButton().text()).toEqual(buttonText);
});
it('passes the correct data to the button', () => {
expect(findButton().props()).toMatchObject({
loading: false,
...buttonProps,
});
});
},
);
describe('when clicking the button', () => {
describe.each`
context | filePath | statusCode | partialErrorMessage
${'a response error code'} | ${MERGE_REQUEST_PATH} | ${500} | ${'500'}
${'no filePath'} | ${''} | ${200} | ${/merge request.*fail/}
`(
'given an unsuccessful endpoint response due to $context',
({ filePath, statusCode, partialErrorMessage }) => {
beforeEach(() => {
axiosMock.onPost(endpoint).replyOnce(statusCode, { filePath });
jest.spyOn(Sentry, 'captureException').mockImplementation();
createComponent();
findButton().vm.$emit('click');
});
it('sets the loading prop to true', () => {
expect(findButton().props().loading).toBe(true);
});
describe('after async tasks', () => {
beforeEach(() => waitForPromises());
it('does not call redirectTo', () => {
expect(redirectTo).not.toHaveBeenCalled();
});
it('creates a flash message', () => {
expect(createFlash).toHaveBeenCalledWith(expect.any(String));
});
it('sends the error to Sentry', () => {
expect(Sentry.captureException.mock.calls).toMatchObject([
[{ message: expect.stringMatching(partialErrorMessage) }],
]);
});
it('sets the loading prop to false', () => {
expect(findButton().props().loading).toBe(false);
});
});
},
);
describe('given a successful endpoint response', () => {
beforeEach(() => {
axiosMock.onPost(endpoint).replyOnce(200, { filePath: MERGE_REQUEST_PATH });
jest.spyOn(Sentry, 'captureException').mockImplementation();
createComponent();
findButton().vm.$emit('click');
});
it('sets the loading prop to true', () => {
expect(findButton().props().loading).toBe(true);
});
describe('after async tasks', () => {
beforeEach(() => waitForPromises());
it('calls redirectTo', () => {
expect(redirectTo).toHaveBeenCalledWith(MERGE_REQUEST_PATH);
});
it('does not create a flash message', () => {
expect(createFlash).not.toHaveBeenCalled();
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it('keeps the loading prop set to true', () => {
// This is done for UX reasons. If the loading prop is set to false
// on success, then there's a period where the button is clickable
// again. Instead, we want the button to display a loading indicator
// for the remainder of the lifetime of the page (i.e., until the
// browser can start painting the new page it's been redirected to).
expect(findButton().props().loading).toBe(true);
});
});
});
});
});
...@@ -215,6 +215,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do ...@@ -215,6 +215,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
def security_scan(type, configured:) def security_scan(type, configured:)
{ {
"type" => type.to_s,
"configured" => configured, "configured" => configured,
"description" => described_class.localized_scan_descriptions[type], "description" => described_class.localized_scan_descriptions[type],
"link" => help_page_path(described_class::SCAN_DOCS[type]), "link" => help_page_path(described_class::SCAN_DOCS[type]),
......
...@@ -20567,18 +20567,36 @@ msgstr "" ...@@ -20567,18 +20567,36 @@ msgstr ""
msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})"
msgstr "" msgstr ""
msgid "SecurityConfiguration|An error occurred while creating the merge request."
msgstr ""
msgid "SecurityConfiguration|Configure"
msgstr ""
msgid "SecurityConfiguration|Enable via Merge Request"
msgstr ""
msgid "SecurityConfiguration|Enabled" msgid "SecurityConfiguration|Enabled"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Enabled with Auto DevOps"
msgstr ""
msgid "SecurityConfiguration|Feature documentation for %{featureName}" msgid "SecurityConfiguration|Feature documentation for %{featureName}"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Not yet enabled" msgid "SecurityConfiguration|Manage"
msgstr ""
msgid "SecurityConfiguration|Not enabled"
msgstr "" msgstr ""
msgid "SecurityConfiguration|Security Control" msgid "SecurityConfiguration|Security Control"
msgstr "" msgstr ""
msgid "SecurityConfiguration|See documentation"
msgstr ""
msgid "SecurityConfiguration|Status" msgid "SecurityConfiguration|Status"
msgstr "" msgstr ""
......
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