Commit 3bbc8ed8 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'djadmin-scanner-profile-form' into 'master'

DAST Scanner Profiles Form MVC

Closes #235404

See merge request gitlab-org/gitlab!40963
parents 270576b8 0f1c4cba
......@@ -14,3 +14,40 @@ export const serializeForm = form => {
return serializeFormEntries(entries);
};
/**
* Check if the value provided is empty or not
*
* It is being used to check if a form input
* value has been set or not
*
* @param {String, Number, Array} - Any form value
* @returns {Boolean} - returns false if a value is set
*
* @example
* returns true for '', [], null, undefined
*/
export const isEmptyValue = value => value == null || value.length === 0;
/**
* A form object serializer
*
* @param {Object} - Form Object
* @returns {Object} - Serialized Form Object
*
* @example
* Input
* {"project": {"value": "hello", "state": false}, "username": {"value": "john"}}
*
* Returns
* {"project": "hello", "username": "john"}
*/
export const serializeFormObject = form =>
Object.fromEntries(
Object.entries(form).reduce((acc, [name, { value }]) => {
if (!isEmptyValue(value)) {
acc.push([name, value]);
}
return acc;
}, []),
);
<script>
import * as Sentry from '@sentry/browser';
import { isEqual } from 'lodash';
import {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlFormInputGroup,
GlModal,
GlIcon,
GlTooltipDirective,
GlInputGroupText,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import { serializeFormObject, isEmptyValue } from '~/lib/utils/forms';
import dastScannerProfileCreateMutation from '../graphql/dast_scanner_profile_create.mutation.graphql';
const initField = (value, isRequired = false) => ({
value,
required: isRequired,
state: null,
feedback: null,
});
const SPIDER_TIMEOUT_MIN = 0;
const SPIDER_TIMEOUT_MAX = 2880;
const TARGET_TIMEOUT_MIN = 1;
const TARGET_TIMEOUT_MAX = 3600;
export default {
name: 'DastScannerProfileForm',
components: {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlFormInputGroup,
GlModal,
GlIcon,
GlInputGroupText,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
projectFullPath: {
type: String,
required: true,
},
profilesLibraryPath: {
type: String,
required: true,
},
},
data() {
const form = {
profileName: initField('', true),
spiderTimeout: initField('', true),
targetTimeout: initField('', true),
};
return {
form,
initialFormValues: serializeFormObject(form),
loading: false,
showAlert: false,
};
},
spiderTimeoutRange: {
min: SPIDER_TIMEOUT_MIN,
max: SPIDER_TIMEOUT_MAX,
},
targetTimeoutRange: {
min: TARGET_TIMEOUT_MIN,
max: TARGET_TIMEOUT_MAX,
},
computed: {
formTouched() {
return !isEqual(serializeFormObject(this.form), this.initialFormValues);
},
formHasErrors() {
return Object.values(this.form).some(({ state }) => state === false);
},
requiredFieldEmpty() {
return Object.values(this.form).some(
({ required, value }) => required && isEmptyValue(value),
);
},
isSubmitDisabled() {
return this.formHasErrors || this.requiredFieldEmpty;
},
},
methods: {
validateTimeout(timeoutObject, range) {
const timeout = timeoutObject;
const hasValue = timeout.value !== '';
const isOutOfRange = timeout.value < range.min || timeout.value > range.max;
if (hasValue && isOutOfRange) {
timeout.state = false;
timeout.feedback = s__('DastProfiles|Please enter a valid timeout value');
return;
}
timeout.state = true;
timeout.feedback = null;
},
validateSpiderTimeout() {
this.validateTimeout(this.form.spiderTimeout, this.$options.spiderTimeoutRange);
},
validateTargetTimeout() {
this.validateTimeout(this.form.targetTimeout, this.$options.targetTimeoutRange);
},
onSubmit() {
this.loading = true;
this.hideErrors();
const variables = {
projectFullPath: this.projectFullPath,
...serializeFormObject(this.form),
};
this.$apollo
.mutate({
mutation: dastScannerProfileCreateMutation,
variables,
})
.then(({ data: { dastScannerProfileCreate: { errors = [] } } }) => {
if (errors.length > 0) {
this.showErrors(errors);
this.loading = false;
} else {
redirectTo(this.profilesLibraryPath);
}
})
.catch(e => {
Sentry.captureException(e);
this.showErrors();
this.loading = false;
});
},
onCancelClicked() {
if (!this.formTouched) {
this.discard();
} else {
this.$refs[this.$options.modalId].show();
}
},
discard() {
redirectTo(this.profilesLibraryPath);
},
showErrors(errors = []) {
this.errors = errors;
this.showAlert = true;
},
hideErrors() {
this.errors = [];
this.showAlert = false;
},
},
modalId: 'deleteDastProfileModal',
i18n: {
modalTitle: s__('DastProfiles|Do you want to discard this scanner profile?'),
modalOkTitle: __('Discard'),
modalCancelTitle: __('Cancel'),
spiderTimeoutTooltip: '',
targetTimeoutTooltip: '',
},
};
</script>
<template>
<h1>{{ s__('DastProfiles|New Scanner Profile') }}</h1>
<gl-form @submit.prevent="onSubmit">
<h2 class="gl-mb-6">
{{ s__('DastProfiles|New scanner profile') }}
</h2>
<gl-alert v-if="showAlert" variant="danger" class="gl-mb-5" @dismiss="hideErrors">
{{ s__('DastProfiles|Could not create the scanner profile. Please try again.') }}
<ul v-if="errors.length" class="gl-mt-3 gl-mb-0">
<li v-for="error in errors" :key="error" v-text="error"></li>
</ul>
</gl-alert>
<gl-form-group :label="s__('DastProfiles|Profile name')">
<gl-form-input
v-model="form.profileName.value"
class="mw-460"
data-testid="profile-name-input"
type="text"
/>
</gl-form-group>
<hr />
<div class="row">
<gl-form-group
class="col-md-6"
:state="form.spiderTimeout.state"
:invalid-feedback="form.spiderTimeout.feedback"
>
<template #label>
{{ s__('DastProfiles|Spider timeout') }}
<gl-icon
v-if="$options.i18n.spiderTimeoutTooltip"
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400 gl-ml-2"
:title="$options.i18n.spiderTimeoutTooltip"
/>
</template>
<gl-form-input-group
v-model.number="form.spiderTimeout.value"
class="mw-460"
data-testid="spider-timeout-input"
type="number"
:min="$options.spiderTimeoutRange.min"
:max="$options.spiderTimeoutRange.max"
@input="validateSpiderTimeout"
>
<template #append>
<gl-input-group-text>{{ __('Minutes') }}</gl-input-group-text>
</template>
</gl-form-input-group>
<div class="gl-text-gray-400 gl-my-2">
{{ s__('DastProfiles|Minimum = 0 (no timeout enabled), Maximum = 2880 minutes') }}
</div>
</gl-form-group>
<gl-form-group
class="col-md-6"
:state="form.targetTimeout.state"
:invalid-feedback="form.targetTimeout.feedback"
>
<template #label>
{{ s__('DastProfiles|Target timeout') }}
<gl-icon
v-if="$options.i18n.targetTimeoutTooltip"
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400 gl-ml-2"
:title="$options.i18n.targetTimeoutTooltip"
/>
</template>
<gl-form-input-group
v-model.number="form.targetTimeout.value"
class="mw-460"
data-testid="target-timeout-input"
type="number"
:min="$options.targetTimeoutRange.min"
:max="$options.targetTimeoutRange.max"
@input="validateTargetTimeout"
>
<template #append>
<gl-input-group-text>{{ __('Seconds') }}</gl-input-group-text>
</template>
</gl-form-input-group>
<div class="gl-text-gray-400 gl-my-2">
{{ s__('DastProfiles|Minimum = 1 second, Maximum = 3600 seconds') }}
</div>
</gl-form-group>
</div>
<hr />
<gl-button
type="submit"
variant="success"
class="js-no-auto-disable"
data-testid="dast-scanner-profile-form-submit-button"
:disabled="isSubmitDisabled"
:loading="loading"
>
{{ s__('DastProfiles|Save profile') }}
</gl-button>
<gl-button
class="gl-ml-2"
data-testid="dast-scanner-profile-form-cancel-button"
@click="onCancelClicked"
>
{{ __('Cancel') }}
</gl-button>
<gl-modal
:ref="$options.modalId"
:modal-id="$options.modalId"
:title="$options.i18n.modalTitle"
:ok-title="$options.i18n.modalOkTitle"
:cancel-title="$options.i18n.modalCancelTitle"
ok-variant="danger"
body-class="gl-display-none"
data-testid="dast-scanner-profile-form-cancel-modal"
@ok="discard()"
/>
</gl-form>
</template>
......@@ -8,11 +8,20 @@ export default () => {
return false;
}
const { projectFullPath, profilesLibraryPath } = el.dataset;
const props = {
projectFullPath,
profilesLibraryPath,
};
return new Vue({
el,
apolloProvider,
render(h) {
return h(DastScannerProfileForm);
return h(DastScannerProfileForm, {
props,
});
},
});
};
mutation dastScannerProfileCreate(
$projectFullPath: ID!
$profileName: String!
$spiderTimeout: Int!
$targetTimeout: Int!
) {
dastScannerProfileCreate(
input: {
fullPath: $projectFullPath
profileName: $profileName
spiderTimeout: $spiderTimeout
targetTimeout: $targetTimeout
}
) {
errors
}
}
......@@ -3,4 +3,5 @@
- breadcrumb_title s_('DastProfiles|New scanner profile')
- page_title s_('DastProfiles|New scanner profile')
.js-dast-scanner-profile-form
.js-dast-scanner-profile-form{ data: { project_full_path: @project.path_with_namespace,
profiles_library_path: project_profiles_path(@project) } }
import merge from 'lodash/merge';
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlForm, GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import DastScannerProfileForm from 'ee/dast_scanner_profiles/components/dast_scanner_profile_form.vue';
import dastScannerProfileCreateMutation from 'ee/dast_scanner_profiles/graphql/dast_scanner_profile_create.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
const defaultProps = {};
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
describe('DastScannerProfileForm', () => {
const projectFullPath = 'group/project';
const profilesLibraryPath = `${TEST_HOST}/${projectFullPath}/-/on_demand_scans/profiles`;
const profileName = 'My DAST scanner profile';
const spiderTimeout = 12;
const targetTimeout = 20;
const defaultProps = {
profilesLibraryPath,
projectFullPath,
};
describe('DAST Scanner Profile', () => {
let wrapper;
const wrapperFactory = (mountFn = shallowMount) => options => {
const findForm = () => wrapper.find(GlForm);
const findProfileNameInput = () => wrapper.find('[data-testid="profile-name-input"]');
const findSpiderTimeoutInput = () => wrapper.find('[data-testid="spider-timeout-input"]');
const findTargetTimeoutInput = () => wrapper.find('[data-testid="target-timeout-input"]');
const findSubmitButton = () =>
wrapper.find('[data-testid="dast-scanner-profile-form-submit-button"]');
const findCancelButton = () =>
wrapper.find('[data-testid="dast-scanner-profile-form-cancel-button"]');
const findCancelModal = () => wrapper.find(GlModal);
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const findAlert = () => wrapper.find(GlAlert);
const componentFactory = (mountFn = shallowMount) => options => {
wrapper = mountFn(
DastScannerProfileForm,
merge(
......@@ -24,15 +53,197 @@ describe('DastScannerProfileForm', () => {
),
);
};
const createWrapper = wrapperFactory();
const createComponent = componentFactory();
const createFullComponent = componentFactory(mount);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the title', () => {
createWrapper();
expect(wrapper.html()).toContain('<h1>New Scanner Profile</h1>');
it('form renders properly', () => {
createComponent();
expect(findForm().exists()).toBe(true);
});
describe('submit button', () => {
beforeEach(() => {
createComponent();
});
describe('is disabled if', () => {
it('form contains errors', async () => {
findProfileNameInput().vm.$emit('input', profileName);
await findSpiderTimeoutInput().vm.$emit('input', '12312');
expect(findSubmitButton().props('disabled')).toBe(true);
});
it('at least one field is empty', async () => {
findProfileNameInput().vm.$emit('input', '');
await findSpiderTimeoutInput().vm.$emit('input', spiderTimeout);
await findTargetTimeoutInput().vm.$emit('input', targetTimeout);
expect(findSubmitButton().props('disabled')).toBe(true);
});
});
describe('is enabled if', () => {
it('all fields are filled in and valid', async () => {
findProfileNameInput().vm.$emit('input', profileName);
await findSpiderTimeoutInput().vm.$emit('input', 0);
await findTargetTimeoutInput().vm.$emit('input', targetTimeout);
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
});
describe.each`
timeoutType | finder | invalidValues | validValue
${'Spider'} | ${findSpiderTimeoutInput} | ${[-1, 2881]} | ${spiderTimeout}
${'Target'} | ${findTargetTimeoutInput} | ${[0, 3601]} | ${targetTimeout}
`('$timeoutType Timeout', ({ finder, invalidValues, validValue }) => {
const errorMessage = 'Please enter a valid timeout value';
beforeEach(() => {
createFullComponent();
});
it.each(invalidValues)('is marked as invalid provided an invalid value', async value => {
await finder()
.find('input')
.setValue(value);
expect(wrapper.text()).toContain(errorMessage);
});
it('is marked as valid provided a valid value', async () => {
await finder()
.find('input')
.setValue(validValue);
expect(wrapper.text()).not.toContain(errorMessage);
});
it('should allow only numbers', async () => {
expect(
finder()
.find('input')
.props('type'),
).toBe('number');
});
});
describe('submission', () => {
const createdProfileId = 30203;
describe('on success', () => {
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastScannerProfileCreate: { id: createdProfileId } } });
findProfileNameInput().vm.$emit('input', profileName);
findSpiderTimeoutInput().vm.$emit('input', spiderTimeout);
findTargetTimeoutInput().vm.$emit('input', targetTimeout);
submitForm();
});
it('sets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(true);
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastScannerProfileCreateMutation,
variables: {
profileName,
spiderTimeout,
targetTimeout,
projectFullPath,
},
});
});
it('redirects to the profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('on top-level error', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetTimeoutInput();
input.vm.$emit('input', targetTimeout);
submitForm();
});
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
it('shows an error alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('on errors as data', () => {
const errors = ['Name is already taken', 'Value should be Int', 'error#3'];
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastScannerProfileCreate: { pipelineUrl: null, errors } } });
const input = findSpiderTimeoutInput();
input.vm.$emit('input', spiderTimeout);
submitForm();
});
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
it('shows an alert with the returned errors', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
errors.forEach(error => {
expect(alert.text()).toContain(error);
});
});
});
});
describe('cancellation', () => {
beforeEach(() => {
createFullComponent();
});
describe('form empty', () => {
it('redirects to the profiles library', () => {
findCancelButton().vm.$emit('click');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
describe('form not empty', () => {
beforeEach(() => {
findProfileNameInput().setValue(profileName);
});
it('asks the user to confirm the action', () => {
jest.spyOn(findCancelModal().vm, 'show').mockReturnValue();
findCancelButton().trigger('click');
expect(findCancelModal().vm.show).toHaveBeenCalled();
});
it('redirects to the profiles library if confirmed', () => {
findCancelModal().vm.$emit('ok');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
});
});
......@@ -11,4 +11,12 @@ RSpec.describe "projects/dast_scanner_profiles/new", type: :view do
it 'renders Vue app root' do
expect(rendered).to have_selector('.js-dast-scanner-profile-form')
end
it 'passes project\'s full path' do
expect(rendered).to include @project.path_with_namespace
end
it 'passes DAST profiles library URL' do
expect(rendered).to include '/on_demand_scans/profiles'
end
end
......@@ -7740,6 +7740,9 @@ msgstr ""
msgid "DastProfiles|Are you sure you want to delete this profile?"
msgstr ""
msgid "DastProfiles|Could not create the scanner profile. Please try again."
msgstr ""
msgid "DastProfiles|Could not create the site profile. Please try again."
msgstr ""
......@@ -7764,6 +7767,9 @@ msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again."
msgstr ""
msgid "DastProfiles|Do you want to discard this scanner profile?"
msgstr ""
msgid "DastProfiles|Do you want to discard this site profile?"
msgstr ""
......@@ -7785,10 +7791,13 @@ msgstr ""
msgid "DastProfiles|Manage profiles"
msgstr ""
msgid "DastProfiles|New Profile"
msgid "DastProfiles|Minimum = 0 (no timeout enabled), Maximum = 2880 minutes"
msgstr ""
msgid "DastProfiles|Minimum = 1 second, Maximum = 3600 seconds"
msgstr ""
msgid "DastProfiles|New Scanner Profile"
msgid "DastProfiles|New Profile"
msgstr ""
msgid "DastProfiles|New scanner profile"
......@@ -7803,6 +7812,9 @@ msgstr ""
msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr ""
msgid "DastProfiles|Please enter a valid timeout value"
msgstr ""
msgid "DastProfiles|Profile name"
msgstr ""
......@@ -7824,9 +7836,15 @@ msgstr ""
msgid "DastProfiles|Site Profiles"
msgstr ""
msgid "DastProfiles|Spider timeout"
msgstr ""
msgid "DastProfiles|Target URL"
msgstr ""
msgid "DastProfiles|Target timeout"
msgstr ""
msgid "Data is still calculating..."
msgstr ""
......@@ -21780,6 +21798,9 @@ msgstr ""
msgid "Secondary"
msgstr ""
msgid "Seconds"
msgstr ""
msgid "Secret"
msgstr ""
......
import { serializeForm } from '~/lib/utils/forms';
import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms';
describe('lib/utils/forms', () => {
const createDummyForm = inputs => {
......@@ -93,4 +93,46 @@ describe('lib/utils/forms', () => {
});
});
});
describe('isEmptyValue', () => {
it.each`
input | returnValue
${''} | ${true}
${[]} | ${true}
${null} | ${true}
${undefined} | ${true}
${'hello'} | ${false}
${' '} | ${false}
${0} | ${false}
`('returns $returnValue for value $input', ({ input, returnValue }) => {
expect(isEmptyValue(input)).toBe(returnValue);
});
});
describe('serializeFormObject', () => {
it('returns an serialized object', () => {
const form = {
profileName: { value: 'hello', state: null, feedback: null },
spiderTimeout: { value: 2, state: true, feedback: null },
targetTimeout: { value: 12, state: true, feedback: null },
};
expect(serializeFormObject(form)).toEqual({
profileName: 'hello',
spiderTimeout: 2,
targetTimeout: 12,
});
});
it('returns only the entries with value', () => {
const form = {
profileName: { value: '', state: null, feedback: null },
spiderTimeout: { value: 0, state: null, feedback: null },
targetTimeout: { value: null, state: null, feedback: null },
name: { value: undefined, state: null, feedback: null },
};
expect(serializeFormObject(form)).toEqual({
spiderTimeout: 0,
});
});
});
});
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