Commit 5c3020aa authored by Mark Florian's avatar Mark Florian

Merge branch '218683-dast-ondemand-scans-form-fe' into 'master'

DAST On-demand scans MVC - Initiate Scan - Create form - Frontend

See merge request gitlab-org/gitlab!33778
parents 2f2301d5 b37e104b
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import OnDemandScansForm from './on_demand_scans_form.vue';
import OnDemandScansEmptyState from './on_demand_scans_empty_state.vue';
export default {
name: 'OnDemandScansApp',
components: {
GlEmptyState,
GlLink,
GlSprintf,
OnDemandScansForm,
OnDemandScansEmptyState,
},
props: {
helpPagePath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
},
data() {
return {
showForm: false,
};
},
};
</script>
<template>
<div>
<gl-empty-state
:svg-path="emptyStateSvgPath"
:title="s__('OnDemandScans|On-demand Scans')"
:primary-button-text="s__('OnDemandScans|Create new DAST scan')"
primary-button-link="#"
>
<template #description>
<gl-sprintf
:message="
s__(
'OnDemandScans|Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST. %{helpLinkStart}More information%{helpLinkEnd}',
)
"
>
<template #helpLink="{ content }">
<gl-link :href="helpPagePath">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</template>
</gl-empty-state>
<on-demand-scans-form
v-if="showForm"
:help-page-path="helpPagePath"
:project-path="projectPath"
:default-branch="defaultBranch"
@cancel="showForm = false"
/>
<on-demand-scans-empty-state
v-else
:help-page-path="helpPagePath"
:empty-state-svg-path="emptyStateSvgPath"
@createNewScan="showForm = true"
/>
</div>
</template>
<script>
import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
export default {
name: 'OnDemandScansEmptyState',
components: {
GlButton,
GlEmptyState,
GlLink,
GlSprintf,
},
props: {
helpPagePath: {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<div>
<gl-empty-state :svg-path="emptyStateSvgPath" :title="s__('OnDemandScans|On-demand Scans')">
<template #description>
<gl-sprintf
:message="
s__(
'OnDemandScans|Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST. %{helpLinkStart}More information%{helpLinkEnd}',
)
"
>
<template #helpLink="{ content }">
<gl-link :href="helpPagePath">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</template>
<template #actions>
<gl-button data-testid="run-scan-button" variant="success" @click="$emit('createNewScan')">
{{ s__('OnDemandScans|Create new DAST scan') }}
</gl-button>
</template>
</gl-empty-state>
</div>
</template>
<script>
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import {
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlIcon,
GlLink,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import runDastScanMutation from '../graphql/run_dast_scan.mutation.graphql';
import { SCAN_TYPES } from '../constants';
const initField = value => ({
value,
state: null,
feedback: null,
});
export default {
components: {
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlIcon,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
helpPagePath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
},
data() {
return {
form: {
scanType: initField(SCAN_TYPES.PASSIVE),
branch: initField(this.defaultBranch),
targetUrl: initField(''),
},
loading: false,
};
},
computed: {
formData() {
return {
projectPath: this.projectPath,
...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);
},
isSubmitDisabled() {
return this.formHasErrors || this.someFieldEmpty;
},
},
methods: {
validateTargetUrl() {
let [state, feedback] = [true, null];
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() {
this.loading = true;
this.$apollo
.mutate({
mutation: runDastScanMutation,
variables: this.formData,
})
.then(({ data: { runDastScan: { pipelineUrl } } }) => {
redirectTo(pipelineUrl);
})
.catch(e => {
Sentry.captureException(e);
createFlash(s__('OnDemandScans|Could not run the scan. Please try again.'));
this.loading = false;
});
},
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<p>
<gl-icon name="information-o" class="gl-vertical-align-text-bottom gl-text-gray-600" />
<gl-sprintf
:message="
s__(
'OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}',
)
"
>
<template #learnMoreLink="{ content }">
<gl-link :href="helpPagePath">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
</header>
<gl-form-group>
<template #label>
{{ s__('OnDemandScans|Scan mode') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Only a passive scan can be performed on demand.')"
/>
</template>
{{ s__('OnDemandScans|Passive DAST Scan') }}
</gl-form-group>
<gl-form-group>
<template #label>
{{ s__('OnDemandScans|Attached branch') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|Attached branch is where the scan job runs.')"
/>
</template>
{{ defaultBranch }}
</gl-form-group>
<gl-form-group :invalid-feedback="form.targetUrl.feedback">
<template #label>
{{ s__('OnDemandScans|Target URL') }}
<gl-icon
v-gl-tooltip.hover
name="information-o"
class="gl-vertical-align-text-bottom gl-text-gray-600"
:title="s__('OnDemandScans|DAST will scan the target URL and any discovered sub URLs.')"
/>
</template>
<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>
<div class="gl-mt-6 gl-pt-6">
<gl-button
type="submit"
variant="success"
class="js-no-auto-disable"
:disabled="isSubmitDisabled"
:loading="loading"
>
{{ s__('OnDemandScans|Run this scan') }}
</gl-button>
<gl-button @click="$emit('cancel')">
{{ __('Cancel') }}
</gl-button>
</div>
</gl-form>
</template>
/* eslint-disable import/prefer-default-export */
export const SCAN_TYPES = {
PASSIVE: 'PASSIVE',
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(),
});
mutation runDastScan(
$projectPath: ID!
$targetUrl: String!
$branch: String!
$scanType: DastScanTypeEnum!
) {
runDastScan(
input: {
projectPath: $projectPath
targetUrl: $targetUrl
branch: $branch
scanType: $scanType
}
) {
pipelineUrl
}
}
import Vue from 'vue';
import apolloProvider from './graphql/provider';
import OnDemandScansApp from './components/on_demand_scans_app.vue';
export default () => {
......@@ -7,16 +8,19 @@ export default () => {
return;
}
const { helpPagePath, emptyStateSvgPath } = el.dataset;
const { helpPagePath, emptyStateSvgPath, projectPath, defaultBranch } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
render(h) {
return h(OnDemandScansApp, {
props: {
helpPagePath,
emptyStateSvgPath,
projectPath,
defaultBranch,
},
});
},
......
# frozen_string_literal: true
module Projects::OnDemandScansHelper
def on_demand_scans_data
def on_demand_scans_data(project)
{
'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,
'project-path' => project.path_with_namespace
}
end
end
- breadcrumb_title s_('OnDemandScans|On-demand Scans')
- page_title s_('OnDemandScans|On-demand Scans')
#js-on-demand-scans-app{ data: on_demand_scans_data }
#js-on-demand-scans-app{ data: on_demand_scans_data(@project) }
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { GlEmptyState } from '@gitlab/ui';
import OnDemandScansApp from 'ee/on_demand_scans/components/on_demand_scans_app.vue';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import OnDemandScansEmptyState from 'ee/on_demand_scans/components/on_demand_scans_empty_state.vue';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
const projectPath = 'group/project';
const defaultBranch = 'master';
const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-empty-state.svg`;
describe('OnDemandScansApp', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyState);
const findOnDemandScansForm = () => wrapper.find(OnDemandScansForm);
const findOnDemandScansEmptyState = () => wrapper.find(OnDemandScansEmptyState);
const expectEmptyState = () => {
expect(wrapper.contains(OnDemandScansForm)).toBe(false);
expect(wrapper.contains(OnDemandScansEmptyState)).toBe(true);
};
const expectForm = () => {
expect(wrapper.contains(OnDemandScansForm)).toBe(true);
expect(wrapper.contains(OnDemandScansEmptyState)).toBe(false);
};
const createComponent = (props = {}) => {
wrapper = shallowMount(OnDemandScansApp, {
propsData: {
helpPagePath,
projectPath,
defaultBranch,
emptyStateSvgPath,
...props,
},
......@@ -31,17 +47,41 @@ describe('OnDemandScansApp', () => {
});
describe('empty state', () => {
it('renders empty state', () => {
expect(wrapper.contains(GlEmptyState)).toBe(true);
it('renders an empty state by default', () => {
expectEmptyState();
});
it('passes correct props to GlEmptyState', () => {
expect(findEmptyState().props()).toMatchObject({
svgPath: emptyStateSvgPath,
title: 'On-demand Scans',
primaryButtonText: 'Create new DAST scan',
primaryButtonLink: '#',
expect(findOnDemandScansEmptyState().props()).toMatchObject({
emptyStateSvgPath,
helpPagePath,
});
});
});
describe('form', () => {
beforeEach(async () => {
findOnDemandScansEmptyState().vm.$emit('createNewScan');
await wrapper.vm.$nextTick();
});
it('renders the form when clicking on the primary button', () => {
expectForm();
});
it('passes correct props to GlEmptyState', () => {
expect(findOnDemandScansForm().props()).toMatchObject({
defaultBranch,
helpPagePath,
projectPath,
});
});
it('shows the empty state on cancel', async () => {
findOnDemandScansForm().vm.$emit('cancel');
await wrapper.vm.$nextTick();
expectEmptyState();
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlForm } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
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 createFlash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
const projectPath = 'group/project';
const defaultBranch = 'master';
const targetUrl = 'http://example.com';
const pipelineUrl = `${TEST_HOST}/${projectPath}/pipelines/123`;
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
redirectTo: jest.fn(),
}));
describe('OnDemandScansApp', () => {
let wrapper;
const findForm = () => wrapper.find(GlForm);
const findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]');
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const createComponent = ({ props = {}, computed = {} } = {}) => {
wrapper = shallowMount(OnDemandScansForm, {
attachToDocument: true,
propsData: {
helpPagePath,
projectPath,
defaultBranch,
...props,
},
computed,
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders properly', () => {
expect(wrapper.isVueInstance()).toBe(true);
});
describe('computed props', () => {
describe('formData', () => {
it('returns an object with a key:value mapping from the form object including the project path', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
};
expect(wrapper.vm.formData).toEqual({
projectPath,
targetUrl,
});
});
});
describe('formHasErrors', () => {
it('returns true if any of the fields are invalid', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: false,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.formHasErrors).toBe(true);
});
it('returns false if none of the fields are invalid', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.formHasErrors).toBe(false);
});
});
describe('someFieldEmpty', () => {
it('returns true if any of the fields are empty', () => {
wrapper.vm.form = {
targetUrl: {
value: '',
state: false,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.someFieldEmpty).toBe(true);
});
it('returns false if no field is empty', () => {
wrapper.vm.form = {
targetUrl: {
value: targetUrl,
state: null,
feedback: '',
},
foo: {
value: 'bar',
state: null,
},
};
expect(wrapper.vm.someFieldEmpty).toBe(false);
});
});
describe('isSubmitDisabled', () => {
it.each`
formHasErrors | someFieldEmpty | expected
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${false}
`(
'is $expected when formHasErrors is $formHasErrors and someFieldEmpty is $someFieldEmpty',
({ formHasErrors, someFieldEmpty, expected }) => {
createComponent({
computed: {
formHasErrors: () => formHasErrors,
someFieldEmpty: () => someFieldEmpty,
},
});
expect(wrapper.vm.isSubmitDisabled).toBe(expected);
},
);
});
});
describe('target URL input', () => {
it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => {
const input = findTargetUrlInput();
input.vm.$emit('input', value);
await wrapper.vm.$nextTick();
expect(wrapper.vm.form.targetUrl).toEqual({
value,
state: false,
feedback: 'Please enter a valid URL format, ex: http://www.example.com/home',
});
expect(input.attributes().state).toBeUndefined();
});
it('is marked as valid provided a valid URL', async () => {
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
await wrapper.vm.$nextTick();
expect(wrapper.vm.form.targetUrl).toEqual({
value: targetUrl,
state: true,
feedback: null,
});
expect(input.attributes().state).toBe('true');
});
});
describe('submission', () => {
describe('on success', () => {
beforeEach(async () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { runDastScan: { pipelineUrl } } });
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('sets loading state', () => {
expect(wrapper.vm.loading).toBe(true);
});
it('triggers GraphQL mutation', () => {
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runDastScanMutation,
variables: {
scanType: 'PASSIVE',
branch: 'master',
targetUrl,
projectPath,
},
});
});
it('redirects to the URL provided in the response', () => {
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl);
});
});
describe('on error', () => {
beforeEach(async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue();
const input = findTargetUrlInput();
input.vm.$emit('input', targetUrl);
submitForm();
});
it('resets loading state', () => {
expect(wrapper.vm.loading).toBe(false);
});
it('shows an error flash', () => {
expect(createFlash).toHaveBeenCalledWith('Could not run the scan. Please try again.');
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { GlEmptyState, GlSprintf } from '@gitlab/ui';
import OnDemandScansEmptyState from 'ee/on_demand_scans/components/on_demand_scans_empty_state.vue';
const helpPagePath = `${TEST_HOST}/application_security/dast/index#on-demand-scans`;
const emptyStateSvgPath = `${TEST_HOST}/assets/illustrations/alert-management-empty-state.svg`;
const GlEmptyStateStub = {
props: GlEmptyState.props,
template: `
<div>
<slot name="description" />
<slot name="actions" />
</div>
`,
};
describe('OnDemandScansEmptyState', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyStateStub);
const findRunScanButton = () => wrapper.find('[data-testid="run-scan-button"]');
const createComponent = (props = {}) => {
wrapper = shallowMount(OnDemandScansEmptyState, {
propsData: {
helpPagePath,
emptyStateSvgPath,
...props,
},
stubs: {
GlEmptyState: GlEmptyStateStub,
GlSprintf,
GlButton: { template: '<button><slot /></button>' },
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders empty state', () => {
expect(wrapper.contains(GlEmptyStateStub)).toBe(true);
});
it('passes correct props to GlEmptyState', () => {
expect(findEmptyState().props()).toMatchObject({
svgPath: emptyStateSvgPath,
title: 'On-demand Scans',
});
});
it('renders the description', () => {
expect(wrapper.text()).toContain(
'Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST.',
);
expect(wrapper.text()).toContain('More information');
});
it('renders the run scan button', () => {
const button = findRunScanButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Create new DAST scan');
});
it('clicking on the run scan button emits createNewScan event', () => {
findRunScanButton().vm.$emit('click');
expect(wrapper.emitted().createNewScan).toBeTruthy();
});
});
......@@ -4,10 +4,14 @@ require 'spec_helper'
RSpec.describe Projects::OnDemandScansHelper do
describe '#on_demand_scans_data' do
let(:project) { create(:project) }
it 'returns proper data' do
expect(helper.on_demand_scans_data).to match(
expect(helper.on_demand_scans_data(project)).to match(
'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,
'project-path' => project.path_with_namespace
)
end
end
......
......@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe "projects/on_demand_scans/index", type: :view do
before do
@project = create(:project)
render
end
......
......@@ -15338,15 +15338,51 @@ msgstr ""
msgid "On track"
msgstr ""
msgid "OnDemandScans|Attached branch"
msgstr ""
msgid "OnDemandScans|Attached branch is where the scan job runs."
msgstr ""
msgid "OnDemandScans|Could not run the scan. Please try again."
msgstr ""
msgid "OnDemandScans|Create new DAST scan"
msgstr ""
msgid "OnDemandScans|DAST will scan the target URL and any discovered sub URLs."
msgstr ""
msgid "OnDemandScans|New on-demand DAST scan"
msgstr ""
msgid "OnDemandScans|On-demand Scans"
msgstr ""
msgid "OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}"
msgstr ""
msgid "OnDemandScans|Only a passive scan can be performed on demand."
msgstr ""
msgid "OnDemandScans|Passive DAST Scan"
msgstr ""
msgid "OnDemandScans|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr ""
msgid "OnDemandScans|Run this scan"
msgstr ""
msgid "OnDemandScans|Scan mode"
msgstr ""
msgid "OnDemandScans|Schedule or run scans immediately against target sites. Currently available on-demand scan type: DAST. %{helpLinkStart}More information%{helpLinkEnd}"
msgstr ""
msgid "OnDemandScans|Target URL"
msgstr ""
msgid "Onboarding"
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