Commit 1f68649f authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by David O'Regan

Add DAST saved scans fields

Extends the on-demand scans form with new feature-flagged fields for the
upcoming saved scans feature.
parent f006fea3
......@@ -4,6 +4,9 @@ import {
GlButton,
GlCard,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink,
GlSkeletonLoader,
GlSprintf,
......@@ -14,6 +17,8 @@ import {
SCAN_TYPE,
} from 'ee/security_configuration/dast_scanner_profiles/constants';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { initFormField } from 'ee/security_configuration/utils';
import validation from '~/vue_shared/directives/validation';
import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { redirectTo } from '~/lib/utils/url_utility';
......@@ -26,6 +31,7 @@ import {
SITE_PROFILES_QUERY,
SITE_PROFILES_EXTENDED_QUERY,
} from '../settings';
import dastScanCreateMutation from '../graphql/dast_scan_create.mutation.graphql';
import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql';
import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
......@@ -53,6 +59,8 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) =>
export default {
SCAN_TYPE_LABEL,
saveAndRunScanBtnId: 'scan-submit-button',
saveScanBtnId: 'scan-save-button',
components: {
ProfileSelectorSummaryCell,
ScannerProfileSelector,
......@@ -61,12 +69,16 @@ export default {
GlButton,
GlCard,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink,
GlSkeletonLoader,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
validation: validation(),
},
mixins: [glFeatureFlagsMixin()],
apollo: {
......@@ -118,7 +130,20 @@ export default {
},
},
data() {
const savedScansFields = this.glFeatures.dastSavedScans
? {
form: {
showValidation: false,
state: false,
fields: {
name: initFormField({ value: '' }),
description: initFormField({ value: '', required: false, skipValidation: true }),
},
},
}
: {};
return {
...savedScansFields,
scannerProfiles: [],
siteProfiles: [],
selectedScannerProfileId: null,
......@@ -167,40 +192,74 @@ export default {
!this.isValidatedSiteProfile
);
},
isSubmitButtonDisabled() {
isFormInvalid() {
return this.someFieldEmpty || this.hasProfilesConflict;
},
isSubmitButtonDisabled() {
const {
isFormInvalid,
loading,
$options: { saveAndRunScanBtnId },
} = this;
return isFormInvalid || (loading && loading !== saveAndRunScanBtnId);
},
isSaveButtonDisabled() {
const {
isFormInvalid,
loading,
$options: { saveScanBtnId },
} = this;
return isFormInvalid || (loading && loading !== saveScanBtnId);
},
},
methods: {
onSubmit() {
this.loading = true;
onSubmit(runAfterCreate = true, button = this.$options.saveAndRunScanBtnId) {
if (this.glFeatures.dastSavedScans) {
this.form.showValidation = true;
if (!this.form.state) {
return;
}
}
this.loading = button;
this.hideErrors();
let mutation = dastOnDemandScanCreateMutation;
let reponseType = 'dastOnDemandScanCreate';
let input = {
fullPath: this.projectPath,
dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
};
if (this.glFeatures.dastSavedScans) {
mutation = dastScanCreateMutation;
reponseType = 'dastScanCreate';
input = {
...input,
name: this.form.fields.name.value,
description: this.form.fields.description.value,
runAfterCreate,
};
}
this.$apollo
.mutate({
mutation: dastOnDemandScanCreateMutation,
mutation,
variables: {
input: {
fullPath: this.projectPath,
dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
},
input,
},
})
.then(
({
data: {
dastOnDemandScanCreate: { pipelineUrl, errors },
},
}) => {
if (errors?.length) {
this.showErrors(ERROR_RUN_SCAN, errors);
this.loading = false;
} else {
redirectTo(pipelineUrl);
}
},
)
.then(({ data }) => {
const response = data[reponseType];
const { errors } = response;
if (errors?.length) {
this.showErrors(ERROR_RUN_SCAN, errors);
this.loading = false;
} else if (this.glFeatures.dastSavedScans && !runAfterCreate) {
redirectTo(response.dastScan.editPath);
} else {
redirectTo(response.pipelineUrl);
}
})
.catch((e) => {
Sentry.captureException(e);
this.showErrors(ERROR_RUN_SCAN);
......@@ -222,7 +281,7 @@ export default {
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<gl-form novalidate @submit.prevent="onSubmit()">
<header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<p>
......@@ -257,6 +316,12 @@ export default {
</gl-alert>
<template v-if="isLoadingProfiles">
<gl-skeleton-loader v-if="glFeatures.dastSavedScans" :width="1248" :height="180">
<rect x="0" y="0" width="100" height="15" rx="4" />
<rect x="0" y="24" width="460" height="32" rx="4" />
<rect x="0" y="71" width="100" height="15" rx="4" />
<rect x="0" y="95" width="460" height="72" rx="4" />
</gl-skeleton-loader>
<gl-card v-for="i in 2" :key="i" class="gl-mb-5">
<template #header>
<gl-skeleton-loader :width="1248" :height="15">
......@@ -272,6 +337,33 @@ export default {
</gl-card>
</template>
<template v-else-if="!failedToLoadProfiles">
<template v-if="glFeatures.dastSavedScans">
<gl-form-group
:label="s__('OnDemandScans|Scan name')"
:invalid-feedback="form.fields.name.feedback"
>
<gl-form-input
v-model="form.fields.name.value"
v-validation:[form.showValidation]
class="mw-460"
data-testid="dast-scan-name-input"
type="text"
:placeholder="s__('OnDemandScans|My daily scan')"
:state="form.fields.name.state"
name="name"
required
/>
</gl-form-group>
<gl-form-group :label="s__('OnDemandScans|Description')">
<gl-form-textarea
v-model="form.fields.description.value"
class="mw-460"
data-testid="dast-scan-description-input"
:placeholder="s__(`OnDemandScans|For example: Tests the login page for SQL injections`)"
:state="form.fields.description.state"
/>
</gl-form-group>
</template>
<scanner-profile-selector
v-model="selectedScannerProfileId"
class="gl-mb-5"
......@@ -390,9 +482,24 @@ export default {
class="js-no-auto-disable"
data-testid="on-demand-scan-submit-button"
:disabled="isSubmitButtonDisabled"
:loading="loading"
:loading="loading === $options.saveAndRunScanBtnId"
>
{{
glFeatures.dastSavedScans
? s__('OnDemandScans|Save and run scan')
: s__('OnDemandScans|Run scan')
}}
</gl-button>
<gl-button
v-if="glFeatures.dastSavedScans"
variant="success"
category="secondary"
data-testid="on-demand-scan-save-button"
:disabled="isSaveButtonDisabled"
:loading="loading === $options.saveScanBtnId"
@click="onSubmit(false, $options.saveScanBtnId)"
>
{{ s__('OnDemandScans|Run scan') }}
{{ s__('OnDemandScans|Save scan') }}
</gl-button>
</div>
</template>
......
mutation dastScanCreate(
$fullPath: ID!
$name: String!
$description: String
$dastSiteProfileId: DastSiteProfileID!
$dastScannerProfileID: DastScannerProfileID!
$runAfterCreate: Boolean
) {
dastScanCreate(
input: {
fullPath: $fullPath
name: $name
description: $description
dastSiteProfileId: $dastSiteProfileId
dastScannerProfileID: $dastScannerProfileID
runAfterCreate: $runAfterCreate
}
) {
dastScan {
editPath
}
pipelineUrl
errors
}
}
import { GlForm, GlSkeletonLoader } from '@gitlab/ui';
import { GlForm, GlFormInput, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
......@@ -6,9 +6,11 @@ import createApolloProvider from 'helpers/mock_apollo_helper';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import dastOnDemandScanCreate from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import dastScanCreateMutation from 'ee/on_demand_scans/graphql/dast_scan_create.mutation.graphql';
import dastOnDemandScanCreateMutation from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import { stubComponent } from 'helpers/stub_component';
import * as responses from '../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../mocks/mock_data';
import { redirectTo } from '~/lib/utils/url_utility';
......@@ -28,6 +30,7 @@ const defaultProps = {
};
const pipelineUrl = `/${projectPath}/pipelines/123`;
const editPath = `/${projectPath}/on_demand_scans/1/edit`;
const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
......@@ -41,18 +44,33 @@ describe('OnDemandScansForm', () => {
let subject;
let requestHandlers;
const GlFormInputStub = stubComponent(GlFormInput, {
template: '<input />',
});
const findForm = () => subject.find(GlForm);
const findByTestId = (testId) => subject.find(`[data-testid="${testId}"]`);
const findNameInput = () => findByTestId('dast-scan-name-input');
const findAlert = () => findByTestId('on-demand-scan-error');
const findProfilesConflictAlert = () => findByTestId('on-demand-scans-profiles-conflict-alert');
const findSubmitButton = () => findByTestId('on-demand-scan-submit-button');
const findSaveButton = () => findByTestId('on-demand-scan-save-button');
const setValidFormData = () => {
findNameInput().vm.$emit('input', 'My daily scan');
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
return subject.vm.$nextTick();
};
const setupSuccess = () => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockResolvedValue({
data: { dastScanCreate: { dastScan: { editPath }, pipelineUrl, errors: [] } },
});
return setValidFormData();
};
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const saveScan = () => findSaveButton().vm.$emit('click');
const createMockApolloProvider = (handlers) => {
localVue.use(VueApollo);
......@@ -100,6 +118,7 @@ describe('OnDemandScansForm', () => {
newSiteProfilePath,
glFeatures: {
securityOnDemandScansSiteValidation: true,
dastSavedScans: true,
},
},
},
......@@ -180,43 +199,74 @@ describe('OnDemandScansForm', () => {
scannerProfiles,
siteProfiles,
},
stubs: {
GlFormInput: GlFormInputStub,
},
});
});
describe('on success', () => {
beforeEach(async () => {
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
await setValidFormData();
submitForm();
});
describe.each`
action | actionFunction | submitButtonLoading | saveButtonLoading | runAfterCreate | redirectPath
${'submit'} | ${submitForm} | ${true} | ${false} | ${true} | ${pipelineUrl}
${'save'} | ${saveScan} | ${false} | ${true} | ${false} | ${editPath}
`(
'on $action',
({
actionFunction,
submitButtonLoading,
saveButtonLoading,
runAfterCreate,
redirectPath,
}) => {
describe('with valid form data', () => {
beforeEach(async () => {
await setupSuccess();
actionFunction();
});
it('sets loading state', () => {
expect(subject.vm.loading).toBe(true);
});
it('sets loading state on correct button', async () => {
const [submitButton, saveButton] = [findSubmitButton(), findSaveButton()];
it('triggers GraphQL mutation', () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastOnDemandScanCreate,
variables: {
input: {
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
},
},
expect(submitButton.props('loading')).toBe(submitButtonLoading);
expect(submitButton.props('disabled')).toBe(!submitButtonLoading);
expect(saveButton.props('loading')).toBe(saveButtonLoading);
expect(saveButton.props('disabled')).toBe(!saveButtonLoading);
});
it(`triggers GraphQL mutation with runAfterCreate set to ${runAfterCreate}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastScanCreateMutation,
variables: {
input: {
name: 'My daily scan',
description: '',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterCreate,
},
},
});
});
it('redirects to the URL provided in the response', async () => {
expect(redirectTo).toHaveBeenCalledWith(redirectPath);
});
it('does not show an alert', async () => {
expect(findAlert().exists()).toBe(false);
});
});
});
it('redirects to the URL provided in the response', () => {
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl);
});
it('does not run any mutation if name is empty', () => {
setValidFormData();
findNameInput().vm.$emit('input', '');
actionFunction();
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
expect(subject.vm.$apollo.mutate).not.toHaveBeenCalled();
});
},
);
describe('on top-level error', () => {
beforeEach(async () => {
......@@ -242,7 +292,7 @@ describe('OnDemandScansForm', () => {
beforeEach(async () => {
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl: null, errors } } });
.mockResolvedValue({ data: { dastScanCreate: { pipelineUrl: null, errors } } });
await setValidFormData();
submitForm();
});
......@@ -262,6 +312,44 @@ describe('OnDemandScansForm', () => {
});
});
describe('dastSavedScans feature flag disabled', () => {
beforeEach(async () => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
},
stubs: {
GlFormInput: GlFormInputStub,
},
provide: {
glFeatures: {
dastSavedScans: false,
},
},
});
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
submitForm();
});
it('triggers GraphQL mutation', () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastOnDemandScanCreateMutation,
variables: {
input: {
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
},
},
});
});
});
describe.each`
description | selectedScannerProfile | selectedSiteProfile | hasConflict
${'a passive scan and a non-validated site'} | ${passiveScannerProfile} | ${nonValidatedSiteProfile} | ${false}
......@@ -295,7 +383,7 @@ describe('OnDemandScansForm', () => {
},
);
describe('feature flag disabled', () => {
describe('securityOnDemandScansSiteValidation feature flag disabled', () => {
beforeEach(() => {
mountShallowSubject({
provide: {
......
......@@ -19559,12 +19559,21 @@ msgstr ""
msgid "OnDemandScans|Create a new site profile"
msgstr ""
msgid "OnDemandScans|Description"
msgstr ""
msgid "OnDemandScans|Edit on-demand DAST scan"
msgstr ""
msgid "OnDemandScans|For example: Tests the login page for SQL injections"
msgstr ""
msgid "OnDemandScans|Manage profiles"
msgstr ""
msgid "OnDemandScans|My daily scan"
msgstr ""
msgid "OnDemandScans|New on-demand DAST scan"
msgstr ""
......@@ -19583,6 +19592,15 @@ msgstr ""
msgid "OnDemandScans|Run scan"
msgstr ""
msgid "OnDemandScans|Save and run scan"
msgstr ""
msgid "OnDemandScans|Save scan"
msgstr ""
msgid "OnDemandScans|Scan name"
msgstr ""
msgid "OnDemandScans|Scanner profile"
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