Commit 9bf37d57 authored by David Pisek's avatar David Pisek Committed by Enrique Alcántara

DAST on-demand site profile: Handle validation states

Adds logic to handle pending and in-progress states when a profile
gets edited.
parent 8c78a0cc
......@@ -13,13 +13,16 @@ import {
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
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';
import dastSiteTokenCreateMutation from '../graphql/dast_site_token_create.mutation.graphql';
import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graphql';
import { DAST_SITE_VALIDATION_STATUS } from '../constants';
import { DAST_SITE_VALIDATION_STATUS, DAST_SITE_VALIDATION_POLL_INTERVAL } from '../constants';
const { PENDING, INPROGRESS, PASSED, FAILED } = DAST_SITE_VALIDATION_STATUS;
const initField = value => ({
value,
......@@ -61,22 +64,25 @@ export default {
},
data() {
const { name = '', targetUrl = '' } = this.siteProfile || {};
const isSiteValid = false;
const form = {
profileName: initField(name),
targetUrl: initField(targetUrl),
};
return {
fetchValidationTimeout: null,
form,
initialFormValues: extractFormValues(form),
isFetchingValidationStatus: false,
isValidatingSite: false,
loading: false,
showAlert: false,
isLoading: false,
hasAlert: false,
tokenId: null,
token: null,
isSiteValid,
validateSite: isSiteValid,
isSiteValidationActive: false,
isSiteValidationTouched: false,
validationStatus: null,
errorMessage: '',
errors: [],
};
......@@ -85,6 +91,9 @@ export default {
isEdit() {
return Boolean(this.siteProfile?.id);
},
isSiteValidationDisabled() {
return !this.form.targetUrl.state || this.validationStatusMatches(INPROGRESS);
},
i18n() {
const { isEdit } = this;
return {
......@@ -121,44 +130,93 @@ export default {
return Object.values(this.form).some(({ value }) => !value);
},
isSubmitDisabled() {
return (this.validateSite && !this.isSiteValid) || this.formHasErrors || this.someFieldEmpty;
return (
(this.isSiteValidationActive && !this.validationStatusMatches(PASSED)) ||
this.formHasErrors ||
this.someFieldEmpty ||
this.validationStatusMatches(INPROGRESS)
);
},
showValidationSection() {
return this.validateSite && !this.isSiteValid && !this.isValidatingSite;
return (
this.isSiteValidationActive &&
!this.isValidatingSite &&
![INPROGRESS, PASSED].some(this.validationStatusMatches)
);
},
siteValidationStatusDescription() {
const descriptions = {
[PENDING]: { text: s__('DastProfiles|Site must be validated to run an active scan.') },
[INPROGRESS]: {
text: s__('DastProfiles|Validation is in progress...'),
},
[PASSED]: {
text: s__(
'DastProfiles|Validation succeeded. Both active and passive scans can be run against the target site.',
),
cssClass: 'gl-text-green-500',
},
[FAILED]: {
text: s__('DastProfiles|Validation failed. Please try again.'),
cssClass: 'gl-text-red-500',
dismissed: this.isSiteValidationTouched,
},
};
const defaultDescription = descriptions[PENDING];
const currentStatusDescription = descriptions[this.validationStatus];
return currentStatusDescription && !currentStatusDescription.dismissed
? currentStatusDescription
: defaultDescription;
},
},
watch: {
async created() {
if (this.isEdit) {
this.validateTargetUrl();
if (this.glFeatures.securityOnDemandScansSiteValidation) {
await this.fetchValidationStatus();
if ([PASSED, INPROGRESS].some(this.validationStatusMatches)) {
this.isSiteValidationActive = true;
}
}
}
},
destroyed() {
clearTimeout(this.fetchValidationTimeout);
this.fetchValidationTimeout = null;
},
methods: {
async validateSite(validate) {
this.isSiteValidationActive = validate;
this.isSiteValidationTouched = true;
this.tokenId = null;
this.token = null;
if (!validate) {
this.isSiteValid = false;
this.validationStatus = null;
} else {
try {
this.isValidatingSite = true;
await this.fetchValidationStatus();
if (!this.isSiteValid) {
if (![PASSED, INPROGRESS].some(this.validationStatusMatches)) {
await this.createValidationToken();
}
} catch (exception) {
this.captureException(exception);
this.isSiteValidationActive = false;
} finally {
this.isValidatingSite = false;
}
}
},
},
async created() {
if (this.isEdit) {
this.validateTargetUrl();
if (this.glFeatures.securityOnDemandScansSiteValidation) {
await this.fetchValidationStatus();
}
}
},
methods: {
validationStatusMatches(status) {
return this.validationStatus === status;
},
validateTargetUrl() {
if (!isAbsolute(this.form.targetUrl.value)) {
this.form.targetUrl.state = false;
......@@ -186,14 +244,20 @@ export default {
fullPath: this.fullPath,
targetUrl: this.form.targetUrl.value,
},
fetchPolicy: fetchPolicies.NETWORK_ONLY,
});
this.isSiteValid = status === DAST_SITE_VALIDATION_STATUS.VALID;
this.validationStatus = status;
if (this.validationStatusMatches(INPROGRESS)) {
this.fetchValidationTimeout = setTimeout(
this.fetchValidationStatus,
DAST_SITE_VALIDATION_POLL_INTERVAL,
);
}
} catch (exception) {
this.showErrors({
message: this.i18n.siteValidation.validationStatusFetchError,
});
this.validateSite = false;
throw new Error(exception);
} finally {
this.isFetchingValidationStatus = false;
......@@ -219,13 +283,12 @@ export default {
}
} catch (exception) {
this.showErrors({ message: errorMessage });
this.validateSite = false;
throw new Error(exception);
}
},
onSubmit() {
this.loading = true;
this.isLoading = true;
this.hideErrors();
const { errorMessage } = this.i18n;
......@@ -248,7 +311,7 @@ export default {
}) => {
if (errors.length > 0) {
this.showErrors({ message: errorMessage, errors });
this.loading = false;
this.isLoading = false;
} else {
redirectTo(this.profilesLibraryPath);
}
......@@ -257,7 +320,7 @@ export default {
.catch(exception => {
this.showErrors({ message: errorMessage });
this.captureException(exception);
this.loading = false;
this.isLoading = false;
});
},
onCancelClicked() {
......@@ -267,6 +330,9 @@ export default {
this.$refs[this.$options.modalId].show();
}
},
onValidationSuccess() {
this.validationStatus = PASSED;
},
discard() {
redirectTo(this.profilesLibraryPath);
},
......@@ -276,12 +342,12 @@ export default {
showErrors({ message, errors = [] }) {
this.errorMessage = message;
this.errors = errors;
this.showAlert = true;
this.hasAlert = true;
},
hideErrors() {
this.errorMessage = '';
this.errors = [];
this.showAlert = false;
this.hasAlert = false;
},
},
modalId: 'deleteDastProfileModal',
......@@ -295,7 +361,7 @@ export default {
</h2>
<gl-alert
v-if="showAlert"
v-if="hasAlert"
variant="danger"
class="gl-mb-5"
data-testid="dast-site-profile-form-alert"
......@@ -322,7 +388,7 @@ export default {
data-testid="target-url-input-group"
:invalid-feedback="form.targetUrl.feedback"
:description="
validateSite && !isValidatingSite
isSiteValidationActive && !isValidatingSite
? s__('DastProfiles|Validation must be turned off to change the target URL')
: null
"
......@@ -334,7 +400,7 @@ export default {
data-testid="target-url-input"
type="url"
:state="form.targetUrl.state"
:disabled="validateSite"
:disabled="isSiteValidationActive"
@input="validateTargetUrl"
/>
</gl-form-group>
......@@ -342,22 +408,23 @@ export default {
<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
v-if="siteValidationStatusDescription.text"
class="gl-mt-3"
:class="siteValidationStatusDescription.cssClass"
data-testid="siteValidationStatusDescription"
>
{{ siteValidationStatusDescription.text }}
</p>
</template>
<gl-toggle
v-model="validateSite"
data-testid="dast-site-validation-toggle"
:disabled="!form.targetUrl.state"
:is-loading="isFetchingValidationStatus || isValidatingSite"
:value="isSiteValidationActive"
:disabled="isSiteValidationDisabled"
:is-loading="
!isSiteValidationDisabled && (isFetchingValidationStatus || isValidatingSite)
"
@change="validateSite"
/>
</gl-form-group>
......@@ -367,7 +434,7 @@ export default {
:token-id="tokenId"
:token="token"
:target-url="form.targetUrl.value"
@success="isSiteValid = true"
@success="onValidationSuccess"
/>
</gl-collapse>
</template>
......@@ -381,7 +448,7 @@ export default {
class="js-no-auto-disable"
data-testid="dast-site-profile-form-submit-button"
:disabled="isSubmitDisabled"
:loading="loading"
:loading="isLoading"
>
{{ s__('DastProfiles|Save profile') }}
</gl-button>
......
......@@ -14,10 +14,12 @@ import {
} from '@gitlab/ui';
import download from '~/lib/utils/downloader';
import { cleanLeadingSeparator, joinPaths, stripPathTail } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import {
DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
DAST_SITE_VALIDATION_METHODS,
DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_POLL_INTERVAL,
} from '../constants';
import dastSiteValidationCreateMutation from '../graphql/dast_site_validation_create.mutation.graphql';
import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graphql';
......@@ -53,14 +55,19 @@ export default {
},
},
}) {
if (status === DAST_SITE_VALIDATION_STATUS.VALID) {
if (status === DAST_SITE_VALIDATION_STATUS.PASSED) {
this.onSuccess();
}
if (status === DAST_SITE_VALIDATION_STATUS.FAILED) {
this.onError();
}
},
skip() {
return !(this.isCreatingValidation || this.isValidating);
},
pollInterval: 1000,
pollInterval: DAST_SITE_VALIDATION_POLL_INTERVAL,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
error(e) {
this.onError(e);
},
......@@ -159,7 +166,7 @@ export default {
});
if (errors?.length) {
this.onError();
} else if (status === DAST_SITE_VALIDATION_STATUS.VALID) {
} else if (status === DAST_SITE_VALIDATION_STATUS.PASSED) {
this.onSuccess();
} else {
this.isCreatingValidation = false;
......
......@@ -12,6 +12,10 @@ export const DAST_SITE_VALIDATION_METHODS = {
};
export const DAST_SITE_VALIDATION_STATUS = {
VALID: 'PASSED_VALIDATION',
INVALID: 'FAILED_VALIDATION',
PENDING: 'PENDING_VALIDATION',
INPROGRESS: 'INPROGRESS_VALIDATION',
PASSED: 'PASSED_VALIDATION',
FAILED: 'FAILED_VALIDATION',
};
export const DAST_SITE_VALIDATION_POLL_INTERVAL = 1000;
......@@ -12,17 +12,15 @@ import dastSiteValidationQuery from 'ee/security_configuration/dast_site_profile
import dastSiteProfileCreateMutation from 'ee/security_configuration/dast_site_profiles_form/graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from 'ee/security_configuration/dast_site_profiles_form/graphql/dast_site_profile_update.mutation.graphql';
import dastSiteTokenCreateMutation from 'ee/security_configuration/dast_site_profiles_form/graphql/dast_site_token_create.mutation.graphql';
import { siteProfiles } from 'ee_jest/on_demand_scans/mock_data';
import * as responses from 'ee_jest/security_configuration/dast_site_profiles_form/mock_data/apollo_mock';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
redirectTo: jest.fn(),
}));
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_profiles_form/constants';
import * as urlUtility from '~/lib/utils/url_utility';
const localVue = createLocalVue();
localVue.use(VueApollo);
const [siteProfileOne] = siteProfiles;
const fullPath = 'group/project';
const profilesLibraryPath = `${TEST_HOST}/${fullPath}/-/security/configuration/dast_profiles`;
const profileName = 'My DAST site profile';
......@@ -52,18 +50,16 @@ describe('DastSiteProfileForm', () => {
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"]');
const findCancelButton = () =>
wrapper.find('[data-testid="dast-site-profile-form-cancel-button"]');
const findByTestId = testId => wrapper.find(`[data-testid="${testId}"]`);
const findProfileNameInput = () => findByTestId('profile-name-input');
const findTargetUrlInputGroup = () => findByTestId('target-url-input-group');
const findTargetUrlInput = () => findByTestId('target-url-input');
const findSubmitButton = () => findByTestId('dast-site-profile-form-submit-button');
const findCancelButton = () => findByTestId('dast-site-profile-form-cancel-button');
const findCancelModal = () => wrapper.find(GlModal);
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const findAlert = () => wrapper.find('[data-testid="dast-site-profile-form-alert"]');
const findSiteValidationToggle = () =>
wrapper.find('[data-testid="dast-site-validation-toggle"]');
const findAlert = () => findByTestId('dast-site-profile-form-alert');
const findSiteValidationToggle = () => findByTestId('dast-site-validation-toggle');
const findDastSiteValidation = () => wrapper.find(DastSiteValidation);
const mockClientFactory = handlers => {
......@@ -95,9 +91,9 @@ describe('DastSiteProfileForm', () => {
apolloProvider.defaultClient = mockClientFactory(handlers);
};
const componentFactory = (mountFn = shallowMount) => options => {
const componentFactory = (mountFn = shallowMount) => (options, handlers) => {
apolloProvider = new VueApollo({
defaultClient: mockClientFactory(),
defaultClient: mockClientFactory(handlers),
});
const mountOpts = merge(
......@@ -187,7 +183,7 @@ describe('DastSiteProfileForm', () => {
describe.each`
title | siteProfile
${'New site profile'} | ${null}
${'Edit site profile'} | ${{ id: 1, name: 'foo', targetUrl: 'bar' }}
${'Edit site profile'} | ${siteProfileOne}
`('$title with feature flag disabled', ({ siteProfile }) => {
beforeEach(() => {
createComponent({
......@@ -312,9 +308,9 @@ describe('DastSiteProfileForm', () => {
});
describe.each`
title | siteProfile | mutationVars | mutationKind
${'New site profile'} | ${null} | ${{}} | ${'dastSiteProfileCreate'}
${'Edit site profile'} | ${{ id: 1, name: 'foo', targetUrl: 'bar' }} | ${{ id: 1 }} | ${'dastSiteProfileUpdate'}
title | siteProfile | mutationVars | mutationKind
${'New site profile'} | ${null} | ${{}} | ${'dastSiteProfileCreate'}
${'Edit site profile'} | ${siteProfileOne} | ${{ id: siteProfileOne.id }} | ${'dastSiteProfileUpdate'}
`('$title', ({ siteProfile, title, mutationVars, mutationKind }) => {
beforeEach(() => {
createFullComponent({
......@@ -322,6 +318,8 @@ describe('DastSiteProfileForm', () => {
siteProfile,
},
});
jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
});
it('sets the correct title', () => {
......@@ -354,7 +352,7 @@ describe('DastSiteProfileForm', () => {
});
it('redirects to the profiles library', () => {
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
expect(urlUtility.redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
it('does not show an alert', () => {
......@@ -418,7 +416,7 @@ describe('DastSiteProfileForm', () => {
describe('form unchanged', () => {
it('redirects to the profiles library', () => {
findCancelButton().vm.$emit('click');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
expect(urlUtility.redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
......@@ -436,9 +434,89 @@ describe('DastSiteProfileForm', () => {
it('redirects to the profiles library if confirmed', () => {
findCancelModal().vm.$emit('ok');
expect(redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
expect(urlUtility.redirectTo).toHaveBeenCalledWith(profilesLibraryPath);
});
});
});
});
describe.each`
givenValidationStatus | expectedDescription | shouldShowDefaultDescriptionAfterToggle | shouldHaveSiteValidationActivated | shouldHaveSiteValidationDisabled | shouldPoll
${DAST_SITE_VALIDATION_STATUS.PENDING} | ${'Site must be validated to run an active scan.'} | ${false} | ${false} | ${false} | ${false}
${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validation is in progress...'} | ${false} | ${true} | ${true} | ${true}
${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validation succeeded. Both active and passive scans can be run against the target site.'} | ${false} | ${true} | ${false} | ${false}
${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed. Please try again.'} | ${true} | ${false} | ${false} | ${false}
`(
'when editing an existing profile and the validation status is "$givenValidationStatus"',
({
givenValidationStatus,
expectedDescription,
shouldHaveSiteValidationActivated,
shouldShowDefaultDescriptionAfterToggle,
shouldHaveSiteValidationDisabled,
shouldPoll,
}) => {
let dastSiteValidationHandler;
beforeEach(() => {
dastSiteValidationHandler = jest
.fn()
.mockResolvedValue(responses.dastSiteValidation(givenValidationStatus));
createFullComponent(
{
provide: {
glFeatures: { securityOnDemandScansSiteValidation: true },
},
propsData: {
siteProfile: siteProfileOne,
},
},
{
dastSiteValidation: dastSiteValidationHandler,
},
);
return wrapper.vm.$nextTick();
});
it('shows the correct status text', () => {
expect(findByTestId('siteValidationStatusDescription').text()).toBe(expectedDescription);
});
it(`shows the correct status text after the validation toggle has been changed`, async () => {
const defaultDescription = 'Site must be validated to run an active scan.';
findSiteValidationToggle().vm.$emit('change', true);
await wrapper.vm.$nextTick();
expect(findByTestId('siteValidationStatusDescription').text()).toBe(
shouldShowDefaultDescriptionAfterToggle ? defaultDescription : expectedDescription,
);
});
it('sets the validation toggle to the correct state', () => {
expect(findSiteValidationToggle().props()).toMatchObject({
value: shouldHaveSiteValidationActivated,
disabled: shouldHaveSiteValidationDisabled,
});
});
it(`should ${shouldPoll ? '' : 'not '}poll the validation status`, async () => {
jest.useFakeTimers();
expect(dastSiteValidationHandler).toHaveBeenCalledTimes(1);
jest.runOnlyPendingTimers();
await waitForPromises();
if (shouldPoll) {
expect(dastSiteValidationHandler).toHaveBeenCalledTimes(2);
} else {
expect(dastSiteValidationHandler).toHaveBeenCalledTimes(1);
}
});
},
);
});
......@@ -9,6 +9,7 @@ import DastSiteValidation from 'ee/security_configuration/dast_site_profiles_for
import dastSiteValidationCreateMutation from 'ee/security_configuration/dast_site_profiles_form/graphql/dast_site_validation_create.mutation.graphql';
import dastSiteValidationQuery from 'ee/security_configuration/dast_site_profiles_form/graphql/dast_site_validation.query.graphql';
import * as responses from 'ee_jest/security_configuration/dast_site_profiles_form/mock_data/apollo_mock';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_profiles_form/constants';
import download from '~/lib/utils/downloader';
jest.mock('~/lib/utils/downloader');
......@@ -184,7 +185,7 @@ describe('DastSiteValidation', () => {
createComponent();
});
describe('success', () => {
describe('passed', () => {
beforeEach(() => {
findValidateButton().vm.$emit('click');
});
......@@ -210,6 +211,24 @@ describe('DastSiteValidation', () => {
});
});
describe('failed', () => {
beforeEach(() => {
respondWith({
dastSiteValidation: () =>
Promise.resolve(responses.dastSiteValidation(DAST_SITE_VALIDATION_STATUS.FAILED)),
});
});
it('shows failure message', async () => {
expect(findErrorMessage()).toBe(null);
findValidateButton().vm.$emit('click');
await waitForPromises();
expect(findErrorMessage()).not.toBe(null);
});
});
describe.each`
errorKind | errorResponse
${'top level error'} | ${() => Promise.reject(new Error('GraphQL Network Error'))}
......
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_profiles_form/constants';
export const dastSiteProfileCreate = (errors = []) => ({
data: { dastSiteProfileCreate: { id: '3083', errors } },
});
......@@ -6,12 +8,14 @@ export const dastSiteProfileUpdate = (errors = []) => ({
data: { dastSiteProfileUpdate: { id: '3083', errors } },
});
export const dastSiteValidation = (status = 'FAILED_VALIDATION') => ({
export const dastSiteValidation = (status = DAST_SITE_VALIDATION_STATUS.PENDING) => ({
data: { project: { dastSiteValidation: { status, id: '1' } } },
});
export const dastSiteValidationCreate = (errors = []) => ({
data: { dastSiteValidationCreate: { status: 'PASSED_VALIDATION', id: '1', errors } },
data: {
dastSiteValidationCreate: { status: DAST_SITE_VALIDATION_STATUS.PASSED, id: '1', errors },
},
});
export const dastSiteTokenCreate = ({ id = '1', token = '1', errors = [] }) => ({
......
......@@ -8178,6 +8178,12 @@ msgstr ""
msgid "DastProfiles|Validation failed, please make sure that you follow the steps above with the choosen method."
msgstr ""
msgid "DastProfiles|Validation failed. Please try again."
msgstr ""
msgid "DastProfiles|Validation is in progress..."
msgstr ""
msgid "DastProfiles|Validation must be turned off to change the target URL"
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