Commit fc8a032e authored by Mark Florian's avatar Mark Florian Committed by Natalia Tepluhina

Implement analyzers section in SAST Configuration

This integrates various pieces of ground work:
 - [Migrating a POST request to a GraphQL mutation][gql]
 - [Creating an expandable container component][exp]
 - [Creating an AnalyzerConfiguration component][ac1]
 - [Extending the AnalyzerConfiguration component][ac2]

This is implemented behind the `sast_configuration_ui_analyzers` feature
flag, since the necessary backend changes are not yet in place. A future
iteration will enable or remove the feature flag.

Other changes include:
 - Explicitly pass the disabled prop to child fields, so they can hide
   the custom value message when disabled
 - Relax the analyzer entity prop validator, since the description is
   nullable, and the template already handles this

Addresses https://gitlab.com/gitlab-org/gitlab/-/issues/238602, part of
https://gitlab.com/groups/gitlab-org/-/epics/3635.

[gql]: https://gitlab.com/gitlab-org/gitlab/-/issues/227575
[exp]: https://gitlab.com/gitlab-org/gitlab/-/issues/233521
[ac1]: https://gitlab.com/gitlab-org/gitlab/-/issues/238600
[ac2]: https://gitlab.com/gitlab-org/gitlab/-/issues/238603
parent 58f318e0
<script> <script>
import { GlFormCheckbox, GlFormGroup } from '@gitlab/ui'; import { GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DynamicFields from './dynamic_fields.vue'; import DynamicFields from './dynamic_fields.vue';
import { isValidAnalyzerEntity } from './utils'; import { isValidAnalyzerEntity } from './utils';
...@@ -9,6 +10,7 @@ export default { ...@@ -9,6 +10,7 @@ export default {
GlFormCheckbox, GlFormCheckbox,
DynamicFields, DynamicFields,
}, },
mixins: [glFeatureFlagsMixin()],
model: { model: {
prop: 'entity', prop: 'entity',
event: 'input', event: 'input',
...@@ -22,8 +24,11 @@ export default { ...@@ -22,8 +24,11 @@ export default {
}, },
}, },
computed: { computed: {
hasConfiguration() { variables() {
return this.entity.configuration?.length > 0; return this.entity.variables?.nodes ?? [];
},
hasVariables() {
return this.variables.length > 0;
}, },
}, },
methods: { methods: {
...@@ -31,8 +36,8 @@ export default { ...@@ -31,8 +36,8 @@ export default {
const entity = { ...this.entity, enabled }; const entity = { ...this.entity, enabled };
this.$emit('input', entity); this.$emit('input', entity);
}, },
onConfigurationUpdate(configuration) { onVariablesUpdate(variables) {
const entity = { ...this.entity, configuration }; const entity = { ...this.entity, variables: { nodes: variables } };
this.$emit('input', entity); this.$emit('input', entity);
}, },
}, },
...@@ -47,11 +52,11 @@ export default { ...@@ -47,11 +52,11 @@ export default {
</gl-form-checkbox> </gl-form-checkbox>
<dynamic-fields <dynamic-fields
v-if="hasConfiguration" v-if="hasVariables"
:disabled="!entity.enabled" :disabled="!entity.enabled"
class="gl-ml-6" class="gl-ml-6 gl-mb-0"
:entities="entity.configuration" :entities="variables"
@input="onConfigurationUpdate" @input="onVariablesUpdate"
/> />
</gl-form-group> </gl-form-group>
</template> </template>
<script> <script>
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import sastCiConfigurationQuery from '../graphql/sast_ci_configuration.query.graphql'; import sastCiConfigurationQuery from '../graphql/sast_ci_configuration.query.graphql';
import sastCiConfigurationWithAnalyzersQuery from '../graphql/sast_ci_configuration_with_analyzers.query.graphql';
import ConfigurationForm from './configuration_form.vue'; import ConfigurationForm from './configuration_form.vue';
export default { export default {
...@@ -12,6 +14,7 @@ export default { ...@@ -12,6 +14,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlSprintf, GlSprintf,
}, },
mixins: [glFeatureFlagsMixin()],
inject: { inject: {
sastDocumentationPath: { sastDocumentationPath: {
from: 'sastDocumentationPath', from: 'sastDocumentationPath',
...@@ -24,7 +27,11 @@ export default { ...@@ -24,7 +27,11 @@ export default {
}, },
apollo: { apollo: {
sastCiConfiguration: { sastCiConfiguration: {
query: sastCiConfigurationQuery, query() {
return this.glFeatures.sastConfigurationUiAnalyzers
? sastCiConfigurationWithAnalyzersQuery
: sastCiConfigurationQuery;
},
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.projectPath,
......
<script> <script>
import { GlAlert, GlButton } from '@gitlab/ui'; import { GlAlert, GlButton, GlIcon, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AnalyzerConfiguration from './analyzer_configuration.vue';
import DynamicFields from './dynamic_fields.vue'; import DynamicFields from './dynamic_fields.vue';
import ExpandableSection from './expandable_section.vue';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import { toSastCiConfigurationEntityInput } from './utils'; import {
toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from './utils';
export default { export default {
components: { components: {
AnalyzerConfiguration,
DynamicFields, DynamicFields,
ExpandableSection,
GlAlert, GlAlert,
GlButton, GlButton,
GlIcon,
GlLink,
}, },
mixins: [glFeatureFlagsMixin()],
inject: { inject: {
createSastMergeRequestPath: { createSastMergeRequestPath: {
from: 'createSastMergeRequestPath', from: 'createSastMergeRequestPath',
default: '', default: '',
}, },
sastAnalyzersDocumentationPath: {
from: 'sastAnalyzersDocumentationPath',
default: '',
},
securityConfigurationPath: { securityConfigurationPath: {
from: 'securityConfigurationPath', from: 'securityConfigurationPath',
default: '', default: '',
...@@ -39,10 +54,20 @@ export default { ...@@ -39,10 +54,20 @@ export default {
return { return {
globalConfiguration: cloneDeep(this.sastCiConfiguration.global.nodes), globalConfiguration: cloneDeep(this.sastCiConfiguration.global.nodes),
pipelineConfiguration: cloneDeep(this.sastCiConfiguration.pipeline.nodes), pipelineConfiguration: cloneDeep(this.sastCiConfiguration.pipeline.nodes),
analyzersConfiguration: this.glFeatures.sastConfigurationUiAnalyzers
? cloneDeep(this.sastCiConfiguration.analyzers.nodes)
: [],
hasSubmissionError: false, hasSubmissionError: false,
isSubmitting: false, isSubmitting: false,
}; };
}, },
computed: {
shouldRenderAnalyzersSection() {
return Boolean(
this.glFeatures.sastConfigurationUiAnalyzers && this.analyzersConfiguration.length > 0,
);
},
},
methods: { methods: {
onSubmit() { onSubmit() {
this.isSubmitting = true; this.isSubmitting = true;
...@@ -75,10 +100,26 @@ export default { ...@@ -75,10 +100,26 @@ export default {
}); });
}, },
getMutationConfiguration() { getMutationConfiguration() {
return { const configuration = {
global: this.globalConfiguration.map(toSastCiConfigurationEntityInput), global: this.globalConfiguration.map(toSastCiConfigurationEntityInput),
pipeline: this.pipelineConfiguration.map(toSastCiConfigurationEntityInput), pipeline: this.pipelineConfiguration.map(toSastCiConfigurationEntityInput),
}; };
if (this.glFeatures.sastConfigurationUiAnalyzers) {
configuration.analyzers = this.analyzersConfiguration.map(
toSastCiConfigurationAnalyzerEntityInput,
);
}
return configuration;
},
onAnalyzerChange(name, updatedAnalyzer) {
const index = this.analyzersConfiguration.findIndex(analyzer => analyzer.name === name);
if (index === -1) {
return;
}
this.analyzersConfiguration.splice(index, 1, updatedAnalyzer);
}, },
}, },
i18n: { i18n: {
...@@ -87,6 +128,13 @@ export default { ...@@ -87,6 +128,13 @@ export default {
), ),
submitButton: s__('SecurityConfiguration|Create Merge Request'), submitButton: s__('SecurityConfiguration|Create Merge Request'),
cancelButton: __('Cancel'), cancelButton: __('Cancel'),
help: __('Help'),
analyzersHeading: s__('SecurityConfiguration|SAST Analyzers'),
analyzersSubHeading: s__(
`SecurityConfiguration|By default, all analyzers are applied in order to
cover all languages across your project, and only run if the language is
detected in the Merge Request.`,
),
}, },
}; };
</script> </script>
...@@ -96,7 +144,35 @@ export default { ...@@ -96,7 +144,35 @@ export default {
<dynamic-fields v-model="globalConfiguration" class="gl-m-0" /> <dynamic-fields v-model="globalConfiguration" class="gl-m-0" />
<dynamic-fields v-model="pipelineConfiguration" class="gl-m-0" /> <dynamic-fields v-model="pipelineConfiguration" class="gl-m-0" />
<hr /> <expandable-section
v-if="shouldRenderAnalyzersSection"
class="gl-mb-5"
data-testid="analyzers-section"
>
<template #heading>
{{ $options.i18n.analyzersHeading }}
<gl-link
target="_blank"
:href="sastAnalyzersDocumentationPath"
:aria-label="$options.i18n.help"
>
<gl-icon name="question" />
</gl-link>
</template>
<template #subheading>
{{ $options.i18n.analyzersSubHeading }}
</template>
<analyzer-configuration
v-for="analyzer in analyzersConfiguration"
:key="analyzer.name"
:entity="analyzer"
@input="onAnalyzerChange(analyzer.name, $event)"
/>
</expandable-section>
<hr v-else />
<gl-alert v-if="hasSubmissionError" class="gl-mb-5" variant="danger" :dismissible="false">{{ <gl-alert v-if="hasSubmissionError" class="gl-mb-5" variant="danger" :dismissible="false">{{
$options.i18n.submissionError $options.i18n.submissionError
......
...@@ -60,6 +60,7 @@ export default { ...@@ -60,6 +60,7 @@ export default {
v-for="entity in entities" v-for="entity in entities"
ref="fields" ref="fields"
:key="entity.field" :key="entity.field"
:disabled="disabled"
v-bind="entity" v-bind="entity"
@input="onInput(entity.field, $event)" @input="onInput(entity.field, $event)"
/> />
......
...@@ -45,10 +45,15 @@ export default { ...@@ -45,10 +45,15 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
disabled: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
isCustomValue() { showCustomValueMessage() {
return this.value !== this.defaultValue; return !this.disabled && this.value !== this.defaultValue;
}, },
inputSize() { inputSize() {
return SCHEMA_TO_PROP_SIZE_MAP[this.size]; return SCHEMA_TO_PROP_SIZE_MAP[this.size];
...@@ -72,9 +77,15 @@ export default { ...@@ -72,9 +77,15 @@ export default {
<gl-form-text class="gl-mt-3">{{ description }}</gl-form-text> <gl-form-text class="gl-mt-3">{{ description }}</gl-form-text>
</template> </template>
<gl-form-input :id="field" :size="inputSize" :value="value" @input="$emit('input', $event)" /> <gl-form-input
:id="field"
:size="inputSize"
:value="value"
:disabled="disabled"
@input="$emit('input', $event)"
/>
<template v-if="isCustomValue" #description> <template v-if="showCustomValueMessage" #description>
<gl-sprintf :message="$options.i18n.CUSTOM_VALUE_MESSAGE"> <gl-sprintf :message="$options.i18n.CUSTOM_VALUE_MESSAGE">
<template #anchor="{ content }"> <template #anchor="{ content }">
<gl-link @click="resetToDefaultValue" v-text="content" /> <gl-link @click="resetToDefaultValue" v-text="content" />
......
...@@ -23,9 +23,9 @@ export const isValidAnalyzerEntity = object => { ...@@ -23,9 +23,9 @@ export const isValidAnalyzerEntity = object => {
return false; return false;
} }
const { name, label, description, enabled } = object; const { name, label, enabled } = object;
return isString(name) && isString(label) && isString(description) && isBoolean(enabled); return isString(name) && isString(label) && isBoolean(enabled);
}; };
/** /**
...@@ -39,3 +39,20 @@ export const toSastCiConfigurationEntityInput = ({ field, defaultValue, value }) ...@@ -39,3 +39,20 @@ export const toSastCiConfigurationEntityInput = ({ field, defaultValue, value })
defaultValue, defaultValue,
value, value,
}); });
/**
* Given a SastCiConfigurationAnalyzersEntity, returns
* a SastCiConfigurationAnalyzerEntityInput suitable for use in the
* configureSast GraphQL mutation.
* @param {SastCiConfigurationAnalyzersEntity}
* @returns {SastCiConfigurationAnalyzerEntityInput}
*/
export const toSastCiConfigurationAnalyzerEntityInput = ({ name, enabled, variables }) => {
const entity = { name, enabled };
if (enabled && variables) {
entity.variables = variables.nodes.map(toSastCiConfigurationEntityInput);
}
return entity;
};
#import "./sast_ci_configuration_entity.fragment.graphql"
fragment SastCiConfigurationFragment on SastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
#import "./sast_ci_configuration_entity.fragment.graphql" #import "./sast_ci_configuration.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) { query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
sastCiConfiguration { sastCiConfiguration {
global { ...SastCiConfigurationFragment
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
} }
} }
} }
#import "./sast_ci_configuration.fragment.graphql"
#import "./sast_ci_configuration_entity.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
...SastCiConfigurationFragment
analyzers {
nodes {
description
enabled
label
name
variables {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
}
}
}
}
...@@ -20,6 +20,7 @@ export default function init() { ...@@ -20,6 +20,7 @@ export default function init() {
securityConfigurationPath, securityConfigurationPath,
createSastMergeRequestPath, createSastMergeRequestPath,
projectPath, projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath, sastDocumentationPath,
} = el.dataset; } = el.dataset;
...@@ -30,6 +31,7 @@ export default function init() { ...@@ -30,6 +31,7 @@ export default function init() {
securityConfigurationPath, securityConfigurationPath,
createSastMergeRequestPath, createSastMergeRequestPath,
projectPath, projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath, sastDocumentationPath,
}, },
render(createElement) { render(createElement) {
......
...@@ -11,6 +11,10 @@ module Projects ...@@ -11,6 +11,10 @@ module Projects
before_action :ensure_sast_configuration_enabled!, except: [:create] before_action :ensure_sast_configuration_enabled!, except: [:create]
before_action :authorize_edit_tree!, only: [:create] before_action :authorize_edit_tree!, only: [:create]
before_action only: [:show] do
push_frontend_feature_flag(:sast_configuration_ui_analyzers, project)
end
def show def show
end end
......
...@@ -5,6 +5,7 @@ module Projects::Security::SastConfigurationHelper ...@@ -5,6 +5,7 @@ module Projects::Security::SastConfigurationHelper
{ {
create_sast_merge_request_path: project_security_configuration_sast_path(project), create_sast_merge_request_path: project_security_configuration_sast_path(project),
project_path: project.full_path, project_path: project.full_path,
sast_analyzers_documentation_path: help_page_path('user/application_security/sast/analyzers'),
sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration'), sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration'),
security_configuration_path: project_security_configuration_path(project) security_configuration_path: project_security_configuration_path(project)
} }
......
---
name: sast_configuration_ui_analyzers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42214
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238602
group: group::static analysis
type: development
default_enabled: false
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import AnalyzerConfiguration from 'ee/security_configuration/sast/components/analyzer_configuration.vue'; import AnalyzerConfiguration from 'ee/security_configuration/sast/components/analyzer_configuration.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue'; import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeAnalyzerEntities, makeEntities, makeSastCiConfiguration } from './helpers';
describe('AnalyzerConfiguration component', () => { describe('AnalyzerConfiguration component', () => {
let wrapper; let wrapper;
let entity;
const entity = {
name: 'name',
label: 'label',
description: 'description',
enabled: false,
};
const createComponent = ({ props = {} } = {}) => { const createComponent = ({ props = {} } = {}) => {
wrapper = mount(AnalyzerConfiguration, { wrapper = mount(AnalyzerConfiguration, {
...@@ -23,6 +18,10 @@ describe('AnalyzerConfiguration component', () => { ...@@ -23,6 +18,10 @@ describe('AnalyzerConfiguration component', () => {
const findInputElement = () => wrapper.find('input[type="checkbox"]'); const findInputElement = () => wrapper.find('input[type="checkbox"]');
const findDynamicFields = () => wrapper.find(DynamicFields); const findDynamicFields = () => wrapper.find(DynamicFields);
beforeEach(() => {
[entity] = makeAnalyzerEntities(1);
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -69,8 +68,8 @@ describe('AnalyzerConfiguration component', () => { ...@@ -69,8 +68,8 @@ describe('AnalyzerConfiguration component', () => {
}); });
}); });
describe('configuration form', () => { describe('child variables', () => {
describe('when there are no SastCiConfigurationEntity', () => { describe('when there are no SastCiConfigurationEntity child variables', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: { entity }, props: { entity },
...@@ -82,45 +81,36 @@ describe('AnalyzerConfiguration component', () => { ...@@ -82,45 +81,36 @@ describe('AnalyzerConfiguration component', () => {
}); });
}); });
describe('when there are one or more SastCiConfigurationEntity', () => { describe('when there are one or more SastCiConfigurationEntity child variables', () => {
const analyzerEntity = { let newEntities;
...entity,
enabled: false,
configuration: [
{
defaultValue: 'defaultVal',
description: 'desc',
field: 'field',
type: 'string',
value: 'val',
label: 'label',
},
],
};
beforeEach(() => { beforeEach(() => {
[entity] = makeSastCiConfiguration().analyzers.nodes;
createComponent({ createComponent({
props: { entity: analyzerEntity }, props: { entity },
}); });
}); });
it('it renders the nested dynamic forms', () => { it('it renders the nested DynamicFields component', () => {
expect(findDynamicFields().exists()).toBe(true); expect(findDynamicFields().exists()).toBe(true);
}); });
it('it emits an input event when dynamic form fields emits an input event', () => { it('it emits an input event when DynamicFields emits an input event', () => {
findDynamicFields().vm.$emit('input', analyzerEntity.configuration); newEntities = makeEntities(1, { field: 'new field' });
findDynamicFields().vm.$emit('input', newEntities);
const [[payload]] = wrapper.emitted('input'); expect(wrapper.emitted('input')).toEqual([
expect(payload).toEqual(analyzerEntity); [{ ...entity, variables: { nodes: newEntities } }],
]);
}); });
it('passes the disabled prop to dynamic fields component', () => { it('passes the disabled prop to DynamicFields component', () => {
expect(findDynamicFields().props('disabled')).toBe(!analyzerEntity.enabled); expect(findDynamicFields().props('disabled')).toBe(!entity.enabled);
}); });
it('passes the entities prop to the dynamic fields component', () => { it('passes the entities prop to the DynamicFields component', () => {
expect(findDynamicFields().props('entities')).toBe(analyzerEntity.configuration); expect(findDynamicFields().props('entities')).toBe(entity.variables.nodes);
}); });
}); });
}); });
......
import { merge } from 'lodash';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AnalyzerConfiguration from 'ee/security_configuration/sast/components/analyzer_configuration.vue';
import ConfigurationForm from 'ee/security_configuration/sast/components/configuration_form.vue'; import ConfigurationForm from 'ee/security_configuration/sast/components/configuration_form.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue'; import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import ExpandableSection from 'ee/security_configuration/sast/components/expandable_section.vue';
import configureSastMutation from 'ee/security_configuration/sast/graphql/configure_sast.mutation.graphql'; import configureSastMutation from 'ee/security_configuration/sast/graphql/configure_sast.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { makeEntities, makeSastCiConfiguration } from './helpers'; import { makeEntities, makeSastCiConfiguration } from './helpers';
...@@ -12,6 +15,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -12,6 +15,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
})); }));
const projectPath = 'group/project'; const projectPath = 'group/project';
const sastAnalyzersDocumentationPath = '/help/sast/analyzers';
const securityConfigurationPath = '/security/configuration'; const securityConfigurationPath = '/security/configuration';
const newMergeRequestPath = '/merge_request/new'; const newMergeRequestPath = '/merge_request/new';
...@@ -24,32 +28,39 @@ describe('ConfigurationForm component', () => { ...@@ -24,32 +28,39 @@ describe('ConfigurationForm component', () => {
pendingPromiseResolvers.forEach(resolve => resolve()); pendingPromiseResolvers.forEach(resolve => resolve());
}; };
const createComponent = ({ mutationResult } = {}) => { const createComponent = ({ mutationResult, ...options } = {}) => {
sastCiConfiguration = makeSastCiConfiguration(); sastCiConfiguration = makeSastCiConfiguration();
wrapper = shallowMount(ConfigurationForm, { wrapper = shallowMount(
provide: { ConfigurationForm,
projectPath, merge(
securityConfigurationPath, {
}, provide: {
propsData: { projectPath,
sastCiConfiguration, securityConfigurationPath,
}, sastAnalyzersDocumentationPath,
mocks: { },
$apollo: { propsData: {
mutate: jest.fn( sastCiConfiguration,
() => },
new Promise(resolve => { mocks: {
pendingPromiseResolvers.push(() => $apollo: {
resolve({ mutate: jest.fn(
data: { configureSast: mutationResult }, () =>
new Promise(resolve => {
pendingPromiseResolvers.push(() =>
resolve({
data: { configureSast: mutationResult },
}),
);
}), }),
); ),
}), },
), },
}, },
}, options,
}); ),
);
}; };
const findForm = () => wrapper.find('form'); const findForm = () => wrapper.find('form');
...@@ -57,8 +68,10 @@ describe('ConfigurationForm component', () => { ...@@ -57,8 +68,10 @@ describe('ConfigurationForm component', () => {
const findErrorAlert = () => wrapper.find(GlAlert); const findErrorAlert = () => wrapper.find(GlAlert);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
const findDynamicFieldsComponents = () => wrapper.findAll(DynamicFields); const findDynamicFieldsComponents = () => wrapper.findAll(DynamicFields);
const findAnalyzerConfigurations = () => wrapper.findAll(AnalyzerConfiguration);
const findAnalyzersSection = () => wrapper.find('[data-testid="analyzers-section"]');
const expectPayloadForEntities = () => { const expectPayloadForEntities = ({ withAnalyzers = false } = {}) => {
const expectedPayload = { const expectedPayload = {
mutation: configureSastMutation, mutation: configureSastMutation,
variables: { variables: {
...@@ -84,6 +97,22 @@ describe('ConfigurationForm component', () => { ...@@ -84,6 +97,22 @@ describe('ConfigurationForm component', () => {
}, },
}; };
if (withAnalyzers) {
expectedPayload.variables.input.configuration.analyzers = [
{
name: 'nameValue0',
enabled: true,
variables: [
{
field: 'field2',
defaultValue: 'defaultValue2',
value: 'value2',
},
],
},
];
}
expect(wrapper.vm.$apollo.mutate.mock.calls).toEqual([[expectedPayload]]); expect(wrapper.vm.$apollo.mutate.mock.calls).toEqual([[expectedPayload]]);
}; };
...@@ -132,109 +161,198 @@ describe('ConfigurationForm component', () => { ...@@ -132,109 +161,198 @@ describe('ConfigurationForm component', () => {
}); });
}); });
describe('when submitting the form', () => { describe('the analyzers section', () => {
beforeEach(() => { describe('given the sastConfigurationUiAnalyzers feature flag is disabled', () => {
jest.spyOn(Sentry, 'captureException').mockImplementation(); beforeEach(() => {
createComponent();
});
it('does not render', () => {
expect(findAnalyzersSection().exists()).toBe(false);
});
}); });
describe.each` describe('given the sastConfigurationUiAnalyzers feature flag is enabled', () => {
context | successPath | errors
${'no successPath'} | ${''} | ${[]}
${'any errors'} | ${''} | ${['an error']}
`('given an unsuccessful endpoint response due to $context', ({ successPath, errors }) => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
mutationResult: { provide: {
successPath, glFeatures: {
errors, sastConfigurationUiAnalyzers: true,
},
},
stubs: {
ExpandableSection,
}, },
}); });
findForm().trigger('submit');
}); });
it('includes the value of each entity in the payload', expectPayloadForEntities); it('renders', () => {
const analyzersSection = findAnalyzersSection();
it(`sets the submit button's loading prop to true`, () => { expect(analyzersSection.exists()).toBe(true);
expect(findSubmitButton().props('loading')).toBe(true); expect(analyzersSection.text()).toContain(ConfigurationForm.i18n.analyzersHeading);
expect(analyzersSection.text()).toContain(ConfigurationForm.i18n.analyzersSubHeading);
}); });
describe('after async tasks', () => { it('has a link to the documentation', () => {
beforeEach(fulfillPendingPromises); const link = findAnalyzersSection().find(GlLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(sastAnalyzersDocumentationPath);
});
it('does not call redirectTo', () => { it('renders each analyzer', () => {
expect(redirectTo).not.toHaveBeenCalled(); const analyzerEntities = sastCiConfiguration.analyzers.nodes;
const analyzerComponents = findAnalyzerConfigurations();
analyzerEntities.forEach((entity, i) => {
expect(analyzerComponents.at(i).props()).toEqual({ entity });
}); });
});
it('displays an alert message', () => { describe('when an AnalyzerConfiguration emits an input event', () => {
expect(findErrorAlert().exists()).toBe(true); let analyzer;
let updatedEntity;
beforeEach(() => {
analyzer = findAnalyzerConfigurations().at(0);
updatedEntity = {
...sastCiConfiguration.analyzers.nodes[0],
value: 'new value',
};
analyzer.vm.$emit('input', updatedEntity);
}); });
it('sends the error to Sentry', () => { it('updates the entity binding', () => {
expect(Sentry.captureException.mock.calls).toMatchObject([ expect(analyzer.props('entity')).toBe(updatedEntity);
[{ message: expect.stringMatching(/merge request.*fail/) }],
]);
}); });
});
});
});
it(`sets the submit button's loading prop to false`, () => { describe('when submitting the form', () => {
expect(findSubmitButton().props('loading')).toBe(false); beforeEach(() => {
}); jest.spyOn(Sentry, 'captureException').mockImplementation();
});
describe('submitting again after a previous error', () => { describe.each`
beforeEach(() => { context | successPath | errors | sastConfigurationUiAnalyzers
findForm().trigger('submit'); ${'no successPath'} | ${''} | ${[]} | ${false}
${'any errors'} | ${''} | ${['an error']} | ${false}
${'no successPath'} | ${''} | ${[]} | ${true}
${'any errors'} | ${''} | ${['an error']} | ${true}
`(
'given an unsuccessful endpoint response due to $context',
({ successPath, errors, sastConfigurationUiAnalyzers }) => {
beforeEach(() => {
createComponent({
mutationResult: {
successPath,
errors,
},
provide: {
glFeatures: { sastConfigurationUiAnalyzers },
},
}); });
it('hides the alert message', () => { findForm().trigger('submit');
expect(findErrorAlert().exists()).toBe(false);
});
}); });
});
});
describe('given a successful endpoint response', () => { it('includes the value of each entity in the payload', () => {
beforeEach(() => { expectPayloadForEntities({ withAnalyzers: sastConfigurationUiAnalyzers });
createComponent({
mutationResult: {
successPath: newMergeRequestPath,
errors: [],
},
}); });
findForm().trigger('submit'); it(`sets the submit button's loading prop to true`, () => {
}); expect(findSubmitButton().props('loading')).toBe(true);
});
it('includes the value of each entity in the payload', expectPayloadForEntities); describe('after async tasks', () => {
beforeEach(fulfillPendingPromises);
it(`sets the submit button's loading prop to true`, () => { it('does not call redirectTo', () => {
expect(findSubmitButton().props().loading).toBe(true); expect(redirectTo).not.toHaveBeenCalled();
}); });
it('displays an alert message', () => {
expect(findErrorAlert().exists()).toBe(true);
});
it('sends the error to Sentry', () => {
expect(Sentry.captureException.mock.calls).toMatchObject([
[{ message: expect.stringMatching(/merge request.*fail/) }],
]);
});
it(`sets the submit button's loading prop to false`, () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
describe('after async tasks', () => { describe('submitting again after a previous error', () => {
beforeEach(fulfillPendingPromises); beforeEach(() => {
findForm().trigger('submit');
});
it('calls redirectTo', () => { it('hides the alert message', () => {
expect(redirectTo).toHaveBeenCalledWith(newMergeRequestPath); expect(findErrorAlert().exists()).toBe(false);
});
});
}); });
},
);
describe.each([true, false])(
'given a successful endpoint response with sastConfigurationUiAnalyzers = %p',
sastConfigurationUiAnalyzers => {
beforeEach(() => {
createComponent({
mutationResult: {
successPath: newMergeRequestPath,
errors: [],
},
provide: {
glFeatures: { sastConfigurationUiAnalyzers },
},
});
it('does not display an alert message', () => { findForm().trigger('submit');
expect(findErrorAlert().exists()).toBe(false);
}); });
it('does not call Sentry.captureException', () => { // See https://github.com/jest-community/eslint-plugin-jest/issues/229
expect(Sentry.captureException).not.toHaveBeenCalled(); // for a similar reason for disabling the rule on the next line
// eslint-disable-next-line jest/no-identical-title
it('includes the value of each entity in the payload', () => {
expectPayloadForEntities({ withAnalyzers: sastConfigurationUiAnalyzers });
}); });
it('keeps the loading prop set to true', () => { // eslint-disable-next-line jest/no-identical-title
// This is done for UX reasons. If the loading prop is set to false it(`sets the submit button's loading prop to true`, () => {
// 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(findSubmitButton().props().loading).toBe(true); expect(findSubmitButton().props().loading).toBe(true);
}); });
});
}); // eslint-disable-next-line jest/no-identical-title
describe('after async tasks', () => {
beforeEach(fulfillPendingPromises);
it('calls redirectTo', () => {
expect(redirectTo).toHaveBeenCalledWith(newMergeRequestPath);
});
it('does not display an alert message', () => {
expect(findErrorAlert().exists()).toBe(false);
});
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(findSubmitButton().props().loading).toBe(true);
});
});
},
);
}); });
describe('the cancel button', () => { describe('the cancel button', () => {
......
...@@ -35,8 +35,11 @@ describe('DynamicFields component', () => { ...@@ -35,8 +35,11 @@ describe('DynamicFields component', () => {
}); });
describe.each([true, false])('given the disabled prop is %p', disabled => { describe.each([true, false])('given the disabled prop is %p', disabled => {
let entities;
beforeEach(() => { beforeEach(() => {
createComponent({ entities: [], disabled }, mount); entities = makeEntities(2);
createComponent({ entities, disabled }, mount);
}); });
it('uses a fieldset as the root element', () => { it('uses a fieldset as the root element', () => {
...@@ -47,6 +50,16 @@ describe('DynamicFields component', () => { ...@@ -47,6 +50,16 @@ describe('DynamicFields component', () => {
it(`${disabled ? 'sets' : 'does not set'} the disabled attribute on the root element`, () => { it(`${disabled ? 'sets' : 'does not set'} the disabled attribute on the root element`, () => {
expect('disabled' in wrapper.attributes()).toBe(disabled); expect('disabled' in wrapper.attributes()).toBe(disabled);
}); });
it('passes the disabled prop to child fields', () => {
entities.forEach((entity, i) => {
expect(
findFields()
.at(i)
.props('disabled'),
).toBe(disabled);
});
});
}); });
describe('given valid entities', () => { describe('given valid entities', () => {
......
...@@ -77,7 +77,7 @@ describe('FormInput component', () => { ...@@ -77,7 +77,7 @@ describe('FormInput component', () => {
}); });
describe('custom value message', () => { describe('custom value message', () => {
describe('given the value equals the custom value', () => { describe('given the value equals the default value', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: testProps, props: testProps,
...@@ -89,7 +89,7 @@ describe('FormInput component', () => { ...@@ -89,7 +89,7 @@ describe('FormInput component', () => {
}); });
}); });
describe('given the value differs from the custom value', () => { describe('given the value differs from the default value', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: { props: {
...@@ -112,6 +112,17 @@ describe('FormInput component', () => { ...@@ -112,6 +112,17 @@ describe('FormInput component', () => {
expect(wrapper.emitted('input')).toEqual([[testProps.defaultValue]]); expect(wrapper.emitted('input')).toEqual([[testProps.defaultValue]]);
}); });
}); });
describe('disabling the input', () => {
beforeEach(() => {
wrapper.setProps({ disabled: true });
return wrapper.vm.$nextTick();
});
it('does not display the custom value message', () => {
expect(findRestoreDefaultLink().exists()).toBe(false);
});
});
}); });
}); });
......
...@@ -18,26 +18,6 @@ export const makeEntities = (count, changes) => ...@@ -18,26 +18,6 @@ export const makeEntities = (count, changes) =>
...changes, ...changes,
})); }));
/**
* Creates a mock SastCiConfiguration GraphQL object instance.
*
* @param {number} totalEntities - The total number of entities to create.
* @returns {SastCiConfiguration}
*/
export const makeSastCiConfiguration = (totalEntities = 2) => {
// Call makeEntities just once to ensure unique fields
const entities = makeEntities(totalEntities);
return {
global: {
nodes: entities.slice(0, totalEntities - 1),
},
pipeline: {
nodes: entities.slice(totalEntities - 1),
},
};
};
/** /**
* Creates an array of objects matching the shape of a GraphQl * Creates an array of objects matching the shape of a GraphQl
* SastCiConfigurationAnalyzersEntity. * SastCiConfigurationAnalyzersEntity.
...@@ -55,3 +35,30 @@ export const makeAnalyzerEntities = (count, changes) => ...@@ -55,3 +35,30 @@ export const makeAnalyzerEntities = (count, changes) =>
enabled: true, enabled: true,
...changes, ...changes,
})); }));
/**
* Creates a mock SastCiConfiguration GraphQL object instance.
*
* @param {number} totalEntities - The total number of entities to create.
* @returns {SastCiConfiguration}
*/
export const makeSastCiConfiguration = () => {
// Call makeEntities just once to ensure unique fields
const entities = makeEntities(3);
return {
global: {
nodes: [entities.shift()],
},
pipeline: {
nodes: [entities.shift()],
},
analyzers: {
nodes: makeAnalyzerEntities(1, {
variables: {
nodes: [entities.shift()],
},
}),
},
};
};
...@@ -2,6 +2,7 @@ import { ...@@ -2,6 +2,7 @@ import {
isValidConfigurationEntity, isValidConfigurationEntity,
isValidAnalyzerEntity, isValidAnalyzerEntity,
toSastCiConfigurationEntityInput, toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from 'ee/security_configuration/sast/components/utils'; } from 'ee/security_configuration/sast/components/utils';
import { makeEntities, makeAnalyzerEntities } from './helpers'; import { makeEntities, makeAnalyzerEntities } from './helpers';
...@@ -40,7 +41,6 @@ describe('isValidAnalyzerEntity', () => { ...@@ -40,7 +41,6 @@ describe('isValidAnalyzerEntity', () => {
{}, {},
...makeAnalyzerEntities(1, { name: undefined }), ...makeAnalyzerEntities(1, { name: undefined }),
...makeAnalyzerEntities(1, { label: undefined }), ...makeAnalyzerEntities(1, { label: undefined }),
...makeAnalyzerEntities(1, { description: undefined }),
...makeAnalyzerEntities(1, { enabled: undefined }), ...makeAnalyzerEntities(1, { enabled: undefined }),
...makeAnalyzerEntities(1, { enabled: '' }), ...makeAnalyzerEntities(1, { enabled: '' }),
]; ];
...@@ -55,7 +55,7 @@ describe('isValidAnalyzerEntity', () => { ...@@ -55,7 +55,7 @@ describe('isValidAnalyzerEntity', () => {
}); });
describe('toSastCiConfigurationEntityInput', () => { describe('toSastCiConfigurationEntityInput', () => {
let entity = makeEntities(1); let entity;
describe('given a SastCiConfigurationEntity', () => { describe('given a SastCiConfigurationEntity', () => {
beforeEach(() => { beforeEach(() => {
...@@ -71,3 +71,51 @@ describe('toSastCiConfigurationEntityInput', () => { ...@@ -71,3 +71,51 @@ describe('toSastCiConfigurationEntityInput', () => {
}); });
}); });
}); });
describe('toSastCiConfigurationAnalyzerEntityInput', () => {
let entity;
describe.each`
context | enabled | variables
${'a disabled entity with variables'} | ${false} | ${makeEntities(1)}
${'an enabled entity without variables'} | ${true} | ${undefined}
`('given $context', ({ enabled, variables }) => {
beforeEach(() => {
if (variables) {
[entity] = makeAnalyzerEntities(1, { enabled, variables: { nodes: variables } });
} else {
[entity] = makeAnalyzerEntities(1, { enabled });
}
});
it('returns a SastCiConfigurationAnalyzerEntityInput without variables', () => {
expect(toSastCiConfigurationAnalyzerEntityInput(entity)).toEqual({
name: entity.name,
enabled: entity.enabled,
});
});
});
describe('given an enabled entity with variables', () => {
beforeEach(() => {
[entity] = makeAnalyzerEntities(1, {
enabled: true,
variables: { nodes: makeEntities(1) },
});
});
it('returns a SastCiConfigurationAnalyzerEntityInput with variables', () => {
expect(toSastCiConfigurationAnalyzerEntityInput(entity)).toEqual({
name: entity.name,
enabled: entity.enabled,
variables: [
{
field: 'field0',
defaultValue: 'defaultValue0',
value: 'value0',
},
],
});
});
});
});
...@@ -6,6 +6,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do ...@@ -6,6 +6,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let(:project_path) { project.full_path } let(:project_path) { project.full_path }
let(:docs_path) { help_page_path('user/application_security/sast/index', anchor: 'configuration') } let(:docs_path) { help_page_path('user/application_security/sast/index', anchor: 'configuration') }
let(:analyzers_docs_path) { help_page_path('user/application_security/sast/analyzers') }
describe '#sast_configuration_data' do describe '#sast_configuration_data' do
subject { helper.sast_configuration_data(project) } subject { helper.sast_configuration_data(project) }
...@@ -14,6 +15,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do ...@@ -14,6 +15,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do
is_expected.to eq({ is_expected.to eq({
create_sast_merge_request_path: project_security_configuration_sast_path(project), create_sast_merge_request_path: project_security_configuration_sast_path(project),
project_path: project_path, project_path: project_path,
sast_analyzers_documentation_path: analyzers_docs_path,
sast_documentation_path: docs_path, sast_documentation_path: docs_path,
security_configuration_path: project_security_configuration_path(project) security_configuration_path: project_security_configuration_path(project)
}) })
......
...@@ -22421,6 +22421,9 @@ msgstr "" ...@@ -22421,6 +22421,9 @@ msgstr ""
msgid "SecurityConfiguration|Available for on-demand DAST" msgid "SecurityConfiguration|Available for on-demand DAST"
msgstr "" msgstr ""
msgid "SecurityConfiguration|By default, all analyzers are applied in order to cover all languages across your project, and only run if the language is detected in the Merge Request."
msgstr ""
msgid "SecurityConfiguration|Configure" msgid "SecurityConfiguration|Configure"
msgstr "" msgstr ""
...@@ -22454,6 +22457,9 @@ msgstr "" ...@@ -22454,6 +22457,9 @@ msgstr ""
msgid "SecurityConfiguration|Not enabled" msgid "SecurityConfiguration|Not enabled"
msgstr "" msgstr ""
msgid "SecurityConfiguration|SAST Analyzers"
msgstr ""
msgid "SecurityConfiguration|SAST Configuration" msgid "SecurityConfiguration|SAST Configuration"
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