diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml index 3dc4b10a6a86c21bffeafa7d7fa4855fdabc475d..2989b3a4262623bd44c59d9ed090c7bf48b1db8f 100644 --- a/config/known_invalid_graphql_queries.yml +++ b/config/known_invalid_graphql_queries.yml @@ -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 diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_failed_site_validations.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_failed_site_validations.vue new file mode 100644 index 0000000000000000000000000000000000000000..2fd75ec5151679dc163064c46555b649a81242a6 --- /dev/null +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_failed_site_validations.vue @@ -0,0 +1,108 @@ +<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> diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_profiles.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_profiles.vue index 07c022e2016a5ebd9bd722ec84ae5f97a49baaf5..4a2720d69f6f9e310c90b74a87eac01a8107d59f 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_profiles.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/dast_profiles.vue @@ -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"> diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql b/ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..4421c7cf817b19d91b9d0187d757706d4c884c12 --- /dev/null +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/graphql/dast_failed_site_validations.query.graphql @@ -0,0 +1,9 @@ +query DastFailedSiteValidations($fullPath: ID!) { + project(fullPath: $fullPath) { + validations: dastSiteValidations(normalizedTargetUrls: $urls, status: "FAILED_VALIDATION") { + nodes { + normalizedTargetUrl + } + } + } +} diff --git a/ee/spec/frontend/security_configuration/dast_profiles/components/dast_failed_site_validations_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/components/dast_failed_site_validations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b013732b12c213b849d137b819b9da6d31efe401 --- /dev/null +++ b/ee/spec/frontend/security_configuration/dast_profiles/components/dast_failed_site_validations_spec.js @@ -0,0 +1,142 @@ +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, + }); + }); + }); +}); diff --git a/ee/spec/frontend/security_configuration/dast_profiles/components/dast_profiles_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/components/dast_profiles_spec.js index 72c55458115c19e3f7d62e4f507d739beb8495af..6cace6a4bf66cdb1d90bb6207845f940ee661ec0 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/components/dast_profiles_spec.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/components/dast_profiles_spec.js @@ -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); + }); + }); }); diff --git a/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js b/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js index b9a28c8e530e31f9b7273093ee3b60d5b075a908..50824a6d11f055136a16d6820b6878a36979331f 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js @@ -103,3 +103,12 @@ export const savedScans = [ }, }, ]; + +export const failedSiteValidations = [ + { + normalizedTargetUrl: 'http://example.com:80', + }, + { + normalizedTargetUrl: 'https://example.com:443', + }, +]; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 58f48f98c770873a5e762c52823e87d02b60e7ee..d44ba61fafb8b90e339bfe4c85540b3e69252c15 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 ""