Commit 1a9d8318 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Kushal Pandya

DAST Site validation: Hook up with backend

* Adds queries and mutations to retrieve the validation status,
  validation token and mutation to validate a site
* Adds error handling
* Adds specs
parent d690637a
......@@ -17,6 +17,9 @@ 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';
const initField = value => ({
value,
......@@ -67,10 +70,15 @@ export default {
form,
initialFormValues: extractFormValues(form),
isFetchingValidationStatus: false,
isValidatingSite: false,
loading: false,
showAlert: false,
tokenId: null,
token: null,
isSiteValid,
validateSite: isSiteValid,
errorMessage: '',
errors: [],
};
},
computed: {
......@@ -93,6 +101,14 @@ export default {
okTitle: __('Discard'),
cancelTitle: __('Cancel'),
},
siteValidation: {
validationStatusFetchError: s__(
'DastProfiles|Could not retrieve site validation status. Please refresh the page, or try again later.',
),
createTokenError: s__(
'DastProfiles|Could not create site validation token. Please refresh the page, or try again later.',
),
},
};
},
formTouched() {
......@@ -108,30 +124,35 @@ export default {
return (this.validateSite && !this.isSiteValid) || this.formHasErrors || this.someFieldEmpty;
},
showValidationSection() {
return this.validateSite && !this.isSiteValid && !this.isFetchingValidationStatus;
return this.validateSite && !this.isSiteValid && !this.isValidatingSite;
},
},
watch: {
async validateSite(validate) {
this.tokenId = null;
this.token = null;
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;
try {
this.isValidatingSite = true;
await this.fetchValidationStatus();
if (!this.isSiteValid) {
await this.createValidationToken();
}
} catch (exception) {
this.captureException(exception);
} finally {
this.isValidatingSite = false;
}
}
},
},
created() {
async created() {
if (this.isEdit) {
this.validateTargetUrl();
await this.fetchValidationStatus();
}
},
methods: {
......@@ -146,9 +167,64 @@ export default {
this.form.targetUrl.state = true;
this.form.targetUrl.feedback = null;
},
async fetchValidationStatus() {
this.isFetchingValidationStatus = true;
try {
const {
data: {
project: {
dastSiteValidation: { status },
},
},
} = await this.$apollo.query({
query: dastSiteValidationQuery,
variables: {
fullPath: this.fullPath,
targetUrl: this.form.targetUrl.value,
},
});
this.isSiteValid = status === DAST_SITE_VALIDATION_STATUS.VALID;
} catch (exception) {
this.showErrors({
message: this.i18n.siteValidation.validationStatusFetchError,
});
this.validateSite = false;
throw new Error(exception);
} finally {
this.isFetchingValidationStatus = false;
}
},
async createValidationToken() {
const errorMessage = this.i18n.siteValidation.createTokenError;
try {
const {
data: {
dastSiteTokenCreate: { id, token, errors = [] },
},
} = await this.$apollo.mutate({
mutation: dastSiteTokenCreateMutation,
variables: { projectFullPath: this.fullPath, targetUrl: this.form.targetUrl.value },
});
if (errors.length) {
this.showErrors({ message: errorMessage, errors });
} else {
this.tokenId = id;
this.token = token;
}
} catch (exception) {
this.showErrors({ message: errorMessage });
this.validateSite = false;
throw new Error(exception);
}
},
onSubmit() {
this.loading = true;
this.hideErrors();
const { errorMessage } = this.i18n;
const variables = {
fullPath: this.fullPath,
......@@ -168,16 +244,16 @@ export default {
},
}) => {
if (errors.length > 0) {
this.showErrors(errors);
this.showErrors({ message: errorMessage, errors });
this.loading = false;
} else {
redirectTo(this.profilesLibraryPath);
}
},
)
.catch(e => {
Sentry.captureException(e);
this.showErrors();
.catch(exception => {
this.showErrors({ message: errorMessage });
this.captureException(exception);
this.loading = false;
});
},
......@@ -191,11 +267,16 @@ export default {
discard() {
redirectTo(this.profilesLibraryPath);
},
showErrors(errors = []) {
captureException(exception) {
Sentry.captureException(exception);
},
showErrors({ message, errors = [] }) {
this.errorMessage = message;
this.errors = errors;
this.showAlert = true;
},
hideErrors() {
this.errorMessage = '';
this.errors = [];
this.showAlert = false;
},
......@@ -210,8 +291,14 @@ export default {
{{ i18n.title }}
</h2>
<gl-alert v-if="showAlert" variant="danger" class="gl-mb-5" @dismiss="hideErrors">
{{ i18n.errorMessage }}
<gl-alert
v-if="showAlert"
variant="danger"
class="gl-mb-5"
data-testid="dast-site-profile-form-alert"
@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>
......@@ -232,7 +319,7 @@ export default {
data-testid="target-url-input-group"
:invalid-feedback="form.targetUrl.feedback"
:description="
validateSite
validateSite && !isValidatingSite
? s__('DastProfiles|Validation must be turned off to change the target URL')
: null
"
......@@ -267,13 +354,15 @@ export default {
v-model="validateSite"
data-testid="dast-site-validation-toggle"
:disabled="!form.targetUrl.state"
:is-loading="isFetchingValidationStatus"
:is-loading="isFetchingValidationStatus || isValidatingSite"
/>
</gl-form-group>
<gl-collapse :visible="showValidationSection">
<dast-site-validation
token="asd"
:full-path="fullPath"
:token-id="tokenId"
:token="token"
:target-url="form.targetUrl.value"
@success="isSiteValid = true"
/>
......
<script>
import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlButton,
......@@ -12,7 +13,13 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import download from '~/lib/utils/downloader';
import { DAST_SITE_VALIDATION_METHOD_TEXT_FILE, DAST_SITE_VALIDATION_METHODS } from '../constants';
import {
DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
DAST_SITE_VALIDATION_METHODS,
DAST_SITE_VALIDATION_STATUS,
} from '../constants';
import dastSiteValidationCreateMutation from '../graphql/dast_site_validation_create.mutation.graphql';
import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graphql';
export default {
name: 'DastSiteValidation',
......@@ -28,18 +35,59 @@ export default {
GlInputGroupText,
GlLoadingIcon,
},
apollo: {
dastSiteValidation: {
query: dastSiteValidationQuery,
variables() {
return {
fullPath: this.fullPath,
targetUrl: this.targetUrl,
};
},
manual: true,
result({
data: {
project: {
dastSiteValidation: { status },
},
},
}) {
if (status === DAST_SITE_VALIDATION_STATUS.VALID) {
this.onSuccess();
}
},
skip() {
return !(this.isCreatingValidation || this.isValidating);
},
pollInterval: 1000,
error(e) {
this.onError(e);
},
},
},
props: {
fullPath: {
type: String,
required: true,
},
targetUrl: {
type: String,
required: true,
},
tokenId: {
type: String,
required: false,
default: null,
},
token: {
type: String,
required: true,
required: false,
default: null,
},
},
data() {
return {
isCreatingValidation: false,
isValidating: false,
hasValidationError: false,
validationMethod: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
......@@ -66,20 +114,45 @@ export default {
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.isCreatingValidation = true;
this.isValidating = true;
try {
await new Promise(resolve => {
setTimeout(resolve, 1000);
const {
data: {
dastSiteValidationCreate: { status, errors },
},
} = await this.$apollo.mutate({
mutation: dastSiteValidationCreateMutation,
variables: {
projectFullPath: this.fullPath,
dastSiteTokenId: this.tokenId,
strategy: this.validationMethod,
},
});
this.isValidating = false;
this.$emit('success');
} catch (_) {
this.hasValidationError = true;
this.isValidating = false;
if (errors?.length) {
this.onError();
} else if (status === DAST_SITE_VALIDATION_STATUS.VALID) {
this.onSuccess();
} else {
this.isCreatingValidation = false;
}
} catch (exception) {
this.onError(exception);
}
},
onSuccess() {
this.isCreatingValidation = false;
this.isValidating = false;
this.$emit('success');
},
onError(exception = null) {
if (exception !== null) {
Sentry.captureException(exception);
}
this.isCreatingValidation = false;
this.isValidating = false;
this.hasValidationError = true;
},
},
validationMethodOptions: Object.values(DAST_SITE_VALIDATION_METHODS),
......
import { s__ } from '~/locale';
export const DAST_SITE_VALIDATION_METHOD_TEXT_FILE = 'DAST_SITE_VALIDATION_METHOD_TEXT_FILE';
export const DAST_SITE_VALIDATION_METHOD_TEXT_FILE = 'TEXT_FILE';
export const DAST_SITE_VALIDATION_METHODS = {
DAST_SITE_VALIDATION_METHOD_TEXT_FILE: {
[DAST_SITE_VALIDATION_METHOD_TEXT_FILE]: {
value: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
text: s__('DastProfiles|Text file validation'),
i18n: {
......@@ -10,3 +10,8 @@ export const DAST_SITE_VALIDATION_METHODS = {
},
},
};
export const DAST_SITE_VALIDATION_STATUS = {
VALID: 'PASSED_VALIDATION',
INVALID: 'FAILED_VALIDATION',
};
mutation dastSiteTokenCreate($projectFullPath: ID!, $targetUrl: String!) {
dastSiteTokenCreate(input: { projectFullPath: $projectFullPath, targetUrl: $targetUrl }) {
token
errors
}
}
query project($fullPath: ID!, $targetUrl: String!) {
project(fullPath: $fullPath) {
dastSiteValidation(targetUrl: $targetUrl) {
status
}
}
}
mutation dastSiteValidationCreate(
$projectFullPath: ID!
$dastSiteTokenId: DastSiteTokenID!
$validationStrategy: DastSiteValidationStrategyEnum
) {
dastSiteValidationCreate(
input: {
projectFullPath: $projectFullPath
dastSiteTokenId: $dastSiteTokenId
strategy: $validationStrategy
}
) {
status
errors
}
}
import merge from 'lodash/merge';
import VueApollo from 'vue-apollo';
import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import { createMockClient } from 'mock-apollo-client';
import { GlLoadingIcon } from '@gitlab/ui';
import waitForPromises from 'jest/helpers/wait_for_promises';
import DastSiteValidation from 'ee/dast_site_profiles_form/components/dast_site_validation.vue';
import dastSiteValidationCreateMutation from 'ee/dast_site_profiles_form/graphql/dast_site_validation_create.mutation.graphql';
import dastSiteValidationQuery from 'ee/dast_site_profiles_form/graphql/dast_site_validation.query.graphql';
import * as responses from 'ee_jest/dast_site_profiles_form/mock_data/apollo_mock';
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 localVue = createLocalVue();
localVue.use(VueApollo);
const fullPath = 'group/project';
const targetUrl = 'https://example.com/';
const token = 'validation-token-123';
const defaultProps = {
fullPath,
targetUrl,
token,
};
const defaultRequestHandlers = {
dastSiteValidation: jest.fn().mockResolvedValue(responses.dastSiteValidation()),
dastSiteValidationCreate: jest.fn().mockResolvedValue(responses.dastSiteValidationCreate()),
};
describe('DastSiteValidation', () => {
let wrapper;
let apolloProvider;
let requestHandlers;
const mockClientFactory = handlers => {
const mockClient = createMockClient();
requestHandlers = {
...defaultRequestHandlers,
...handlers,
};
mockClient.setRequestHandler(dastSiteValidationQuery, requestHandlers.dastSiteValidation);
mockClient.setRequestHandler(
dastSiteValidationCreateMutation,
requestHandlers.dastSiteValidationCreate,
);
return mockClient;
};
const respondWith = handlers => {
apolloProvider.defaultClient = mockClientFactory(handlers);
};
const componentFactory = (mountFn = shallowMount) => options => {
apolloProvider = new VueApollo({
defaultClient: mockClientFactory(),
});
wrapper = mountFn(
DastSiteValidation,
merge(
......@@ -30,6 +71,10 @@ describe('DastSiteValidation', () => {
propsData: defaultProps,
},
options,
{
localVue,
apolloProvider,
},
),
);
};
......@@ -41,6 +86,10 @@ describe('DastSiteValidation', () => {
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);
const findErrorMessage = () =>
withinComponent().queryByText(
/validation failed, please make sure that you follow the steps above with the choosen method./i,
);
afterEach(() => {
wrapper.destroy();
......@@ -88,24 +137,45 @@ describe('DastSiteValidation', () => {
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...');
});
describe('success', () => {
beforeEach(() => {
findValidateButton().vm.$emit('click');
});
it('on success, emits success event', async () => {
jest.spyOn(wrapper.vm, '$emit');
jest.runAllTimers();
await wrapper.vm.$nextTick();
it('while validating, shows a loading state', () => {
expect(findLoadingIcon().exists()).toBe(true);
expect(wrapper.text()).toContain('Validating...');
});
it('on success, emits success event', async () => {
await waitForPromises();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('success');
expect(wrapper.emitted('success')).toHaveLength(1);
});
});
// 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');
describe.each`
errorKind | errorResponse
${'top level error'} | ${() => Promise.reject(new Error('GraphQL Network Error'))}
${'errors as data'} | ${() => Promise.resolve(responses.dastSiteValidationCreate(['error#1', 'error#2']))}
`('$errorKind', ({ errorResponse }) => {
beforeEach(() => {
respondWith({
dastSiteValidationCreate: errorResponse,
});
});
it('on error, shows error state', async () => {
expect(findErrorMessage()).toBe(null);
findValidateButton().vm.$emit('click');
await waitForPromises();
expect(findErrorMessage()).not.toBe(null);
});
});
});
});
export const dastSiteProfileCreate = (errors = []) => ({
data: { dastSiteProfileCreate: { id: '3083', errors } },
});
export const dastSiteProfileUpdate = (errors = []) => ({
data: { dastSiteProfileUpdate: { id: '3083', errors } },
});
export const dastSiteValidation = (status = 'FAILED_VALIDATION') => ({
data: { project: { dastSiteValidation: { status, id: '1' } } },
});
export const dastSiteValidationCreate = (errors = []) => ({
data: { dastSiteValidationCreate: { status: 'PASSED_VALIDATION', id: '1', errors } },
});
export const dastSiteTokenCreate = ({ id = '1', token = '1', errors = [] }) => ({
data: {
dastSiteTokenCreate: {
id,
token,
errors,
},
},
});
......@@ -7798,6 +7798,9 @@ msgstr ""
msgid "DastProfiles|Are you sure you want to delete this profile?"
msgstr ""
msgid "DastProfiles|Could not create site validation token. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not create the scanner profile. Please try again."
msgstr ""
......@@ -7822,6 +7825,9 @@ msgstr ""
msgid "DastProfiles|Could not fetch site profiles. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not retrieve site validation status. Please refresh the page, or try again later."
msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again."
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