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>
import { GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DynamicFields from './dynamic_fields.vue';
import { isValidAnalyzerEntity } from './utils';
......@@ -9,6 +10,7 @@ export default {
GlFormCheckbox,
DynamicFields,
},
mixins: [glFeatureFlagsMixin()],
model: {
prop: 'entity',
event: 'input',
......@@ -22,8 +24,11 @@ export default {
},
},
computed: {
hasConfiguration() {
return this.entity.configuration?.length > 0;
variables() {
return this.entity.variables?.nodes ?? [];
},
hasVariables() {
return this.variables.length > 0;
},
},
methods: {
......@@ -31,8 +36,8 @@ export default {
const entity = { ...this.entity, enabled };
this.$emit('input', entity);
},
onConfigurationUpdate(configuration) {
const entity = { ...this.entity, configuration };
onVariablesUpdate(variables) {
const entity = { ...this.entity, variables: { nodes: variables } };
this.$emit('input', entity);
},
},
......@@ -47,11 +52,11 @@ export default {
</gl-form-checkbox>
<dynamic-fields
v-if="hasConfiguration"
v-if="hasVariables"
:disabled="!entity.enabled"
class="gl-ml-6"
:entities="entity.configuration"
@input="onConfigurationUpdate"
class="gl-ml-6 gl-mb-0"
:entities="variables"
@input="onVariablesUpdate"
/>
</gl-form-group>
</template>
<script>
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
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';
export default {
......@@ -12,6 +14,7 @@ export default {
GlLoadingIcon,
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
inject: {
sastDocumentationPath: {
from: 'sastDocumentationPath',
......@@ -24,7 +27,11 @@ export default {
},
apollo: {
sastCiConfiguration: {
query: sastCiConfigurationQuery,
query() {
return this.glFeatures.sastConfigurationUiAnalyzers
? sastCiConfigurationWithAnalyzersQuery
: sastCiConfigurationQuery;
},
variables() {
return {
fullPath: this.projectPath,
......
<script>
import { GlAlert, GlButton } from '@gitlab/ui';
import { GlAlert, GlButton, GlIcon, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale';
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 ExpandableSection from './expandable_section.vue';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import { toSastCiConfigurationEntityInput } from './utils';
import {
toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from './utils';
export default {
components: {
AnalyzerConfiguration,
DynamicFields,
ExpandableSection,
GlAlert,
GlButton,
GlIcon,
GlLink,
},
mixins: [glFeatureFlagsMixin()],
inject: {
createSastMergeRequestPath: {
from: 'createSastMergeRequestPath',
default: '',
},
sastAnalyzersDocumentationPath: {
from: 'sastAnalyzersDocumentationPath',
default: '',
},
securityConfigurationPath: {
from: 'securityConfigurationPath',
default: '',
......@@ -39,10 +54,20 @@ export default {
return {
globalConfiguration: cloneDeep(this.sastCiConfiguration.global.nodes),
pipelineConfiguration: cloneDeep(this.sastCiConfiguration.pipeline.nodes),
analyzersConfiguration: this.glFeatures.sastConfigurationUiAnalyzers
? cloneDeep(this.sastCiConfiguration.analyzers.nodes)
: [],
hasSubmissionError: false,
isSubmitting: false,
};
},
computed: {
shouldRenderAnalyzersSection() {
return Boolean(
this.glFeatures.sastConfigurationUiAnalyzers && this.analyzersConfiguration.length > 0,
);
},
},
methods: {
onSubmit() {
this.isSubmitting = true;
......@@ -75,10 +100,26 @@ export default {
});
},
getMutationConfiguration() {
return {
const configuration = {
global: this.globalConfiguration.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: {
......@@ -87,6 +128,13 @@ export default {
),
submitButton: s__('SecurityConfiguration|Create Merge Request'),
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>
......@@ -96,7 +144,35 @@ export default {
<dynamic-fields v-model="globalConfiguration" 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">{{
$options.i18n.submissionError
......
......@@ -60,6 +60,7 @@ export default {
v-for="entity in entities"
ref="fields"
:key="entity.field"
:disabled="disabled"
v-bind="entity"
@input="onInput(entity.field, $event)"
/>
......
......@@ -45,10 +45,15 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isCustomValue() {
return this.value !== this.defaultValue;
showCustomValueMessage() {
return !this.disabled && this.value !== this.defaultValue;
},
inputSize() {
return SCHEMA_TO_PROP_SIZE_MAP[this.size];
......@@ -72,9 +77,15 @@ export default {
<gl-form-text class="gl-mt-3">{{ description }}</gl-form-text>
</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">
<template #anchor="{ content }">
<gl-link @click="resetToDefaultValue" v-text="content" />
......
......@@ -23,9 +23,9 @@ export const isValidAnalyzerEntity = object => {
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 })
defaultValue,
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!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
...SastCiConfigurationFragment
}
}
}
#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() {
securityConfigurationPath,
createSastMergeRequestPath,
projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath,
} = el.dataset;
......@@ -30,6 +31,7 @@ export default function init() {
securityConfigurationPath,
createSastMergeRequestPath,
projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath,
},
render(createElement) {
......
......@@ -11,6 +11,10 @@ module Projects
before_action :ensure_sast_configuration_enabled!, except: [: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
end
......
......@@ -5,6 +5,7 @@ module Projects::Security::SastConfigurationHelper
{
create_sast_merge_request_path: project_security_configuration_sast_path(project),
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'),
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 AnalyzerConfiguration from 'ee/security_configuration/sast/components/analyzer_configuration.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeAnalyzerEntities, makeEntities, makeSastCiConfiguration } from './helpers';
describe('AnalyzerConfiguration component', () => {
let wrapper;
const entity = {
name: 'name',
label: 'label',
description: 'description',
enabled: false,
};
let entity;
const createComponent = ({ props = {} } = {}) => {
wrapper = mount(AnalyzerConfiguration, {
......@@ -23,6 +18,10 @@ describe('AnalyzerConfiguration component', () => {
const findInputElement = () => wrapper.find('input[type="checkbox"]');
const findDynamicFields = () => wrapper.find(DynamicFields);
beforeEach(() => {
[entity] = makeAnalyzerEntities(1);
});
afterEach(() => {
wrapper.destroy();
});
......@@ -69,8 +68,8 @@ describe('AnalyzerConfiguration component', () => {
});
});
describe('configuration form', () => {
describe('when there are no SastCiConfigurationEntity', () => {
describe('child variables', () => {
describe('when there are no SastCiConfigurationEntity child variables', () => {
beforeEach(() => {
createComponent({
props: { entity },
......@@ -82,45 +81,36 @@ describe('AnalyzerConfiguration component', () => {
});
});
describe('when there are one or more SastCiConfigurationEntity', () => {
const analyzerEntity = {
...entity,
enabled: false,
configuration: [
{
defaultValue: 'defaultVal',
description: 'desc',
field: 'field',
type: 'string',
value: 'val',
label: 'label',
},
],
};
describe('when there are one or more SastCiConfigurationEntity child variables', () => {
let newEntities;
beforeEach(() => {
[entity] = makeSastCiConfiguration().analyzers.nodes;
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);
});
it('it emits an input event when dynamic form fields emits an input event', () => {
findDynamicFields().vm.$emit('input', analyzerEntity.configuration);
it('it emits an input event when DynamicFields emits an input event', () => {
newEntities = makeEntities(1, { field: 'new field' });
findDynamicFields().vm.$emit('input', newEntities);
const [[payload]] = wrapper.emitted('input');
expect(payload).toEqual(analyzerEntity);
expect(wrapper.emitted('input')).toEqual([
[{ ...entity, variables: { nodes: newEntities } }],
]);
});
it('passes the disabled prop to dynamic fields component', () => {
expect(findDynamicFields().props('disabled')).toBe(!analyzerEntity.enabled);
it('passes the disabled prop to DynamicFields component', () => {
expect(findDynamicFields().props('disabled')).toBe(!entity.enabled);
});
it('passes the entities prop to the dynamic fields component', () => {
expect(findDynamicFields().props('entities')).toBe(analyzerEntity.configuration);
it('passes the entities prop to the DynamicFields component', () => {
expect(findDynamicFields().props('entities')).toBe(entity.variables.nodes);
});
});
});
......
import { merge } from 'lodash';
import * as Sentry from '@sentry/browser';
import { GlAlert } from '@gitlab/ui';
import { GlAlert, GlLink } from '@gitlab/ui';
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 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 { redirectTo } from '~/lib/utils/url_utility';
import { makeEntities, makeSastCiConfiguration } from './helpers';
......@@ -12,6 +15,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
const projectPath = 'group/project';
const sastAnalyzersDocumentationPath = '/help/sast/analyzers';
const securityConfigurationPath = '/security/configuration';
const newMergeRequestPath = '/merge_request/new';
......@@ -24,32 +28,39 @@ describe('ConfigurationForm component', () => {
pendingPromiseResolvers.forEach(resolve => resolve());
};
const createComponent = ({ mutationResult } = {}) => {
const createComponent = ({ mutationResult, ...options } = {}) => {
sastCiConfiguration = makeSastCiConfiguration();
wrapper = shallowMount(ConfigurationForm, {
provide: {
projectPath,
securityConfigurationPath,
},
propsData: {
sastCiConfiguration,
},
mocks: {
$apollo: {
mutate: jest.fn(
() =>
new Promise(resolve => {
pendingPromiseResolvers.push(() =>
resolve({
data: { configureSast: mutationResult },
wrapper = shallowMount(
ConfigurationForm,
merge(
{
provide: {
projectPath,
securityConfigurationPath,
sastAnalyzersDocumentationPath,
},
propsData: {
sastCiConfiguration,
},
mocks: {
$apollo: {
mutate: jest.fn(
() =>
new Promise(resolve => {
pendingPromiseResolvers.push(() =>
resolve({
data: { configureSast: mutationResult },
}),
);
}),
);
}),
),
),
},
},
},
},
});
options,
),
);
};
const findForm = () => wrapper.find('form');
......@@ -57,8 +68,10 @@ describe('ConfigurationForm component', () => {
const findErrorAlert = () => wrapper.find(GlAlert);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
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 = {
mutation: configureSastMutation,
variables: {
......@@ -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]]);
};
......@@ -132,109 +161,198 @@ describe('ConfigurationForm component', () => {
});
});
describe('when submitting the form', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
describe('the analyzers section', () => {
describe('given the sastConfigurationUiAnalyzers feature flag is disabled', () => {
beforeEach(() => {
createComponent();
});
it('does not render', () => {
expect(findAnalyzersSection().exists()).toBe(false);
});
});
describe.each`
context | successPath | errors
${'no successPath'} | ${''} | ${[]}
${'any errors'} | ${''} | ${['an error']}
`('given an unsuccessful endpoint response due to $context', ({ successPath, errors }) => {
describe('given the sastConfigurationUiAnalyzers feature flag is enabled', () => {
beforeEach(() => {
createComponent({
mutationResult: {
successPath,
errors,
provide: {
glFeatures: {
sastConfigurationUiAnalyzers: true,
},
},
stubs: {
ExpandableSection,
},
});
findForm().trigger('submit');
});
it('includes the value of each entity in the payload', expectPayloadForEntities);
it(`sets the submit button's loading prop to true`, () => {
expect(findSubmitButton().props('loading')).toBe(true);
it('renders', () => {
const analyzersSection = findAnalyzersSection();
expect(analyzersSection.exists()).toBe(true);
expect(analyzersSection.text()).toContain(ConfigurationForm.i18n.analyzersHeading);
expect(analyzersSection.text()).toContain(ConfigurationForm.i18n.analyzersSubHeading);
});
describe('after async tasks', () => {
beforeEach(fulfillPendingPromises);
it('has a link to the documentation', () => {
const link = findAnalyzersSection().find(GlLink);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(sastAnalyzersDocumentationPath);
});
it('does not call redirectTo', () => {
expect(redirectTo).not.toHaveBeenCalled();
it('renders each analyzer', () => {
const analyzerEntities = sastCiConfiguration.analyzers.nodes;
const analyzerComponents = findAnalyzerConfigurations();
analyzerEntities.forEach((entity, i) => {
expect(analyzerComponents.at(i).props()).toEqual({ entity });
});
});
it('displays an alert message', () => {
expect(findErrorAlert().exists()).toBe(true);
describe('when an AnalyzerConfiguration emits an input event', () => {
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', () => {
expect(Sentry.captureException.mock.calls).toMatchObject([
[{ message: expect.stringMatching(/merge request.*fail/) }],
]);
it('updates the entity binding', () => {
expect(analyzer.props('entity')).toBe(updatedEntity);
});
});
});
});
it(`sets the submit button's loading prop to false`, () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
describe('when submitting the form', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
});
describe('submitting again after a previous error', () => {
beforeEach(() => {
findForm().trigger('submit');
describe.each`
context | successPath | errors | sastConfigurationUiAnalyzers
${'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', () => {
expect(findErrorAlert().exists()).toBe(false);
});
findForm().trigger('submit');
});
});
});
describe('given a successful endpoint response', () => {
beforeEach(() => {
createComponent({
mutationResult: {
successPath: newMergeRequestPath,
errors: [],
},
it('includes the value of each entity in the payload', () => {
expectPayloadForEntities({ withAnalyzers: sastConfigurationUiAnalyzers });
});
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`, () => {
expect(findSubmitButton().props().loading).toBe(true);
});
it('does not call redirectTo', () => {
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', () => {
beforeEach(fulfillPendingPromises);
describe('submitting again after a previous error', () => {
beforeEach(() => {
findForm().trigger('submit');
});
it('calls redirectTo', () => {
expect(redirectTo).toHaveBeenCalledWith(newMergeRequestPath);
it('hides the alert message', () => {
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', () => {
expect(findErrorAlert().exists()).toBe(false);
findForm().trigger('submit');
});
it('does not call Sentry.captureException', () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
// See https://github.com/jest-community/eslint-plugin-jest/issues/229
// 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', () => {
// 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).
// eslint-disable-next-line jest/no-identical-title
it(`sets the submit button's loading prop to 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', () => {
......
......@@ -35,8 +35,11 @@ describe('DynamicFields component', () => {
});
describe.each([true, false])('given the disabled prop is %p', disabled => {
let entities;
beforeEach(() => {
createComponent({ entities: [], disabled }, mount);
entities = makeEntities(2);
createComponent({ entities, disabled }, mount);
});
it('uses a fieldset as the root element', () => {
......@@ -47,6 +50,16 @@ describe('DynamicFields component', () => {
it(`${disabled ? 'sets' : 'does not set'} the disabled attribute on the root element`, () => {
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', () => {
......
......@@ -77,7 +77,7 @@ describe('FormInput component', () => {
});
describe('custom value message', () => {
describe('given the value equals the custom value', () => {
describe('given the value equals the default value', () => {
beforeEach(() => {
createComponent({
props: testProps,
......@@ -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(() => {
createComponent({
props: {
......@@ -112,6 +112,17 @@ describe('FormInput component', () => {
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) =>
...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
* SastCiConfigurationAnalyzersEntity.
......@@ -55,3 +35,30 @@ export const makeAnalyzerEntities = (count, changes) =>
enabled: true,
...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 {
isValidConfigurationEntity,
isValidAnalyzerEntity,
toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from 'ee/security_configuration/sast/components/utils';
import { makeEntities, makeAnalyzerEntities } from './helpers';
......@@ -40,7 +41,6 @@ describe('isValidAnalyzerEntity', () => {
{},
...makeAnalyzerEntities(1, { name: undefined }),
...makeAnalyzerEntities(1, { label: undefined }),
...makeAnalyzerEntities(1, { description: undefined }),
...makeAnalyzerEntities(1, { enabled: undefined }),
...makeAnalyzerEntities(1, { enabled: '' }),
];
......@@ -55,7 +55,7 @@ describe('isValidAnalyzerEntity', () => {
});
describe('toSastCiConfigurationEntityInput', () => {
let entity = makeEntities(1);
let entity;
describe('given a SastCiConfigurationEntity', () => {
beforeEach(() => {
......@@ -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
let_it_be(:project) { create(:project) }
let(:project_path) { project.full_path }
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
subject { helper.sast_configuration_data(project) }
......@@ -14,6 +15,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do
is_expected.to eq({
create_sast_merge_request_path: project_security_configuration_sast_path(project),
project_path: project_path,
sast_analyzers_documentation_path: analyzers_docs_path,
sast_documentation_path: docs_path,
security_configuration_path: project_security_configuration_path(project)
})
......
......@@ -22421,6 +22421,9 @@ msgstr ""
msgid "SecurityConfiguration|Available for on-demand DAST"
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"
msgstr ""
......@@ -22454,6 +22457,9 @@ msgstr ""
msgid "SecurityConfiguration|Not enabled"
msgstr ""
msgid "SecurityConfiguration|SAST Analyzers"
msgstr ""
msgid "SecurityConfiguration|SAST Configuration"
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