Commit 5ca13465 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Mark Florian

DAST site profile form

Implemented the basic form for creating a DAST site profile
parent 7a987cfe
<script>
import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlModal } from '@gitlab/ui';
import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
const initField = value => ({
value,
state: null,
feedback: null,
});
export default {
name: 'DastSiteProfileForm',
components: {
GlAlert,
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlModal,
},
props: {
fullPath: {
type: String,
required: true,
},
profilesLibraryPath: {
type: String,
required: true,
},
},
data() {
return {
form: {
profileName: initField(''),
targetUrl: initField(''),
},
loading: false,
showAlert: false,
};
},
computed: {
formData() {
return {
fullPath: this.fullPath,
...Object.fromEntries(Object.entries(this.form).map(([key, { value }]) => [key, value])),
};
},
formHasErrors() {
return Object.values(this.form).some(({ state }) => state === false);
},
someFieldEmpty() {
return Object.values(this.form).some(({ value }) => !value);
},
everyFieldEmpty() {
return Object.values(this.form).every(({ value }) => !value);
},
isSubmitDisabled() {
return this.formHasErrors || this.someFieldEmpty;
},
},
methods: {
validateTargetUrl() {
if (!isAbsolute(this.form.targetUrl.value)) {
this.form.targetUrl.state = false;
this.form.targetUrl.feedback = s__(
'DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home',
);
return;
}
this.form.targetUrl.state = true;
this.form.targetUrl.feedback = null;
},
onSubmit() {
this.loading = true;
this.showAlert = false;
this.$apollo
.mutate({
mutation: dastSiteProfileCreateMutation,
variables: this.formData,
})
.then(data => {
if (data.errors?.length > 0) {
throw new Error(data.errors);
}
redirectTo(this.profilesLibraryPath);
})
.catch(e => {
Sentry.captureException(e);
this.showAlert = true;
this.loading = false;
});
},
onCancelClicked() {
if (this.everyFieldEmpty) {
this.discard();
} else {
this.$refs[this.$options.modalId].show();
}
},
discard() {
redirectTo(this.profilesLibraryPath);
},
},
modalId: 'deleteDastProfileModal',
i18n: {
modalTitle: s__('DastProfiles|Do you want to discard this site profile?'),
modalOkTitle: __('Discard'),
modalCancelTitle: __('Cancel'),
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<gl-alert v-if="showAlert" variant="danger" @dismiss="showAlert = false">
{{ s__('DastProfiles|Could not create the site profile. Please try again.') }}
</gl-alert>
<h2 class="gl-mb-6">
{{ s__('DastProfiles|New site profile') }}
</h2>
<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 />
<gl-form-group
:invalid-feedback="form.targetUrl.feedback"
:label="s__('DastProfiles|Target URL')"
>
<gl-form-input
v-model="form.targetUrl.value"
class="mw-460"
data-testid="target-url-input"
type="url"
:state="form.targetUrl.state"
@input="validateTargetUrl"
/>
</gl-form-group>
<hr />
<div class="gl-mt-6 gl-pt-6">
<gl-button
type="submit"
variant="success"
class="js-no-auto-disable"
data-testid="dast-site-profile-form-submit-button"
:disabled="isSubmitDisabled"
:loading="loading"
>
{{ s__('DastProfiles|Save profile') }}
</gl-button>
<gl-button data-testid="dast-site-profile-form-cancel-button" @click="onCancelClicked">
{{ __('Cancel') }}
</gl-button>
</div>
<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-site-profile-form-cancel-modal"
@ok="discard()"
/>
</gl-form>
</template>
mutation dastSiteProfileCreate($fullPath: ID!, $profileName: String!, $targetUrl: String) {
dastSiteProfileCreate(
input: { fullPath: $fullPath, profileName: $profileName, targetUrl: $targetUrl }
) {
id
errors
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(),
});
import Vue from 'vue';
import apolloProvider from './graphql/provider';
import DastSiteProfileForm from './components/dast_site_profile_form.vue';
export default () => {
const el = document.querySelector('.js-dast-site-profile-form');
if (!el) {
return;
}
const { fullPath, profilesLibraryPath } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
render(h) {
return h(DastSiteProfileForm, {
props: {
fullPath,
profilesLibraryPath,
},
});
},
});
};
import initDastSiteProfileForm from 'ee/dast_site_profiles_form';
document.addEventListener('DOMContentLoaded', initDastSiteProfileForm);
......@@ -3,4 +3,5 @@
- breadcrumb_title s_('DastProfiles|New site profile')
- page_title s_('DastProfiles|New site profile')
%h1= s_('DastProfiles|New Site Profile')
.js-dast-site-profile-form{ data: { full_path: @project.path_with_namespace,
profiles_library_path: project_profiles_path(@project) } }
import merge from 'lodash/merge';
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlForm, GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import DastSiteProfileForm from 'ee/dast_site_profiles_form/components/dast_site_profile_form.vue';
import dastSiteProfileCreateMutation from 'ee/dast_site_profiles_form/graphql/dast_site_profile_create.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
redirectTo: jest.fn(),
}));
const fullPath = 'group/project';
const profilesLibraryPath = `${TEST_HOST}/${fullPath}/-/on_demand_scans/profiles`;
const profileName = 'My DAST site profile';
const targetUrl = 'http://example.com';
const defaultProps = {
profilesLibraryPath,
fullPath,
};
describe('OnDemandScansApp', () => {
let wrapper;
const findForm = () => wrapper.find(GlForm);
const findProfileNameInput = () => wrapper.find('[data-testid="profile-name-input"]');
const findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]');
const findSubmitButton = () =>
wrapper.find('[data-testid="dast-site-profile-form-submit-button"]');
const findCancelButton = () =>
wrapper.find('[data-testid="dast-site-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(
DastSiteProfileForm,
merge(
{},
{
propsData: defaultProps,
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
},
options,
),
);
};
const createComponent = componentFactory();
const createFullComponent = componentFactory(mount);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders properly', () => {
createComponent();
expect(wrapper.isVueInstance()).toBe(true);
});
describe('submit button', () => {
beforeEach(() => {
createComponent();
});
describe('is disabled if', () => {
it('form contains errors', async () => {
findProfileNameInput().vm.$emit('input', profileName);
await findTargetUrlInput().vm.$emit('input', 'invalid URL');
expect(findSubmitButton().props('disabled')).toBe(true);
});
it('at least one field is empty', async () => {
findProfileNameInput().vm.$emit('input', '');
await findTargetUrlInput().vm.$emit('input', targetUrl);
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 findTargetUrlInput().vm.$emit('input', targetUrl);
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
});
describe('target URL input', () => {
const errorMessage = 'Please enter a valid URL format, ex: http://www.example.com/home';
beforeEach(() => {
createFullComponent();
});
it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => {
findTargetUrlInput().setValue(value);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(errorMessage);
});
it('is marked as valid provided a valid URL', async () => {
findTargetUrlInput().setValue(targetUrl);
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
});
describe('submission', () => {
const createdProfileId = 30203;
describe('on success', () => {
beforeEach(() => {
createComponent();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastSiteProfileCreate: { id: createdProfileId } } });
findProfileNameInput().vm.$emit('input', profileName);
findTargetUrlInput().vm.$emit('input', targetUrl);
submitForm();
});
it('sets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(true);
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastSiteProfileCreateMutation,
variables: {
profileName,
targetUrl,
fullPath,
},
});
});
it('redirects to the profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
describe('on error', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false);
});
it('shows an error alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
});
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(() => {
findTargetUrlInput().setValue(targetUrl);
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);
});
});
});
});
......@@ -8,7 +8,15 @@ RSpec.describe "projects/dast_site_profiles/new", type: :view do
render
end
it 'renders a placeholder title' do
expect(rendered).to have_content('New Site Profile')
it 'renders Vue app root' do
expect(rendered).to have_selector('.js-dast-site-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
......@@ -7380,15 +7380,30 @@ msgstr ""
msgid "Dashboard|Unable to add %{invalidProjects}. This dashboard is available for public projects, and private projects in groups with a Silver plan."
msgstr ""
msgid "DastProfiles|Manage profiles"
msgid "DastProfiles|Could not create the site profile. Please try again."
msgstr ""
msgid "DastProfiles|Do you want to discard this site profile?"
msgstr ""
msgid "DastProfiles|New Site Profile"
msgid "DastProfiles|Manage profiles"
msgstr ""
msgid "DastProfiles|New site profile"
msgstr ""
msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr ""
msgid "DastProfiles|Profile name"
msgstr ""
msgid "DastProfiles|Save profile"
msgstr ""
msgid "DastProfiles|Target URL"
msgstr ""
msgid "Data is still calculating..."
msgstr ""
......@@ -8164,6 +8179,9 @@ msgstr ""
msgid "Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them."
msgstr ""
msgid "Discard"
msgstr ""
msgid "Discard all changes"
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