Commit 0f1c4cba authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Phil Hughes

Add DAST Scanner Profiles Form

This change is to add MVC form for creating
DAST Scanner profiles in On-demand Scans
parent deb72ebb
...@@ -14,3 +14,40 @@ export const serializeForm = form => { ...@@ -14,3 +14,40 @@ export const serializeForm = form => {
return serializeFormEntries(entries); 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> <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 { export default {
name: 'DastScannerProfileForm', 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> </script>
<template> <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> </template>
...@@ -8,11 +8,20 @@ export default () => { ...@@ -8,11 +8,20 @@ export default () => {
return false; return false;
} }
const { projectFullPath, profilesLibraryPath } = el.dataset;
const props = {
projectFullPath,
profilesLibraryPath,
};
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
render(h) { 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 @@ ...@@ -3,4 +3,5 @@
- breadcrumb_title s_('DastProfiles|New scanner profile') - breadcrumb_title s_('DastProfiles|New scanner profile')
- page_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 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 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; 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( wrapper = mountFn(
DastScannerProfileForm, DastScannerProfileForm,
merge( merge(
...@@ -24,15 +53,197 @@ describe('DastScannerProfileForm', () => { ...@@ -24,15 +53,197 @@ describe('DastScannerProfileForm', () => {
), ),
); );
}; };
const createWrapper = wrapperFactory(); const createComponent = componentFactory();
const createFullComponent = componentFactory(mount);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('renders the title', () => { it('form renders properly', () => {
createWrapper(); createComponent();
expect(wrapper.html()).toContain('<h1>New Scanner Profile</h1>'); 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 ...@@ -11,4 +11,12 @@ RSpec.describe "projects/dast_scanner_profiles/new", type: :view do
it 'renders Vue app root' do it 'renders Vue app root' do
expect(rendered).to have_selector('.js-dast-scanner-profile-form') expect(rendered).to have_selector('.js-dast-scanner-profile-form')
end 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 end
...@@ -7731,6 +7731,9 @@ msgstr "" ...@@ -7731,6 +7731,9 @@ msgstr ""
msgid "DastProfiles|Are you sure you want to delete this profile?" msgid "DastProfiles|Are you sure you want to delete this profile?"
msgstr "" msgstr ""
msgid "DastProfiles|Could not create the scanner profile. Please try again."
msgstr ""
msgid "DastProfiles|Could not create the site profile. Please try again." msgid "DastProfiles|Could not create the site profile. Please try again."
msgstr "" msgstr ""
...@@ -7746,6 +7749,9 @@ msgstr "" ...@@ -7746,6 +7749,9 @@ msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again." msgid "DastProfiles|Could not update the site profile. Please try again."
msgstr "" msgstr ""
msgid "DastProfiles|Do you want to discard this scanner profile?"
msgstr ""
msgid "DastProfiles|Do you want to discard this site profile?" msgid "DastProfiles|Do you want to discard this site profile?"
msgstr "" msgstr ""
...@@ -7764,10 +7770,13 @@ msgstr "" ...@@ -7764,10 +7770,13 @@ msgstr ""
msgid "DastProfiles|Manage profiles" msgid "DastProfiles|Manage profiles"
msgstr "" 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 "" msgstr ""
msgid "DastProfiles|New Scanner Profile" msgid "DastProfiles|New Profile"
msgstr "" msgstr ""
msgid "DastProfiles|New scanner profile" msgid "DastProfiles|New scanner profile"
...@@ -7782,6 +7791,9 @@ msgstr "" ...@@ -7782,6 +7791,9 @@ msgstr ""
msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home" msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr "" msgstr ""
msgid "DastProfiles|Please enter a valid timeout value"
msgstr ""
msgid "DastProfiles|Profile name" msgid "DastProfiles|Profile name"
msgstr "" msgstr ""
...@@ -7800,9 +7812,15 @@ msgstr "" ...@@ -7800,9 +7812,15 @@ msgstr ""
msgid "DastProfiles|Site Profiles" msgid "DastProfiles|Site Profiles"
msgstr "" msgstr ""
msgid "DastProfiles|Spider timeout"
msgstr ""
msgid "DastProfiles|Target URL" msgid "DastProfiles|Target URL"
msgstr "" msgstr ""
msgid "DastProfiles|Target timeout"
msgstr ""
msgid "Data is still calculating..." msgid "Data is still calculating..."
msgstr "" msgstr ""
...@@ -21738,6 +21756,9 @@ msgstr "" ...@@ -21738,6 +21756,9 @@ msgstr ""
msgid "Secondary" msgid "Secondary"
msgstr "" msgstr ""
msgid "Seconds"
msgstr ""
msgid "Secret" msgid "Secret"
msgstr "" msgstr ""
......
import { serializeForm } from '~/lib/utils/forms'; import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms';
describe('lib/utils/forms', () => { describe('lib/utils/forms', () => {
const createDummyForm = inputs => { const createDummyForm = inputs => {
...@@ -93,4 +93,46 @@ describe('lib/utils/forms', () => { ...@@ -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