DAST Site validation - Add Validation Component - Frontend

Implemented basic validation components for validating DAST sites.
parent 7b580444
---
name: security-on-demand-scans-site-validation
name: security_on_demand_scans_site_validation
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40685
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241815
group: group::dynamic analysis
......
<script>
import * as Sentry from '@sentry/browser';
import { isEqual } from 'lodash';
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlModal } from '@gitlab/ui';
import {
GlAlert,
GlButton,
GlCollapse,
GlForm,
GlFormGroup,
GlFormInput,
GlModal,
GlToggle,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DastSiteValidation from './dast_site_validation.vue';
import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql';
......@@ -21,11 +32,15 @@ export default {
components: {
GlAlert,
GlButton,
GlCollapse,
GlForm,
GlFormGroup,
GlFormInput,
GlModal,
GlToggle,
DastSiteValidation,
},
mixins: [glFeatureFlagsMixin()],
props: {
fullPath: {
type: String,
......@@ -43,6 +58,7 @@ export default {
},
data() {
const { name = '', targetUrl = '' } = this.siteProfile || {};
const isSiteValid = false;
const form = {
profileName: initField(name),
targetUrl: initField(targetUrl),
......@@ -50,8 +66,11 @@ export default {
return {
form,
initialFormValues: extractFormValues(form),
isFetchingValidationStatus: false,
loading: false,
showAlert: false,
isSiteValid,
validateSite: isSiteValid,
};
},
computed: {
......@@ -86,9 +105,35 @@ export default {
return Object.values(this.form).some(({ value }) => !value);
},
isSubmitDisabled() {
return this.formHasErrors || this.someFieldEmpty;
return (this.validateSite && !this.isSiteValid) || this.formHasErrors || this.someFieldEmpty;
},
showValidationSection() {
return this.validateSite && !this.isSiteValid && !this.isFetchingValidationStatus;
},
},
watch: {
async validateSite(validate) {
if (!validate) {
this.isSiteValid = false;
} else {
// TODO: In the next iteration, this should be changed to:
// * Trigger a GraphQL query to retrieve the site's validation status
// * If the site is not validated, this should also trigger the dastSiteTokenCreate GraphQL
// mutation to create the validation token and pass it down to the validation component.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/238578
this.isFetchingValidationStatus = true;
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
this.isFetchingValidationStatus = false;
}
},
},
created() {
if (this.isEdit) {
this.validateTargetUrl();
}
},
methods: {
validateTargetUrl() {
if (!isAbsolute(this.form.targetUrl.value)) {
......@@ -184,7 +229,13 @@ export default {
<hr />
<gl-form-group
data-testid="target-url-input-group"
:invalid-feedback="form.targetUrl.feedback"
:description="
validateSite
? s__('DastProfiles|Validation must be turned off to change the target URL')
: null
"
:label="s__('DastProfiles|Target URL')"
>
<gl-form-input
......@@ -193,10 +244,42 @@ export default {
data-testid="target-url-input"
type="url"
:state="form.targetUrl.state"
:disabled="validateSite"
@input="validateTargetUrl"
/>
</gl-form-group>
<template v-if="glFeatures.securityOnDemandScansSiteValidation">
<gl-form-group :label="s__('DastProfiles|Validate target site')">
<template #description>
<p v-if="!isSiteValid" class="gl-mt-3">
{{ s__('DastProfiles|Site must be validated to run an active scan.') }}
</p>
<p v-else class="gl-text-green-500 gl-mt-3">
{{
s__(
'DastProfiles|Validation succeeded. Both active and passive scans can be run against the target site.',
)
}}
</p>
</template>
<gl-toggle
v-model="validateSite"
data-testid="dast-site-validation-toggle"
:disabled="!form.targetUrl.state"
:is-loading="isFetchingValidationStatus"
/>
</gl-form-group>
<gl-collapse :visible="showValidationSection">
<dast-site-validation
token="asd"
:target-url="form.targetUrl.value"
@success="isSiteValid = true"
/>
</gl-collapse>
</template>
<hr />
<div class="gl-mt-6 gl-pt-6">
......
<script>
import {
GlAlert,
GlButton,
GlCard,
GlFormGroup,
GlFormInput,
GlFormInputGroup,
GlFormRadioGroup,
GlIcon,
GlInputGroupText,
GlLoadingIcon,
} from '@gitlab/ui';
import download from '~/lib/utils/downloader';
import { DAST_SITE_VALIDATION_METHOD_TEXT_FILE, DAST_SITE_VALIDATION_METHODS } from '../constants';
export default {
name: 'DastSiteValidation',
components: {
GlAlert,
GlButton,
GlCard,
GlFormGroup,
GlFormInput,
GlFormInputGroup,
GlFormRadioGroup,
GlIcon,
GlInputGroupText,
GlLoadingIcon,
},
props: {
targetUrl: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
},
data() {
return {
isValidating: false,
hasValidationError: false,
validationMethod: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
};
},
computed: {
isTextFileValidation() {
return this.validationMethod === DAST_SITE_VALIDATION_METHOD_TEXT_FILE;
},
textFileName() {
return `GitLab-DAST-Site-Validation-${this.token}.txt`;
},
locationStepLabel() {
return DAST_SITE_VALIDATION_METHODS[this.validationMethod].i18n.locationStepLabel;
},
},
watch: {
targetUrl() {
this.hasValidationError = false;
},
},
methods: {
downloadTextFile() {
download({ fileName: this.textFileName, fileData: btoa(this.token) });
},
async validate() {
// TODO: Trigger the dastSiteValidationCreate GraphQL mutation.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/238578
this.hasValidationError = false;
this.isValidating = true;
try {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
this.isValidating = false;
this.$emit('success');
} catch (_) {
this.hasValidationError = true;
this.isValidating = false;
}
},
},
validationMethodOptions: Object.values(DAST_SITE_VALIDATION_METHODS),
};
</script>
<template>
<gl-card class="gl-bg-gray-10">
<gl-alert variant="warning" :dismissible="false" class="gl-mb-3">
{{ s__('DastProfiles|Site is not validated yet, please follow the steps.') }}
</gl-alert>
<gl-form-group :label="s__('DastProfiles|Step 1 - Choose site validation method')">
<gl-form-radio-group v-model="validationMethod" :options="$options.validationMethodOptions" />
</gl-form-group>
<gl-form-group
v-if="isTextFileValidation"
:label="s__('DastProfiles|Step 2 - Add following text to the target site')"
>
<gl-button
variant="info"
category="secondary"
size="small"
icon="download"
data-testid="download-dast-text-file-validation-button"
:aria-label="s__('DastProfiles|Download validation text file')"
@click="downloadTextFile()"
>
{{ textFileName }}
</gl-button>
</gl-form-group>
<gl-form-group :label="locationStepLabel" class="mw-460">
<gl-form-input-group>
<template #prepend>
<gl-input-group-text>{{ targetUrl }}</gl-input-group-text>
</template>
<gl-form-input class="gl-bg-white!" />
</gl-form-input-group>
</gl-form-group>
<hr />
<gl-button
variant="success"
category="secondary"
data-testid="validate-dast-site-button"
:disabled="isValidating"
@click="validate"
>
{{ s__('DastProfiles|Validate') }}
</gl-button>
<span
class="gl-ml-3"
:class="{ 'gl-text-orange-600': isValidating, 'gl-text-red-500': hasValidationError }"
>
<template v-if="isValidating">
<gl-loading-icon inline /> {{ s__('DastProfiles|Validating...') }}
</template>
<template v-else-if="hasValidationError">
<gl-icon name="status_failed" />
{{
s__(
'DastProfiles|Validation failed, please make sure that you follow the steps above with the choosen method.',
)
}}
</template>
</span>
</gl-card>
</template>
import { s__ } from '~/locale';
export const DAST_SITE_VALIDATION_METHOD_TEXT_FILE = 'DAST_SITE_VALIDATION_METHOD_TEXT_FILE';
export const DAST_SITE_VALIDATION_METHODS = {
DAST_SITE_VALIDATION_METHOD_TEXT_FILE: {
value: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
text: s__('DastProfiles|Text file validation'),
i18n: {
locationStepLabel: s__('DastProfiles|Step 3 - Confirm text file location and validate'),
},
},
};
......@@ -2,7 +2,10 @@
module Projects
class DastSiteProfilesController < Projects::ApplicationController
before_action :authorize_read_on_demand_scans!
before_action do
authorize_read_on_demand_scans!
push_frontend_feature_flag(:security_on_demand_scans_site_validation, @project)
end
def new
end
......
......@@ -4,6 +4,7 @@ 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 DastSiteValidation from 'ee/dast_site_profiles_form/components/dast_site_validation.vue';
import dastSiteProfileCreateMutation from 'ee/dast_site_profiles_form/graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from 'ee/dast_site_profiles_form/graphql/dast_site_profile_update.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
......@@ -23,13 +24,14 @@ const defaultProps = {
fullPath,
};
describe('OnDemandScansApp', () => {
describe('DastSiteProfileForm', () => {
let wrapper;
const withinComponent = () => within(wrapper.element);
const findForm = () => wrapper.find(GlForm);
const findProfileNameInput = () => wrapper.find('[data-testid="profile-name-input"]');
const findTargetUrlInputGroup = () => wrapper.find('[data-testid="target-url-input-group"]');
const findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]');
const findSubmitButton = () =>
wrapper.find('[data-testid="dast-site-profile-form-submit-button"]');
......@@ -38,6 +40,9 @@ describe('OnDemandScansApp', () => {
const findCancelModal = () => wrapper.find(GlModal);
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const findAlert = () => wrapper.find(GlAlert);
const findSiteValidationToggle = () =>
wrapper.find('[data-testid="dast-site-validation-toggle"]');
const findDastSiteValidation = () => wrapper.find(DastSiteValidation);
const componentFactory = (mountFn = shallowMount) => options => {
wrapper = mountFn(
......@@ -119,6 +124,80 @@ describe('OnDemandScansApp', () => {
});
});
describe('validation', () => {
describe('with feature flag disabled', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { securityOnDemandScansSiteValidation: false },
},
});
});
it('does not render validation components', () => {
expect(findSiteValidationToggle().exists()).toBe(false);
expect(findDastSiteValidation().exists()).toBe(false);
});
});
describe('with feature flag enabled', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { securityOnDemandScansSiteValidation: true },
},
});
});
it('renders validation components', () => {
expect(findSiteValidationToggle().exists()).toBe(true);
expect(findDastSiteValidation().exists()).toBe(true);
});
it('toggle is disabled until target URL is valid', async () => {
expect(findSiteValidationToggle().props('disabled')).toBe(true);
await findTargetUrlInput().vm.$emit('input', targetUrl);
expect(findSiteValidationToggle().props('disabled')).toBe(false);
});
it('disables target URL input when validation is enabled', async () => {
const targetUrlInputGroup = findTargetUrlInputGroup();
const targetUrlInput = findTargetUrlInput();
await targetUrlInput.vm.$emit('input', targetUrl);
expect(targetUrlInputGroup.attributes('description')).toBeUndefined();
expect(targetUrlInput.attributes('disabled')).toBeUndefined();
await findSiteValidationToggle().vm.$emit('change', true);
expect(targetUrlInputGroup.attributes('description')).toBe(
'Validation must be turned off to change the target URL',
);
expect(targetUrlInput.attributes('disabled')).toBe('true');
});
// TODO: refactor this test case when actual GraphQL mutations are implemented
// See https://gitlab.com/gitlab-org/gitlab/-/issues/238578
it('when validation section is opened and validation succeeds, section is collapsed', async () => {
jest.useFakeTimers();
expect(wrapper.vm.showValidationSection).toBe(false);
findTargetUrlInput().vm.$emit('input', targetUrl);
await findSiteValidationToggle().vm.$emit('change', true);
jest.runAllTimers();
await wrapper.vm.$nextTick();
expect(wrapper.vm.showValidationSection).toBe(true);
await findDastSiteValidation().vm.$emit('success');
expect(wrapper.vm.showValidationSection).toBe(false);
});
});
});
describe.each`
title | siteProfile | mutation | mutationVars | mutationKind
${'New site profile'} | ${null} | ${dastSiteProfileCreateMutation} | ${{}} | ${'dastSiteProfileCreate'}
......
import merge from 'lodash/merge';
import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import DastSiteValidation from 'ee/dast_site_profiles_form/components/dast_site_validation.vue';
import download from '~/lib/utils/downloader';
jest.mock('~/lib/utils/downloader');
// TODO: stop using fake timers once GraphQL mutations are implemented.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/238578
jest.useFakeTimers();
const targetUrl = 'https://example.com/';
const token = 'validation-token-123';
const defaultProps = {
targetUrl,
token,
};
describe('DastSiteValidation', () => {
let wrapper;
const componentFactory = (mountFn = shallowMount) => options => {
wrapper = mountFn(
DastSiteValidation,
merge(
{},
{
propsData: defaultProps,
},
options,
),
);
};
const createComponent = componentFactory();
const createFullComponent = componentFactory(mount);
const withinComponent = () => within(wrapper.element);
const findDownloadButton = () =>
wrapper.find('[data-testid="download-dast-text-file-validation-button"]');
const findValidateButton = () => wrapper.find('[data-testid="validate-dast-site-button"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
beforeEach(() => {
createFullComponent();
});
it('renders properly', () => {
expect(wrapper.html()).not.toBe('');
});
it('renders a download button containing the token', () => {
const downloadButton = withinComponent().getByRole('button', {
name: 'Download validation text file',
});
expect(downloadButton).not.toBeNull();
});
it('renders an input group with the target URL prepended', () => {
const inputGroup = withinComponent().getByRole('group', {
name: 'Step 3 - Confirm text file location and validate',
});
expect(inputGroup).not.toBeNull();
expect(inputGroup.textContent).toContain(targetUrl);
});
});
describe('text file validation', () => {
beforeEach(() => {
createComponent();
});
it('clicking on the download button triggers a download of a text file containing the token', () => {
findDownloadButton().vm.$emit('click');
expect(download).toHaveBeenCalledWith({
fileName: `GitLab-DAST-Site-Validation-${token}.txt`,
fileData: btoa(token),
});
});
});
describe('validation', () => {
beforeEach(() => {
createComponent();
findValidateButton().vm.$emit('click');
});
it('while validating, shows a loading state', () => {
expect(findLoadingIcon().exists()).toBe(true);
expect(wrapper.text()).toContain('Validating...');
});
it('on success, emits success event', async () => {
jest.spyOn(wrapper.vm, '$emit');
jest.runAllTimers();
await wrapper.vm.$nextTick();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('success');
});
// TODO: test error handling once GraphQL mutations are implemented.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/238578
it.todo('on error, shows error state');
});
});
......@@ -7779,6 +7779,9 @@ msgstr ""
msgid "DastProfiles|Do you want to discard your changes?"
msgstr ""
msgid "DastProfiles|Download validation text file"
msgstr ""
msgid "DastProfiles|Edit feature will come soon. Please create a new profile if changes needed"
msgstr ""
......@@ -7839,21 +7842,57 @@ msgstr ""
msgid "DastProfiles|Site Profiles"
msgstr ""
msgid "DastProfiles|Site is not validated yet, please follow the steps."
msgstr ""
msgid "DastProfiles|Site must be validated to run an active scan."
msgstr ""
msgid "DastProfiles|Spider timeout"
msgstr ""
msgid "DastProfiles|Step 1 - Choose site validation method"
msgstr ""
msgid "DastProfiles|Step 2 - Add following text to the target site"
msgstr ""
msgid "DastProfiles|Step 3 - Confirm text file location and validate"
msgstr ""
msgid "DastProfiles|Target URL"
msgstr ""
msgid "DastProfiles|Target timeout"
msgstr ""
msgid "DastProfiles|Text file validation"
msgstr ""
msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request."
msgstr ""
msgid "DastProfiles|The maximum number of seconds allowed for the spider to traverse the site."
msgstr ""
msgid "DastProfiles|Validate"
msgstr ""
msgid "DastProfiles|Validate target site"
msgstr ""
msgid "DastProfiles|Validating..."
msgstr ""
msgid "DastProfiles|Validation failed, please make sure that you follow the steps above with the choosen method."
msgstr ""
msgid "DastProfiles|Validation must be turned off to change the target URL"
msgstr ""
msgid "DastProfiles|Validation succeeded. Both active and passive scans can be run against the target site."
msgstr ""
msgid "Data is still calculating..."
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