Commit da5255f5 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '238577-dast-site-validation-component' into 'master'

DAST Site validation - Add Validation Component - Frontend

See merge request gitlab-org/gitlab!40753
parents 90960b2f 2b3aa50a
--- ---
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 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40685
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241815 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241815
group: group::dynamic analysis group: group::dynamic analysis
......
<script> <script>
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { isEqual } from 'lodash'; 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 { __, s__ } from '~/locale';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility'; 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 dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql'; import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql';
...@@ -21,11 +32,15 @@ export default { ...@@ -21,11 +32,15 @@ export default {
components: { components: {
GlAlert, GlAlert,
GlButton, GlButton,
GlCollapse,
GlForm, GlForm,
GlFormGroup, GlFormGroup,
GlFormInput, GlFormInput,
GlModal, GlModal,
GlToggle,
DastSiteValidation,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
fullPath: { fullPath: {
type: String, type: String,
...@@ -43,6 +58,7 @@ export default { ...@@ -43,6 +58,7 @@ export default {
}, },
data() { data() {
const { name = '', targetUrl = '' } = this.siteProfile || {}; const { name = '', targetUrl = '' } = this.siteProfile || {};
const isSiteValid = false;
const form = { const form = {
profileName: initField(name), profileName: initField(name),
targetUrl: initField(targetUrl), targetUrl: initField(targetUrl),
...@@ -50,8 +66,11 @@ export default { ...@@ -50,8 +66,11 @@ export default {
return { return {
form, form,
initialFormValues: extractFormValues(form), initialFormValues: extractFormValues(form),
isFetchingValidationStatus: false,
loading: false, loading: false,
showAlert: false, showAlert: false,
isSiteValid,
validateSite: isSiteValid,
}; };
}, },
computed: { computed: {
...@@ -86,9 +105,35 @@ export default { ...@@ -86,9 +105,35 @@ export default {
return Object.values(this.form).some(({ value }) => !value); return Object.values(this.form).some(({ value }) => !value);
}, },
isSubmitDisabled() { 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: { methods: {
validateTargetUrl() { validateTargetUrl() {
if (!isAbsolute(this.form.targetUrl.value)) { if (!isAbsolute(this.form.targetUrl.value)) {
...@@ -184,7 +229,13 @@ export default { ...@@ -184,7 +229,13 @@ export default {
<hr /> <hr />
<gl-form-group <gl-form-group
data-testid="target-url-input-group"
:invalid-feedback="form.targetUrl.feedback" :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')" :label="s__('DastProfiles|Target URL')"
> >
<gl-form-input <gl-form-input
...@@ -193,10 +244,42 @@ export default { ...@@ -193,10 +244,42 @@ export default {
data-testid="target-url-input" data-testid="target-url-input"
type="url" type="url"
:state="form.targetUrl.state" :state="form.targetUrl.state"
:disabled="validateSite"
@input="validateTargetUrl" @input="validateTargetUrl"
/> />
</gl-form-group> </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 /> <hr />
<div class="gl-mt-6 gl-pt-6"> <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 @@ ...@@ -2,7 +2,10 @@
module Projects module Projects
class DastSiteProfilesController < Projects::ApplicationController 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 def new
end end
......
...@@ -4,6 +4,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -4,6 +4,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlForm, GlModal } from '@gitlab/ui'; import { GlAlert, GlForm, GlModal } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import DastSiteProfileForm from 'ee/dast_site_profiles_form/components/dast_site_profile_form.vue'; 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 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 dastSiteProfileUpdateMutation from 'ee/dast_site_profiles_form/graphql/dast_site_profile_update.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
...@@ -23,13 +24,14 @@ const defaultProps = { ...@@ -23,13 +24,14 @@ const defaultProps = {
fullPath, fullPath,
}; };
describe('OnDemandScansApp', () => { describe('DastSiteProfileForm', () => {
let wrapper; let wrapper;
const withinComponent = () => within(wrapper.element); const withinComponent = () => within(wrapper.element);
const findForm = () => wrapper.find(GlForm); const findForm = () => wrapper.find(GlForm);
const findProfileNameInput = () => wrapper.find('[data-testid="profile-name-input"]'); 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 findTargetUrlInput = () => wrapper.find('[data-testid="target-url-input"]');
const findSubmitButton = () => const findSubmitButton = () =>
wrapper.find('[data-testid="dast-site-profile-form-submit-button"]'); wrapper.find('[data-testid="dast-site-profile-form-submit-button"]');
...@@ -38,6 +40,9 @@ describe('OnDemandScansApp', () => { ...@@ -38,6 +40,9 @@ describe('OnDemandScansApp', () => {
const findCancelModal = () => wrapper.find(GlModal); const findCancelModal = () => wrapper.find(GlModal);
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} }); const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const findAlert = () => wrapper.find(GlAlert); 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 => { const componentFactory = (mountFn = shallowMount) => options => {
wrapper = mountFn( wrapper = mountFn(
...@@ -119,6 +124,80 @@ describe('OnDemandScansApp', () => { ...@@ -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` describe.each`
title | siteProfile | mutation | mutationVars | mutationKind title | siteProfile | mutation | mutationVars | mutationKind
${'New site profile'} | ${null} | ${dastSiteProfileCreateMutation} | ${{}} | ${'dastSiteProfileCreate'} ${'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 "" ...@@ -7779,6 +7779,9 @@ msgstr ""
msgid "DastProfiles|Do you want to discard your changes?" msgid "DastProfiles|Do you want to discard your changes?"
msgstr "" msgstr ""
msgid "DastProfiles|Download validation text file"
msgstr ""
msgid "DastProfiles|Edit feature will come soon. Please create a new profile if changes needed" msgid "DastProfiles|Edit feature will come soon. Please create a new profile if changes needed"
msgstr "" msgstr ""
...@@ -7839,21 +7842,57 @@ msgstr "" ...@@ -7839,21 +7842,57 @@ msgstr ""
msgid "DastProfiles|Site Profiles" msgid "DastProfiles|Site Profiles"
msgstr "" 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" msgid "DastProfiles|Spider timeout"
msgstr "" 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" msgid "DastProfiles|Target URL"
msgstr "" msgstr ""
msgid "DastProfiles|Target timeout" msgid "DastProfiles|Target timeout"
msgstr "" 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." msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request."
msgstr "" msgstr ""
msgid "DastProfiles|The maximum number of seconds allowed for the spider to traverse the site." msgid "DastProfiles|The maximum number of seconds allowed for the spider to traverse the site."
msgstr "" 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..." msgid "Data is still calculating..."
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