Commit f26d5345 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Brandon Labuschagne

Show failed site validations in profiles library

Shows an alert for each failed site validation in the DAST profiles
library, along with a button to retry the validation, and a dismiss
button that revokes the validation.
parent 2111cd7d
......@@ -3,3 +3,4 @@ filenames:
- ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/api_fuzzing_ci_configuration.query.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
- ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue';
import { DAST_SITE_VALIDATION_MODAL_ID } from 'ee/security_configuration/dast_site_validation/constants';
import dastSiteValidationRevokeMutation from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validation_revoke.mutation.graphql';
import dastFailedSiteValidationsQuery from '../graphql/dast_failed_site_validations.query.graphql';
export default {
name: 'DastFailedSiteValidations',
dastSiteValidationModalId: DAST_SITE_VALIDATION_MODAL_ID,
components: {
GlAlert,
GlLink,
GlSprintf,
DastSiteValidationModal,
},
props: {
fullPath: {
type: String,
required: true,
},
},
data() {
return {
failedValidations: [],
validateTargetUrl: null,
};
},
apollo: {
dastFailedSiteValidations: {
query: dastFailedSiteValidationsQuery,
manual: true,
variables() {
return {
fullPath: this.fullPath,
};
},
result({
data: {
project: {
validations: { nodes },
},
},
}) {
this.failedValidations = nodes.map((node) => ({
...node,
url: new URL(node.normalizedTargetUrl).href,
}));
},
},
},
methods: {
retryValidation({ url }) {
this.validateTargetUrl = url;
this.$nextTick(() => {
this.$refs[DAST_SITE_VALIDATION_MODAL_ID].show();
});
},
revokeValidation({ normalizedTargetUrl }) {
this.$apollo.mutate({
mutation: dastSiteValidationRevokeMutation,
variables: {
fullPath: this.fullPath,
normalizedTargetUrl,
},
});
this.failedValidations = this.failedValidations.filter(
(failedValidation) => failedValidation.normalizedTargetUrl !== normalizedTargetUrl,
);
},
},
};
</script>
<template>
<div v-if="failedValidations.length">
<gl-alert
v-for="failedValidation in failedValidations"
:key="failedValidation.url"
variant="danger"
class="gl-mt-3"
@dismiss="revokeValidation(failedValidation)"
>
<gl-sprintf
:message="
s__(
'DastSiteValidation|Validation failed for %{url}. %{retryButtonStart}Retry validation%{retryButtonEnd}.',
)
"
>
<template #url>{{ failedValidation.url }}</template>
<template #retryButton="{ content }"
><gl-link href="#" role="button" @click="retryValidation(failedValidation)">{{
content
}}</gl-link></template
>
</gl-sprintf>
</gl-alert>
<dast-site-validation-modal
v-if="validateTargetUrl"
:ref="$options.dastSiteValidationModalId"
:full-path="fullPath"
:target-url="validateTargetUrl"
@hidden="validateTargetUrl = null"
/>
</div>
</template>
......@@ -7,6 +7,7 @@ import { __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import * as cacheUtils from '../graphql/cache_utils';
import { getProfileSettings } from '../settings/profiles';
import DastFailedSiteValidations from './dast_failed_site_validations.vue';
export default {
components: {
......@@ -14,6 +15,7 @@ export default {
GlDropdownItem,
GlTab,
GlTabs,
DastFailedSiteValidations,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -223,6 +225,10 @@ export default {
<template>
<section>
<dast-failed-site-validations
v-if="glFeatures.dastFailedSiteValidations"
:full-path="projectFullPath"
/>
<header>
<div class="gl-display-flex gl-align-items-center gl-pt-6 gl-pb-4">
<h2 class="my-0">
......
query DastFailedSiteValidations($fullPath: ID!) {
project(fullPath: $fullPath) {
validations: dastSiteValidations(normalizedTargetUrls: $urls, status: "FAILED_VALIDATION") {
nodes {
normalizedTargetUrl
}
}
}
}
import { GlAlert } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import DastFailedSiteValidations from 'ee/security_configuration/dast_profiles/components/dast_failed_site_validations.vue';
import dastFailedSiteValidationsQuery from 'ee/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql';
import dastSiteValidationRevokeMutation from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validation_revoke.mutation.graphql';
import createApolloProvider from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { dastSiteValidationRevoke as dastSiteValidationRevokeResponse } from '../../dast_site_validation/mock_data/apollo_mock';
import { dastSiteValidations as dastSiteValidationsResponse } from '../mocks/apollo_mock';
import { failedSiteValidations } from '../mocks/mock_data';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
const GlModal = {
template: '<div data-testid="validation-modal" />',
methods: {
show: () => {},
},
};
const localVue = createLocalVue();
describe('EE - DastFailedSiteValidations', () => {
let wrapper;
let requestHandlers;
const createMockApolloProvider = (handlers) => {
localVue.use(VueApollo);
requestHandlers = handlers;
return createApolloProvider([
[dastFailedSiteValidationsQuery, requestHandlers.dastFailedSiteValidations],
[dastSiteValidationRevokeMutation, requestHandlers.dastSiteValidationRevoke],
]);
};
const createComponentFactory = (mountFn = shallowMount) => (options = {}, handlers) => {
const defaultProps = {
fullPath: TEST_PROJECT_FULL_PATH,
};
wrapper = extendedWrapper(
mountFn(
DastFailedSiteValidations,
merge(
{
propsData: defaultProps,
localVue,
apolloProvider: createMockApolloProvider(handlers),
stubs: {
GlModal,
},
},
options,
),
),
);
};
const createFullComponent = createComponentFactory(mount);
const withinComponent = () => within(wrapper.element);
const findFirstRetryButton = () =>
withinComponent().getAllByRole('button', { name: /retry validation/i })[0];
const findFirstDismissButton = () =>
withinComponent().getAllByRole('button', { name: /dismiss/i })[0];
const findValidationModal = () => wrapper.findByTestId('validation-modal');
afterEach(() => {
wrapper.destroy();
});
describe('with failed site validations', () => {
beforeEach(() => {
createFullComponent(
{},
{
dastFailedSiteValidations: jest
.fn()
.mockResolvedValue(dastSiteValidationsResponse(failedSiteValidations)),
dastSiteValidationRevoke: jest.fn().mockResolvedValue(dastSiteValidationRevokeResponse()),
},
);
});
it('triggers the dastSiteValidations query', () => {
expect(requestHandlers.dastFailedSiteValidations).toHaveBeenCalledWith({
fullPath: TEST_PROJECT_FULL_PATH,
});
});
it('renders an alert for each failed validation', () => {
expect(wrapper.findAllComponents(GlAlert)).toHaveLength(failedSiteValidations.length);
});
it.each`
index | expectedUrl
${0} | ${'http://example.com/'}
${1} | ${'https://example.com/'}
`('shows parsed URL $expectedUrl in alert #$index', ({ index, expectedUrl }) => {
expect(wrapper.findAllComponents(GlAlert).at(index).text()).toMatchInterpolatedText(
`Validation failed for ${expectedUrl}. Retry validation.`,
);
});
it('shows the validation modal when clicking on a retry button', async () => {
expect(findValidationModal().exists()).toBe(false);
findFirstRetryButton().click();
await wrapper.vm.$nextTick();
const modal = findValidationModal();
expect(modal.exists()).toBe(true);
expect(modal.attributes('targetUrl')).toBe(failedSiteValidations[0].url);
});
it('destroys the modal after it has been hidden', async () => {
findFirstRetryButton().click();
await wrapper.vm.$nextTick();
const modal = findValidationModal();
expect(modal.exists()).toBe(true);
modal.vm.$emit('hidden');
await wrapper.vm.$nextTick();
expect(modal.exists()).toBe(false);
});
it('triggers the dastSiteValidationRevoke GraphQL mutation', async () => {
findFirstDismissButton().click();
await wrapper.vm.$nextTick();
expect(wrapper.findAllComponents(GlAlert)).toHaveLength(1);
expect(requestHandlers.dastSiteValidationRevoke).toHaveBeenCalledWith({
fullPath: TEST_PROJECT_FULL_PATH,
normalizedTargetUrl: failedSiteValidations[0].normalizedTargetUrl,
});
});
});
});
......@@ -2,6 +2,7 @@ import { GlDropdown, GlTabs } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import DastFailedSiteValidations from 'ee/security_configuration/dast_profiles/components/dast_failed_site_validations.vue';
import DastProfiles from 'ee/security_configuration/dast_profiles/components/dast_profiles.vue';
import setWindowLocation from 'helpers/set_window_location_helper';
......@@ -48,6 +49,11 @@ describe('EE - DastProfiles', () => {
{
propsData: defaultProps,
mocks: defaultMocks,
provide: {
glFeatures: {
dastFailedSiteValidations: true,
},
},
},
options,
),
......@@ -73,6 +79,14 @@ describe('EE - DastProfiles', () => {
wrapper.destroy();
});
describe('failed validations', () => {
it('renders the failed site validations summary', () => {
createComponent();
expect(wrapper.findComponent(DastFailedSiteValidations).exists()).toBe(true);
});
});
describe('header', () => {
it('shows a heading that describes the purpose of the page', () => {
createFullComponent();
......@@ -235,4 +249,18 @@ describe('EE - DastProfiles', () => {
expect(mutate).toHaveBeenCalledTimes(1);
});
});
describe('dastFailedSiteValidations feature flag disabled', () => {
it('does not render the failed site validations summary', () => {
createComponent({
provide: {
glFeatures: {
dastFailedSiteValidations: false,
},
},
});
expect(wrapper.findComponent(DastFailedSiteValidations).exists()).toBe(false);
});
});
});
......@@ -103,3 +103,12 @@ export const savedScans = [
},
},
];
export const failedSiteValidations = [
{
normalizedTargetUrl: 'http://example.com:80',
},
{
normalizedTargetUrl: 'https://example.com:443',
},
];
......@@ -9750,6 +9750,9 @@ msgstr ""
msgid "DastSiteValidation|Validation failed"
msgstr ""
msgid "DastSiteValidation|Validation failed for %{url}. %{retryButtonStart}Retry validation%{retryButtonEnd}."
msgstr ""
msgid "DastSiteValidation|Validation succeeded. Both active and passive scans can be run against the target site."
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