Commit e21bf6a2 authored by Mark Florian's avatar Mark Florian

Merge branch 'create-enable-feature-via-mr' into 'master'

Dependency Scanning - MVC to enable within the UI - Frontend [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57496
parents 9954291a ddd78143
......@@ -5,3 +5,4 @@ filenames:
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
- ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql
- app/assets/javascripts/repository/queries/blob_info.query.graphql
- ee/app/assets/javascripts/security_configuration/graphql/configure_dependency_scanning.mutation.graphql
<script>
import { GlLink, GlTable } from '@gitlab/ui';
import { GlAlert, GlLink, GlTable } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import FeatureStatus from './feature_status.vue';
import ManageFeature from './manage_feature.vue';
......@@ -9,6 +9,7 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`;
export default {
components: {
GlAlert,
GlLink,
GlTable,
FeatureStatus,
......@@ -35,12 +36,20 @@ export default {
default: '',
},
},
data() {
return {
errorMessage: '',
};
},
methods: {
getFeatureDocumentationLinkLabel(item) {
return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), {
return sprintf(this.$options.i18n.docsLinkLabel, {
featureName: item.name,
});
},
onError(value) {
this.errorMessage = value;
},
},
fields: [
{
......@@ -59,42 +68,51 @@ export default {
thClass,
},
],
i18n: {
docsLinkLabel: s__('SecurityConfiguration|Feature documentation for %{featureName}'),
docsLinkText: s__('SecurityConfiguration|More information'),
},
};
</script>
<template>
<gl-table
:items="features"
:fields="$options.fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
>
<template #cell(description)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div>
<div>
{{ item.description }}
<gl-link
target="_blank"
:href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ s__('SecurityConfiguration|More information') }}
</gl-link>
</div>
</template>
<div>
<gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
<gl-table
:items="features"
:fields="$options.fields"
stacked="md"
:tbody-tr-attr="{ 'data-testid': 'security-scanner-row' }"
>
<template #cell(description)="{ item }">
<div class="gl-text-gray-900">{{ item.name }}</div>
<div>
{{ item.description }}
<gl-link
target="_blank"
:href="item.helpPath"
:aria-label="getFeatureDocumentationLinkLabel(item)"
>
{{ $options.i18n.docsLinkText }}
</gl-link>
</div>
</template>
<template #cell(status)="{ item }">
<feature-status
:feature="item"
:gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled"
:data-qa-selector="`${item.type}_status`"
/>
</template>
<template #cell(status)="{ item }">
<feature-status
:feature="item"
:gitlab-ci-present="gitlabCiPresent"
:gitlab-ci-history-path="gitlabCiHistoryPath"
:auto-devops-enabled="autoDevopsEnabled"
:data-qa-selector="`${item.type}_status`"
/>
</template>
<template #cell(manage)="{ item }">
<manage-feature :feature="item" />
</template>
</gl-table>
<template #cell(manage)="{ item }">
<manage-feature :feature="item" @error="onError" />
</template>
</gl-table>
</div>
</template>
import { s__ } from '~/locale';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import configureDependencyScanningMutation from '../graphql/configure_dependency_scanning.mutation.graphql';
export const SMALL = 'SMALL';
export const MEDIUM = 'MEDIUM';
......@@ -15,3 +17,10 @@ export const SCHEMA_TO_PROP_SIZE_MAP = {
export const CUSTOM_VALUE_MESSAGE = s__(
"SecurityConfiguration|Using custom settings. You won't receive automatic updates on this variable. %{anchorStart}Restore to default%{anchorEnd}",
);
export const featureToMutationMap = {
[REPORT_TYPE_DEPENDENCY_SCANNING]: {
type: 'configureDependencyScanning',
mutation: configureDependencyScanningMutation,
},
};
<script>
import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import ManageDastProfiles from './manage_dast_profiles.vue';
import ManageGeneric from './manage_generic.vue';
import ManageViaMr from './manage_via_mr.vue';
const scannerComponentMap = {
[REPORT_TYPE_DAST_PROFILES]: ManageDastProfiles,
[REPORT_TYPE_DEPENDENCY_SCANNING]: ManageViaMr,
};
export default {
mixins: [glFeatureFlagMixin()],
props: propsUnion([ManageGeneric, ...Object.values(scannerComponentMap)]),
computed: {
filteredScannerComponentMap() {
const scannerComponentMapCopy = { ...scannerComponentMap };
if (!this.glFeatures.secDependencyScanningUiEnable) {
delete scannerComponentMapCopy[REPORT_TYPE_DEPENDENCY_SCANNING];
}
return scannerComponentMapCopy;
},
manageComponent() {
return scannerComponentMap[this.feature.type] ?? ManageGeneric;
return this.filteredScannerComponentMap[this.feature.type] ?? ManageGeneric;
},
},
};
</script>
<template>
<component :is="manageComponent" v-bind="$props" />
<component :is="manageComponent" v-bind="$props" @error="$emit('error', $event)" />
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../graphql/provider';
import { featureToMutationMap } from './constants';
export default {
apolloProvider,
components: {
GlButton,
},
inject: {
projectPath: {
from: 'projectPath',
default: '',
},
},
props: {
feature: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
featureSettings() {
return featureToMutationMap[this.feature.type];
},
},
methods: {
async mutate() {
this.isLoading = true;
try {
const { data } = await this.$apollo.mutate({
mutation: this.featureSettings.mutation,
variables: {
fullPath: this.projectPath,
},
});
const { errors, successPath } = data[this.featureSettings.type];
if (errors.length > 0) {
throw new Error(errors[0]);
}
if (!successPath) {
throw new Error(
sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
);
}
redirectTo(successPath);
} catch (e) {
this.$emit('error', e.message);
this.isLoading = false;
}
},
},
i18n: {
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
noSuccessPathError: s__(
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
),
},
};
</script>
<template>
<gl-button
v-if="!feature.configured"
:loading="isLoading"
variant="success"
category="secondary"
@click="mutate"
>{{ $options.i18n.buttonLabel }}</gl-button
>
</template>
mutation configureDependencyScanning($fullPath: ID!) {
configureDependencyScanning(fullPath: $fullPath) {
successPath
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(),
});
......@@ -15,6 +15,7 @@ module EE
before_action only: [:show] do
push_frontend_feature_flag(:security_auto_fix, project, default_enabled: false)
push_frontend_feature_flag(:api_fuzzing_configuration_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:sec_dependency_scanning_ui_enable, project, default_enabled: :yaml)
end
before_action only: [:auto_fix] do
......
---
name: sec_dependency_scanning_ui_enable
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57496
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326005
milestone: '13.11'
type: development
group: group::composition analysis
default_enabled: false
const buildConfigureDependencyScanningMock = ({
successPath = 'testSuccessPath',
errors = [],
} = {}) => ({
data: {
configureDependencyScanning: {
successPath,
errors,
__typename: 'ConfigureDependencyScanningPayload',
},
},
});
export const configureDependencyScanningSuccess = buildConfigureDependencyScanningMock();
export const configureDependencyScanningNoSuccessPath = buildConfigureDependencyScanningMock({
successPath: '',
});
export const configureDependencyScanningError = buildConfigureDependencyScanningMock({
errors: ['foo'],
});
import { GlLink } from '@gitlab/ui';
import { GlAlert, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import ConfigurationTable from 'ee/security_configuration/components/configuration_table.vue';
import FeatureStatus from 'ee/security_configuration/components/feature_status.vue';
......@@ -41,6 +41,7 @@ describe('ConfigurationTable component', () => {
});
};
const getTable = () => wrapper.find('table');
const getRows = () => wrapper.findAll('tbody tr');
const getRowCells = (row) => {
const [description, status, manage] = row.findAll('td').wrappers;
......@@ -53,8 +54,7 @@ describe('ConfigurationTable component', () => {
it.each(mockFeatures)('renders the feature %p correctly', (feature) => {
createComponent({ features: [feature] });
expect(wrapper.classes('b-table-stacked-md')).toBeTruthy();
expect(getTable().classes('b-table-stacked-md')).toBe(true);
const rows = getRows();
expect(rows).toHaveLength(1);
......@@ -70,4 +70,16 @@ describe('ConfigurationTable component', () => {
expect(manage.find(ManageFeature).props()).toEqual({ feature });
expect(description.find(GlLink).attributes('href')).toBe(feature.helpPath);
});
it('catches errors and displays them in an alert', async () => {
const error = 'error message';
createComponent({ features: mockFeatures });
const firstRow = getRows().at(0);
await firstRow.findComponent(ManageFeature).vm.$emit('error', error);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(error);
});
});
......@@ -2,7 +2,11 @@ import { shallowMount } from '@vue/test-utils';
import ManageDastProfiles from 'ee/security_configuration/components/manage_dast_profiles.vue';
import ManageFeature from 'ee/security_configuration/components/manage_feature.vue';
import ManageGeneric from 'ee/security_configuration/components/manage_generic.vue';
import { REPORT_TYPE_DAST_PROFILES } from '~/vue_shared/security_reports/constants';
import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
} from '~/vue_shared/security_reports/constants';
import { generateFeatures } from './helpers';
const attrs = {
......@@ -13,7 +17,14 @@ describe('ManageFeature component', () => {
let wrapper;
const createComponent = (options) => {
wrapper = shallowMount(ManageFeature, options);
wrapper = shallowMount(ManageFeature, {
provide: {
glFeatures: {
secDependencyScanningUiEnable: true,
},
},
...options,
});
};
afterEach(() => {
......@@ -29,12 +40,20 @@ describe('ManageFeature component', () => {
it('passes through attributes to the expected component', () => {
expect(wrapper.attributes()).toMatchObject(attrs);
});
it('re-emits caught errors', () => {
const component = wrapper.findComponent(ManageGeneric);
component.vm.$emit('error', 'testerror');
expect(wrapper.emitted('error')).toEqual([['testerror']]);
});
});
describe.each`
type | expectedComponent
${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles}
${'foo'} | ${ManageGeneric}
type | expectedComponent
${REPORT_TYPE_DAST_PROFILES} | ${ManageDastProfiles}
${REPORT_TYPE_DEPENDENCY_SCANNING} | ${ManageViaMr}
${'foo'} | ${ManageGeneric}
`('given a $type feature', ({ type, expectedComponent }) => {
let feature;
let component;
......@@ -43,7 +62,6 @@ describe('ManageFeature component', () => {
[feature] = generateFeatures(1, { type });
createComponent({ propsData: { feature } });
component = wrapper.findComponent(expectedComponent);
});
......@@ -55,4 +73,21 @@ describe('ManageFeature component', () => {
expect(component.props()).toEqual({ feature });
});
});
it.each`
type | featureFlag
${REPORT_TYPE_DEPENDENCY_SCANNING} | ${'secDependencyScanningUiEnable'}
`('renders generic component for $type if $featureFlag is disabled', ({ type, featureFlag }) => {
const [feature] = generateFeatures(1, { type });
createComponent({
propsData: { feature },
provide: {
glFeatures: {
[featureFlag]: false,
},
},
});
expect(wrapper.findComponent(ManageGeneric).exists()).toBe(true);
});
});
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import ManageViaMr from 'ee/security_configuration/components/manage_via_mr.vue';
import configureDependencyScanningMutation from 'ee/security_configuration/graphql/configure_dependency_scanning.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { redirectTo } from '~/lib/utils/url_utility';
import { REPORT_TYPE_DEPENDENCY_SCANNING } from '~/vue_shared/security_reports/constants';
import {
configureDependencyScanningSuccess,
configureDependencyScanningNoSuccessPath,
configureDependencyScanningError,
} from './apollo_mocks';
jest.mock('~/lib/utils/url_utility');
Vue.use(VueApollo);
describe('ManageViaMr component', () => {
let wrapper;
const findButton = () => wrapper.findComponent(GlButton);
const successHandler = async () => configureDependencyScanningSuccess;
const noSuccessPathHandler = async () => configureDependencyScanningNoSuccessPath;
const errorHandler = async () => configureDependencyScanningError;
const pendingHandler = () => new Promise(() => {});
function createMockApolloProvider(handler) {
const requestHandlers = [[configureDependencyScanningMutation, handler]];
return createMockApollo(requestHandlers);
}
function createComponent({ mockApollo, isFeatureConfigured = false } = {}) {
wrapper = extendedWrapper(
mount(ManageViaMr, {
apolloProvider: mockApollo,
propsData: {
feature: {
name: 'Dependency Scanning',
configured: isFeatureConfigured,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
},
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
describe('when feature is configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: true });
});
it('it does not render a button', () => {
expect(findButton().exists()).toBe(false);
});
});
describe('when feature is not configured', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo, isFeatureConfigured: false });
});
it('it does render a button', () => {
expect(findButton().exists()).toBe(true);
});
});
describe('given a pending response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(pendingHandler);
createComponent({ mockApollo });
});
it('renders spinner correctly', async () => {
const button = findButton();
expect(button.props('loading')).toBe(false);
await button.trigger('click');
expect(button.props('loading')).toBe(true);
});
});
describe('given a successful response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo });
});
it('should call redirect helper with correct value', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(redirectTo).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
// 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);
});
});
describe.each`
handler | message
${noSuccessPathHandler} | ${'Dependency Scanning merge request creation mutation failed'}
${errorHandler} | ${'foo'}
`('given an error response', ({ handler, message }) => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(handler);
createComponent({ mockApollo });
});
it('should catch and emit error', async () => {
await wrapper.trigger('click');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[message]]);
expect(findButton().props('loading')).toBe(false);
});
});
});
......@@ -27062,6 +27062,9 @@ msgstr ""
msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed"
msgstr ""
msgid "SecurityConfiguration|An error occurred while creating the merge request."
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