Use site profiles in on-demand DAST scans

- Added the ability to use existing site profiles when running new
on-demand DAST scans
- Updated/added specs
parent d429e468
...@@ -29,6 +29,14 @@ export default { ...@@ -29,6 +29,14 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
profilesLibraryPath: {
type: String,
required: true,
},
newSiteProfilePath: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -46,6 +54,8 @@ export default { ...@@ -46,6 +54,8 @@ export default {
:help-page-path="helpPagePath" :help-page-path="helpPagePath"
:project-path="projectPath" :project-path="projectPath"
:default-branch="defaultBranch" :default-branch="defaultBranch"
:profiles-library-path="profilesLibraryPath"
:new-site-profile-path="newSiteProfilePath"
@cancel="showForm = false" @cancel="showForm = false"
/> />
<on-demand-scans-form-old <on-demand-scans-form-old
......
<script> <script>
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale'; import { s__ } from '~/locale';
import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import { import {
GlAlert,
GlButton, GlButton,
GlCard,
GlForm, GlForm,
GlFormGroup, GlFormGroup,
GlFormInput,
GlIcon, GlIcon,
GlLink, GlLink,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlSkeletonLoader,
GlSprintf, GlSprintf,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import runDastScanMutation from '../graphql/run_dast_scan.mutation.graphql'; import dastSiteProfilesQuery from 'ee/dast_profiles/graphql/dast_site_profiles.query.graphql';
import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql';
import { SCAN_TYPES } from '../constants'; import { SCAN_TYPES } from '../constants';
const ERROR_RUN_SCAN = 'ERROR_RUN_SCAN';
const ERROR_FETCH_SITE_PROFILES = 'ERROR_FETCH_SITE_PROFILES';
const ERROR_MESSAGES = {
[ERROR_RUN_SCAN]: s__('OnDemandScans|Could not run the scan. Please try again.'),
[ERROR_FETCH_SITE_PROFILES]: s__(
'OnDemandScans|Could not fetch site profiles. Please try again.',
),
};
const initField = value => ({ const initField = value => ({
value, value,
state: null, state: null,
...@@ -24,17 +38,39 @@ const initField = value => ({ ...@@ -24,17 +38,39 @@ const initField = value => ({
export default { export default {
components: { components: {
GlAlert,
GlButton, GlButton,
GlCard,
GlForm, GlForm,
GlFormGroup, GlFormGroup,
GlFormInput,
GlIcon, GlIcon,
GlLink, GlLink,
GlDropdown,
GlDropdownItem,
GlSkeletonLoader,
GlSprintf, GlSprintf,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
apollo: {
siteProfiles: {
query: dastSiteProfilesQuery,
variables() {
return {
fullPath: this.projectPath,
};
},
update(data) {
const siteProfileEdges = data?.project?.siteProfiles?.edges ?? [];
return siteProfileEdges.map(({ node }) => node);
},
error(e) {
Sentry.captureException(e);
this.showErrors(ERROR_FETCH_SITE_PROFILES);
},
},
},
props: { props: {
helpPagePath: { helpPagePath: {
type: String, type: String,
...@@ -48,21 +84,42 @@ export default { ...@@ -48,21 +84,42 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
profilesLibraryPath: {
type: String,
required: true,
},
newSiteProfilePath: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
siteProfiles: [],
form: { form: {
scanType: initField(SCAN_TYPES.PASSIVE), scanType: initField(SCAN_TYPES.PASSIVE),
branch: initField(this.defaultBranch), branch: initField(this.defaultBranch),
targetUrl: initField(''), dastSiteProfileId: initField(null),
}, },
loading: false, loading: false,
errorType: null,
errors: [],
showAlert: false,
}; };
}, },
computed: { computed: {
errorMessage() {
return ERROR_MESSAGES[this.errorType] || null;
},
isLoadingProfiles() {
return this.$apollo.queries.siteProfiles.loading;
},
failedToLoadProfiles() {
return [ERROR_FETCH_SITE_PROFILES].includes(this.errorType);
},
formData() { formData() {
return { return {
projectPath: this.projectPath, fullPath: this.projectPath,
...Object.fromEntries(Object.entries(this.form).map(([key, { value }]) => [key, value])), ...Object.fromEntries(Object.entries(this.form).map(([key, { value }]) => [key, value])),
}; };
}, },
...@@ -75,37 +132,35 @@ export default { ...@@ -75,37 +132,35 @@ export default {
isSubmitDisabled() { isSubmitDisabled() {
return this.formHasErrors || this.someFieldEmpty; return this.formHasErrors || this.someFieldEmpty;
}, },
selectedSiteProfile() {
const selectedSiteProfileId = this.form.dastSiteProfileId.value;
return selectedSiteProfileId === null
? null
: this.siteProfiles.find(({ id }) => id === selectedSiteProfileId);
},
siteProfileText() {
const { selectedSiteProfile } = this;
return selectedSiteProfile
? `${selectedSiteProfile.profileName}: ${selectedSiteProfile.targetUrl}`
: s__('OnDemandScans|Select one of the existing profiles');
},
}, },
methods: { methods: {
validateTargetUrl() { setSiteProfile({ id }) {
let [state, feedback] = [true, null]; this.form.dastSiteProfileId.value = id;
const { value: targetUrl } = this.form.targetUrl;
if (!isAbsolute(targetUrl)) {
state = false;
feedback = s__(
'OnDemandScans|Please enter a valid URL format, ex: http://www.example.com/home',
);
}
this.form.targetUrl = {
...this.form.targetUrl,
state,
feedback,
};
}, },
onSubmit() { onSubmit() {
this.loading = true; this.loading = true;
this.hideErrors();
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: runDastScanMutation, mutation: dastOnDemandScanCreateMutation,
variables: this.formData, variables: this.formData,
}) })
.then(({ data: { runDastScan: { pipelineUrl, errors } } }) => { .then(({ data: { dastOnDemandScanCreate: { pipelineUrl, errors } } }) => {
if (errors?.length) { if (errors?.length) {
createFlash( this.showErrors(ERROR_RUN_SCAN, errors);
sprintf(s__('OnDemandScans|Could not run the scan: %{backendErrorMessage}'), {
backendErrorMessage: errors.join(', '),
}),
);
this.loading = false; this.loading = false;
} else { } else {
redirectTo(pipelineUrl); redirectTo(pipelineUrl);
...@@ -113,10 +168,20 @@ export default { ...@@ -113,10 +168,20 @@ export default {
}) })
.catch(e => { .catch(e => {
Sentry.captureException(e); Sentry.captureException(e);
createFlash(s__('OnDemandScans|Could not run the scan. Please try again.')); this.showErrors(ERROR_RUN_SCAN);
this.loading = false; this.loading = false;
}); });
}, },
showErrors(errorType, errors = []) {
this.errorType = errorType;
this.errors = errors;
this.showAlert = true;
},
hideErrors() {
this.errorType = null;
this.errors = [];
this.showAlert = false;
},
}, },
}; };
</script> </script>
...@@ -126,7 +191,6 @@ export default { ...@@ -126,7 +191,6 @@ export default {
<header class="gl-mb-6"> <header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2> <h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<p> <p>
<gl-icon name="information-o" class="gl-vertical-align-text-bottom gl-text-gray-400" />
<gl-sprintf <gl-sprintf
:message=" :message="
s__( s__(
...@@ -143,51 +207,140 @@ export default { ...@@ -143,51 +207,140 @@ export default {
</p> </p>
</header> </header>
<gl-form-group> <gl-alert
v-if="showAlert"
variant="danger"
class="gl-mb-5"
data-testid="on-demand-scan-error"
:dismissible="!failedToLoadProfiles"
@dismiss="hideErrors"
>
{{ errorMessage }}
<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>
<template v-if="isLoadingProfiles">
<gl-card v-for="i in 2" :key="i">
<template #header>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="300" height="15" rx="4" />
</gl-skeleton-loader>
</template>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="600" height="15" rx="4" />
</gl-skeleton-loader>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="300" height="15" rx="4" />
</gl-skeleton-loader>
</gl-card>
</template>
<template v-else-if="!failedToLoadProfiles">
<gl-card>
<template #header>
<h3 class="gl-font-lg gl-display-inline">{{ s__('OnDemandScans|Scanner settings') }}</h3>
</template>
<gl-form-group class="gl-mt-4">
<template #label> <template #label>
{{ s__('OnDemandScans|Scan mode') }} {{ s__('OnDemandScans|Scan mode') }}
<gl-icon <gl-icon
v-gl-tooltip.hover v-gl-tooltip.hover
name="information-o" name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400" class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Only a passive scan can be performed on demand.')" :title="s__('OnDemandScans|Only a passive scan can be performed on demand.')"
/> />
</template> </template>
{{ s__('OnDemandScans|Passive DAST Scan') }} {{ s__('OnDemandScans|Passive') }}
</gl-form-group> </gl-form-group>
<gl-form-group> <gl-form-group class="gl-mt-7 gl-mb-2">
<template #label> <template #label>
{{ s__('OnDemandScans|Attached branch') }} {{ s__('OnDemandScans|Attached branch') }}
<gl-icon <gl-icon
v-gl-tooltip.hover v-gl-tooltip.hover
name="information-o" name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400" class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Attached branch is where the scan job runs.')" :title="s__('OnDemandScans|Attached branch is where the scan job runs.')"
/> />
</template> </template>
{{ defaultBranch }} {{ defaultBranch }}
</gl-form-group> </gl-form-group>
</gl-card>
<gl-form-group :invalid-feedback="form.targetUrl.feedback"> <gl-card>
<template #header>
<div class="row">
<div class="col-7">
<h3 class="gl-font-lg gl-display-inline">{{ s__('OnDemandScans|Site profiles') }}</h3>
</div>
<div class="col-5 gl-text-right">
<gl-button
:href="siteProfiles.length ? profilesLibraryPath : null"
:disabled="!siteProfiles.length"
variant="success"
category="secondary"
size="small"
data-testid="manage-site-profiles-button"
>
{{ s__('OnDemandScans|Manage profiles') }}
</gl-button>
</div>
</div>
</template>
<gl-form-group v-if="siteProfiles.length">
<template #label> <template #label>
{{ s__('OnDemandScans|Target URL') }} {{ s__('OnDemandScans|Use existing site profile') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-400"
:title="s__('OnDemandScans|DAST will scan the target URL and any discovered sub URLs.')"
/>
</template> </template>
<gl-form-input <gl-dropdown
v-model="form.targetUrl.value" v-model="form.dastSiteProfileId.value"
:text="siteProfileText"
class="mw-460" class="mw-460"
data-testid="target-url-input" data-testid="site-profiles-dropdown"
type="url" >
:state="form.targetUrl.state" <gl-dropdown-item
@input="validateTargetUrl" v-for="siteProfile in siteProfiles"
/> :key="siteProfile.id"
:is-checked="form.dastSiteProfileId.value === siteProfile.id"
is-check-item
@click="setSiteProfile(siteProfile)"
>
{{ siteProfile.profileName }}
</gl-dropdown-item>
</gl-dropdown>
<template v-if="selectedSiteProfile">
<hr />
<div class="row" data-testid="site-profile-summary">
<div class="col-md-6">
<div class="row">
<div class="col-md-3">{{ s__('DastProfiles|Target URL') }}:</div>
<div class="col-md-9 gl-font-weight-bold">
{{ selectedSiteProfile.targetUrl }}
</div>
</div>
</div>
</div>
</template>
</gl-form-group> </gl-form-group>
<template v-else>
<p class="gl-text-gray-700">
{{
s__(
'OnDemandScans|No profile yet. In order to create a new scan, you need to have at least one completed site profile.',
)
}}
</p>
<gl-button
:href="newSiteProfilePath"
variant="success"
category="secondary"
data-testid="create-site-profile-link"
>
{{ s__('OnDemandScans|Create a new site profile') }}
</gl-button>
</template>
</gl-card>
<div class="gl-mt-6 gl-pt-6"> <div class="gl-mt-6 gl-pt-6">
<gl-button <gl-button
...@@ -197,11 +350,12 @@ export default { ...@@ -197,11 +350,12 @@ export default {
:disabled="isSubmitDisabled" :disabled="isSubmitDisabled"
:loading="loading" :loading="loading"
> >
{{ s__('OnDemandScans|Run this scan') }} {{ s__('OnDemandScans|Run scan') }}
</gl-button> </gl-button>
<gl-button @click="$emit('cancel')"> <gl-button data-testid="on-demand-scan-cancel-button" @click="$emit('cancel')">
{{ __('Cancel') }} {{ __('Cancel') }}
</gl-button> </gl-button>
</div> </div>
</template>
</gl-form> </gl-form>
</template> </template>
mutation dastOnDemandScanCreate($fullPath: ID!, $dastSiteProfileId: DastSiteProfileID!) {
dastOnDemandScanCreate(input: { fullPath: $fullPath, dastSiteProfileId: $dastSiteProfileId }) {
pipelineUrl
errors
}
}
...@@ -5,13 +5,19 @@ import OnDemandScansApp from './components/on_demand_scans_app.vue'; ...@@ -5,13 +5,19 @@ import OnDemandScansApp from './components/on_demand_scans_app.vue';
export default () => { export default () => {
const el = document.querySelector('#js-on-demand-scans-app'); const el = document.querySelector('#js-on-demand-scans-app');
if (!el) { if (!el) {
return; return null;
} }
const { helpPagePath, emptyStateSvgPath, projectPath, defaultBranch } = el.dataset; const {
helpPagePath,
emptyStateSvgPath,
projectPath,
defaultBranch,
profilesLibraryPath,
newSiteProfilePath,
} = el.dataset;
// eslint-disable-next-line no-new return new Vue({
new Vue({
el, el,
apolloProvider, apolloProvider,
render(h) { render(h) {
...@@ -21,6 +27,8 @@ export default () => { ...@@ -21,6 +27,8 @@ export default () => {
emptyStateSvgPath, emptyStateSvgPath,
projectPath, projectPath,
defaultBranch, defaultBranch,
profilesLibraryPath,
newSiteProfilePath,
}, },
}); });
}, },
......
...@@ -6,7 +6,9 @@ module Projects::OnDemandScansHelper ...@@ -6,7 +6,9 @@ module Projects::OnDemandScansHelper
'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'), 'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'),
'default-branch' => project.default_branch, 'default-branch' => project.default_branch,
'project-path' => project.path_with_namespace 'project-path' => project.path_with_namespace,
'profiles-library-path': project_profiles_path(project),
'new-site-profile-path': new_project_dast_site_profile_path(project)
} }
end end
end end
...@@ -10,6 +10,8 @@ const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-sca ...@@ -10,6 +10,8 @@ const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-sca
const projectPath = 'group/project'; const projectPath = 'group/project';
const defaultBranch = 'master'; const defaultBranch = 'master';
const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-empty-state.svg`; const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-empty-state.svg`;
const profilesLibraryPath = `${TEST_HOST}/${projectPath}/-/on_demand_scans/profiles`;
const newSiteProfilePath = `${TEST_HOST}/${projectPath}/-/on_demand_scans/profiles`;
describe('OnDemandScansApp', () => { describe('OnDemandScansApp', () => {
let wrapper; let wrapper;
...@@ -32,6 +34,8 @@ describe('OnDemandScansApp', () => { ...@@ -32,6 +34,8 @@ describe('OnDemandScansApp', () => {
projectPath, projectPath,
defaultBranch, defaultBranch,
emptyStateSvgPath, emptyStateSvgPath,
profilesLibraryPath,
newSiteProfilePath,
}, },
}, },
options, options,
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlForm } from '@gitlab/ui'; import { GlForm } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue'; import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form_old.vue';
import runDastScanMutation from 'ee/on_demand_scans/graphql/run_dast_scan.mutation.graphql'; import runDastScanMutation from 'ee/on_demand_scans/graphql/run_dast_scan.mutation.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
......
import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash';
import { GlForm } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils';
import { GlForm, GlSkeletonLoader } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue'; import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import runDastScanMutation from 'ee/on_demand_scans/graphql/run_dast_scan.mutation.graphql'; import dastOnDemandScanCreate from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`; const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
const projectPath = 'group/project'; const projectPath = 'group/project';
const defaultBranch = 'master'; const defaultBranch = 'master';
const profilesLibraryPath = `${TEST_HOST}/${projectPath}/-/on_demand_scans/profiles`;
const newSiteProfilePath = `${TEST_HOST}/${projectPath}/-/on_demand_scans/profiles`;
const targetUrl = 'http://example.com'; const defaultProps = {
helpPagePath,
projectPath,
defaultBranch,
profilesLibraryPath,
newSiteProfilePath,
};
const siteProfiles = [
{ id: 1, profileName: 'My first site profile', targetUrl: 'https://example.com' },
{ id: 2, profileName: 'My second site profile', targetUrl: 'https://foo.bar' },
];
const pipelineUrl = `${TEST_HOST}/${projectPath}/pipelines/123`; const pipelineUrl = `${TEST_HOST}/${projectPath}/pipelines/123`;
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute, isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
redirectTo: jest.fn(), redirectTo: jest.fn(),
...@@ -23,26 +35,42 @@ describe('OnDemandScansApp', () => { ...@@ -23,26 +35,42 @@ describe('OnDemandScansApp', () => {
let wrapper; let wrapper;
const findForm = () => wrapper.find(GlForm); const findForm = () => wrapper.find(GlForm);
const findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]'); const findSiteProfilesDropdown = () => wrapper.find('[data-testid="site-profiles-dropdown"]');
const findManageSiteProfilesButton = () =>
wrapper.find('[data-testid="manage-site-profiles-button"]');
const findCreateNewSiteProfileLink = () =>
wrapper.find('[data-testid="create-site-profile-link"]');
const findAlert = () => wrapper.find('[data-testid="on-demand-scan-error"]');
const findCancelButton = () => wrapper.find('[data-testid="on-demand-scan-cancel-button"]');
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} }); const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const createComponent = ({ props = {}, computed = {} } = {}) => { const wrapperFactory = (mountFn = shallowMount) => (options = {}) => {
wrapper = shallowMount(OnDemandScansForm, { wrapper = mountFn(
attachToDocument: true, OnDemandScansForm,
propsData: { merge(
helpPagePath, {},
projectPath, {
defaultBranch, propsData: defaultProps,
...props,
},
computed,
mocks: { mocks: {
$apollo: { $apollo: {
mutate: jest.fn(), mutate: jest.fn(),
queries: {
siteProfiles: {},
}, },
}, },
}); },
},
options,
{
data() {
return { ...options.data };
},
},
),
);
}; };
const createComponent = wrapperFactory();
const createFullComponent = wrapperFactory(mount);
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -61,15 +89,15 @@ describe('OnDemandScansApp', () => { ...@@ -61,15 +89,15 @@ describe('OnDemandScansApp', () => {
describe('formData', () => { describe('formData', () => {
it('returns an object with a key:value mapping from the form object including the project path', () => { it('returns an object with a key:value mapping from the form object including the project path', () => {
wrapper.vm.form = { wrapper.vm.form = {
targetUrl: { siteProfileId: {
value: targetUrl, value: siteProfiles[0],
state: null, state: null,
feedback: '', feedback: '',
}, },
}; };
expect(wrapper.vm.formData).toEqual({ expect(wrapper.vm.formData).toEqual({
projectPath, fullPath: projectPath,
targetUrl, siteProfileId: siteProfiles[0],
}); });
}); });
}); });
...@@ -77,8 +105,8 @@ describe('OnDemandScansApp', () => { ...@@ -77,8 +105,8 @@ describe('OnDemandScansApp', () => {
describe('formHasErrors', () => { describe('formHasErrors', () => {
it('returns true if any of the fields are invalid', () => { it('returns true if any of the fields are invalid', () => {
wrapper.vm.form = { wrapper.vm.form = {
targetUrl: { siteProfileId: {
value: targetUrl, value: siteProfiles[0],
state: false, state: false,
feedback: '', feedback: '',
}, },
...@@ -92,8 +120,8 @@ describe('OnDemandScansApp', () => { ...@@ -92,8 +120,8 @@ describe('OnDemandScansApp', () => {
it('returns false if none of the fields are invalid', () => { it('returns false if none of the fields are invalid', () => {
wrapper.vm.form = { wrapper.vm.form = {
targetUrl: { siteProfileId: {
value: targetUrl, value: siteProfiles[0],
state: null, state: null,
feedback: '', feedback: '',
}, },
...@@ -109,7 +137,7 @@ describe('OnDemandScansApp', () => { ...@@ -109,7 +137,7 @@ describe('OnDemandScansApp', () => {
describe('someFieldEmpty', () => { describe('someFieldEmpty', () => {
it('returns true if any of the fields are empty', () => { it('returns true if any of the fields are empty', () => {
wrapper.vm.form = { wrapper.vm.form = {
targetUrl: { siteProfileId: {
value: '', value: '',
state: false, state: false,
feedback: '', feedback: '',
...@@ -124,8 +152,8 @@ describe('OnDemandScansApp', () => { ...@@ -124,8 +152,8 @@ describe('OnDemandScansApp', () => {
it('returns false if no field is empty', () => { it('returns false if no field is empty', () => {
wrapper.vm.form = { wrapper.vm.form = {
targetUrl: { siteProfileId: {
value: targetUrl, value: siteProfiles[0],
state: null, state: null,
feedback: '', feedback: '',
}, },
...@@ -161,42 +189,88 @@ describe('OnDemandScansApp', () => { ...@@ -161,42 +189,88 @@ describe('OnDemandScansApp', () => {
}); });
}); });
describe('target URL input', () => { describe('site profiles', () => {
it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => { describe('while site profiles are being fetched', () => {
const input = findTargetUrlInput(); beforeEach(() => {
input.vm.$emit('input', value); createComponent({ mocks: { $apollo: { queries: { siteProfiles: { loading: true } } } } });
await wrapper.vm.$nextTick(); });
expect(wrapper.vm.form.targetUrl).toEqual({ it('shows a skeleton loader', () => {
value, expect(wrapper.contains(GlSkeletonLoader)).toBe(true);
state: false, });
feedback: 'Please enter a valid URL format, ex: http://www.example.com/home', });
describe('when site profiles could not be fetched', () => {
beforeEach(() => {
createComponent();
return wrapper.vm.showErrors('ERROR_FETCH_SITE_PROFILES');
}); });
expect(input.attributes().state).toBeUndefined();
it('shows a non-dismissible alert and no field', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.props('dismissible')).toBe(false);
expect(alert.text()).toContain('Could not fetch site profiles. Please try again.');
});
});
describe('when there are no site profiles yet', () => {
beforeEach(() => {
createFullComponent();
});
it('disables the link to manage site profiles', () => {
expect(findManageSiteProfilesButton().props('disabled')).toBe(true);
}); });
it('is marked as valid provided a valid URL', async () => { it('shows a link to create a new site profile', () => {
const input = findTargetUrlInput(); const link = findCreateNewSiteProfileLink();
input.vm.$emit('input', targetUrl); expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(newSiteProfilePath);
});
});
describe('when there are site profiles', () => {
beforeEach(() => {
createComponent({
data: {
siteProfiles,
},
});
});
it('shows a dropdown containing the site profiles', () => {
const dropdown = findSiteProfilesDropdown();
expect(dropdown.exists()).toBe(true);
expect(dropdown.element.children).toHaveLength(siteProfiles.length);
});
it('when a site profile is selected, its summary is displayed below the dropdown', async () => {
wrapper.vm.form.dastSiteProfileId.value = siteProfiles[0].id;
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const summary = wrapper.find('[data-testid="site-profile-summary"]');
expect(wrapper.vm.form.targetUrl).toEqual({ expect(summary.exists()).toBe(true);
value: targetUrl, expect(summary.text()).toContain(siteProfiles[0].targetUrl);
state: true,
feedback: null,
}); });
expect(input.attributes().state).toBe('true');
}); });
}); });
describe('submission', () => { describe('submission', () => {
beforeEach(() => {
createComponent({
data: {
siteProfiles,
},
});
});
describe('on success', () => { describe('on success', () => {
beforeEach(async () => { beforeEach(() => {
jest jest
.spyOn(wrapper.vm.$apollo, 'mutate') .spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { runDastScan: { pipelineUrl, errors: [] } } }); .mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
const input = findTargetUrlInput(); findSiteProfilesDropdown().vm.$emit('input', siteProfiles[0]);
input.vm.$emit('input', targetUrl);
submitForm(); submitForm();
}); });
...@@ -206,12 +280,12 @@ describe('OnDemandScansApp', () => { ...@@ -206,12 +280,12 @@ describe('OnDemandScansApp', () => {
it('triggers GraphQL mutation', () => { it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runDastScanMutation, mutation: dastOnDemandScanCreate,
variables: { variables: {
scanType: 'PASSIVE', scanType: 'PASSIVE',
branch: 'master', branch: 'master',
targetUrl, dastSiteProfileId: siteProfiles[0],
projectPath, fullPath: projectPath,
}, },
}); });
}); });
...@@ -219,13 +293,16 @@ describe('OnDemandScansApp', () => { ...@@ -219,13 +293,16 @@ describe('OnDemandScansApp', () => {
it('redirects to the URL provided in the response', () => { it('redirects to the URL provided in the response', () => {
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl); expect(redirectTo).toHaveBeenCalledWith(pipelineUrl);
}); });
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
}); });
describe('on top-level error', () => { describe('on top-level error', () => {
beforeEach(async () => { beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetUrlInput(); findSiteProfilesDropdown().vm.$emit('input', siteProfiles[0]);
input.vm.$emit('input', targetUrl);
submitForm(); submitForm();
}); });
...@@ -233,19 +310,21 @@ describe('OnDemandScansApp', () => { ...@@ -233,19 +310,21 @@ describe('OnDemandScansApp', () => {
expect(wrapper.vm.loading).toBe(false); expect(wrapper.vm.loading).toBe(false);
}); });
it('shows an error flash', () => { it('shows an alert', () => {
expect(createFlash).toHaveBeenCalledWith('Could not run the scan. Please try again.'); const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('Could not run the scan. Please try again.');
}); });
}); });
describe('on errors as data', () => { describe('on errors as data', () => {
beforeEach(async () => { const errors = ['error#1', 'error#2', 'error#3'];
const errors = ['A', 'B', 'C'];
beforeEach(() => {
jest jest
.spyOn(wrapper.vm.$apollo, 'mutate') .spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { runDastScan: { pipelineUrl: null, errors } } }); .mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl: null, errors } } });
const input = findTargetUrlInput(); findSiteProfilesDropdown().vm.$emit('input', siteProfiles[0]);
input.vm.$emit('input', targetUrl);
submitForm(); submitForm();
}); });
...@@ -253,9 +332,23 @@ describe('OnDemandScansApp', () => { ...@@ -253,9 +332,23 @@ describe('OnDemandScansApp', () => {
expect(wrapper.vm.loading).toBe(false); expect(wrapper.vm.loading).toBe(false);
}); });
it('shows an error flash', () => { it('shows an alert with the returned errors', () => {
expect(createFlash).toHaveBeenCalledWith('Could not run the scan: A, B, C'); const alert = findAlert();
expect(alert.exists()).toBe(true);
errors.forEach(error => {
expect(alert.text()).toContain(error);
});
});
}); });
}); });
describe('cancel', () => {
it('emits cancel event on click', () => {
jest.spyOn(wrapper.vm, '$emit');
findCancelButton().vm.$emit('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('cancel');
});
}); });
}); });
...@@ -11,7 +11,9 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -11,7 +11,9 @@ RSpec.describe Projects::OnDemandScansHelper do
'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'), 'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'),
'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'),
'default-branch' => project.default_branch, 'default-branch' => project.default_branch,
'project-path' => project.path_with_namespace 'project-path' => project.path_with_namespace,
'profiles-library-path': project_profiles_path(project),
'new-site-profile-path': new_project_dast_site_profile_path(project)
) )
end end
end end
......
...@@ -16655,21 +16655,33 @@ msgstr "" ...@@ -16655,21 +16655,33 @@ msgstr ""
msgid "OnDemandScans|Attached branch is where the scan job runs." msgid "OnDemandScans|Attached branch is where the scan job runs."
msgstr "" msgstr ""
msgid "OnDemandScans|Could not fetch site profiles. Please try again."
msgstr ""
msgid "OnDemandScans|Could not run the scan. Please try again." msgid "OnDemandScans|Could not run the scan. Please try again."
msgstr "" msgstr ""
msgid "OnDemandScans|Could not run the scan: %{backendErrorMessage}" msgid "OnDemandScans|Could not run the scan: %{backendErrorMessage}"
msgstr "" msgstr ""
msgid "OnDemandScans|Create a new site profile"
msgstr ""
msgid "OnDemandScans|Create new DAST scan" msgid "OnDemandScans|Create new DAST scan"
msgstr "" msgstr ""
msgid "OnDemandScans|DAST will scan the target URL and any discovered sub URLs." msgid "OnDemandScans|DAST will scan the target URL and any discovered sub URLs."
msgstr "" msgstr ""
msgid "OnDemandScans|Manage profiles"
msgstr ""
msgid "OnDemandScans|New on-demand DAST scan" msgid "OnDemandScans|New on-demand DAST scan"
msgstr "" msgstr ""
msgid "OnDemandScans|No profile yet. In order to create a new scan, you need to have at least one completed site profile."
msgstr ""
msgid "OnDemandScans|On-demand Scans" msgid "OnDemandScans|On-demand Scans"
msgstr "" msgstr ""
...@@ -16679,24 +16691,42 @@ msgstr "" ...@@ -16679,24 +16691,42 @@ msgstr ""
msgid "OnDemandScans|Only a passive scan can be performed on demand." msgid "OnDemandScans|Only a passive scan can be performed on demand."
msgstr "" msgstr ""
msgid "OnDemandScans|Passive"
msgstr ""
msgid "OnDemandScans|Passive DAST Scan" msgid "OnDemandScans|Passive DAST Scan"
msgstr "" msgstr ""
msgid "OnDemandScans|Please enter a valid URL format, ex: http://www.example.com/home" msgid "OnDemandScans|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr "" msgstr ""
msgid "OnDemandScans|Run scan"
msgstr ""
msgid "OnDemandScans|Run this scan" msgid "OnDemandScans|Run this scan"
msgstr "" msgstr ""
msgid "OnDemandScans|Scan mode" msgid "OnDemandScans|Scan mode"
msgstr "" msgstr ""
msgid "OnDemandScans|Scanner settings"
msgstr ""
msgid "OnDemandScans|Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST. %{helpLinkStart}More information%{helpLinkEnd}" msgid "OnDemandScans|Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST. %{helpLinkStart}More information%{helpLinkEnd}"
msgstr "" msgstr ""
msgid "OnDemandScans|Select one of the existing profiles"
msgstr ""
msgid "OnDemandScans|Site profiles"
msgstr ""
msgid "OnDemandScans|Target URL" msgid "OnDemandScans|Target URL"
msgstr "" msgstr ""
msgid "OnDemandScans|Use existing site profile"
msgstr ""
msgid "Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its respositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc." msgid "Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its respositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc."
msgstr "" 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