DAST sites validation statuses polling

This implements GraphQL polling in the DAST site profiles library to
regularly update the status of profiles that are being validated.
parent c478b884
...@@ -3,12 +3,16 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; ...@@ -3,12 +3,16 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { import {
DAST_SITE_VALIDATION_STATUS, DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_STATUS_PROPS, DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_POLLING_INTERVAL,
} from 'ee/security_configuration/dast_site_validation/constants'; } from 'ee/security_configuration/dast_site_validation/constants';
import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue'; import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue';
import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validations.query.graphql';
import { updateSiteProfilesStatuses } from '../graphql/cache_utils';
import ProfilesList from './dast_profiles_list.vue'; import ProfilesList from './dast_profiles_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
const { PENDING, FAILED } = DAST_SITE_VALIDATION_STATUS; const { PENDING, INPROGRESS, FAILED, PASSED } = DAST_SITE_VALIDATION_STATUS;
export default { export default {
components: { components: {
...@@ -17,6 +21,44 @@ export default { ...@@ -17,6 +21,44 @@ export default {
DastSiteValidationModal, DastSiteValidationModal,
ProfilesList, ProfilesList,
}, },
apollo: {
validations: {
query: dastSiteValidationsQuery,
fetchPolicy: fetchPolicies.NO_CACHE,
manual: true,
variables() {
return {
fullPath: this.fullPath,
urls: this.urlsPendingValidation,
};
},
pollInterval: DAST_SITE_VALIDATION_POLLING_INTERVAL,
skip() {
return !this.urlsPendingValidation.length;
},
result(response) {
const {
data: {
validations: { nodes = [] },
},
} = response;
const store = this.$apolloProvider.defaultClient;
nodes.forEach(({ normalizedTargetUrl, status }) => {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status,
store,
});
if ([PASSED, FAILED].includes(status)) {
this.urlsPendingValidation = this.urlsPendingValidation.filter(
url => url !== normalizedTargetUrl,
);
}
});
},
},
},
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -26,13 +68,37 @@ export default { ...@@ -26,13 +68,37 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
profiles: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
validatingProfile: null, validatingProfile: null,
urlsPendingValidation: [],
}; };
}, },
statuses: DAST_SITE_VALIDATION_STATUS_PROPS, statuses: DAST_SITE_VALIDATION_STATUS_PROPS,
watch: {
profiles: {
immediate: true,
deep: true,
handler(profiles = []) {
if (!this.glFeatures.securityOnDemandScansSiteValidation) {
return;
}
profiles.forEach(({ validationStatus, normalizedTargetUrl }) => {
if (
[PENDING, INPROGRESS].includes(validationStatus) &&
!this.urlsPendingValidation.includes(normalizedTargetUrl)
) {
this.urlsPendingValidation.push(normalizedTargetUrl);
}
});
},
},
},
methods: { methods: {
shouldShowValidationBtn(status) { shouldShowValidationBtn(status) {
return ( return (
...@@ -52,11 +118,19 @@ export default { ...@@ -52,11 +118,19 @@ export default {
this.showValidationModal(); this.showValidationModal();
}); });
}, },
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apolloProvider.defaultClient,
});
},
}, },
}; };
</script> </script>
<template> <template>
<profiles-list :full-path="fullPath" v-bind="$attrs" v-on="$listeners"> <profiles-list :full-path="fullPath" :profiles="profiles" v-bind="$attrs" v-on="$listeners">
<template #cell(validationStatus)="{ value }"> <template #cell(validationStatus)="{ value }">
<template v-if="shouldShowValidationStatus(value)"> <template v-if="shouldShowValidationStatus(value)">
<span :class="$options.statuses[value].cssClass"> <span :class="$options.statuses[value].cssClass">
...@@ -87,6 +161,7 @@ export default { ...@@ -87,6 +161,7 @@ export default {
ref="dast-site-validation-modal" ref="dast-site-validation-modal"
:full-path="fullPath" :full-path="fullPath"
:target-url="validatingProfile.targetUrl" :target-url="validatingProfile.targetUrl"
@primary="startValidatingProfile(validatingProfile)"
/> />
</profiles-list> </profiles-list>
</template> </template>
import { produce } from 'immer'; import { produce } from 'immer';
import gql from 'graphql-tag';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
/** /**
* Appends paginated results to existing ones * Appends paginated results to existing ones
* - to be used with $apollo.queries.x.fetchMore * - to be used with $apollo.queries.x.fetchMore
...@@ -54,3 +57,32 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) => ...@@ -54,3 +57,32 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) =>
errors: [], errors: [],
}, },
}); });
export const updateSiteProfilesStatuses = ({ fullPath, normalizedTargetUrl, status, store }) => {
const queryBody = {
query: dastSiteProfilesQuery,
variables: {
fullPath,
},
};
const sourceData = store.readQuery(queryBody);
const profilesWithNormalizedTargetUrl = sourceData.project.siteProfiles.edges.flatMap(
({ node }) => (node.normalizedTargetUrl === normalizedTargetUrl ? node : []),
);
profilesWithNormalizedTargetUrl.forEach(({ id }) => {
store.writeFragment({
id: `DastSiteProfile:${id}`,
fragment: gql`
fragment profile on DastSiteProfile {
validationStatus
}
`,
data: {
validationStatus: status,
},
});
});
};
...@@ -2,7 +2,8 @@ ...@@ -2,7 +2,8 @@
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) { query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last) { siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
@connection(key: "dastSiteProfiles") {
pageInfo { pageInfo {
...PageInfo ...PageInfo
} }
......
query project($fullPath: ID!, $targetUrl: String!) {
project(fullPath: $fullPath) {
dastSiteValidation(targetUrl: $targetUrl) {
status
}
}
}
...@@ -50,3 +50,5 @@ export const DAST_SITE_VALIDATION_STATUS_PROPS = { ...@@ -50,3 +50,5 @@ export const DAST_SITE_VALIDATION_STATUS_PROPS = {
export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST'; export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST';
export const DAST_SITE_VALIDATION_MODAL_ID = 'dast-site-validation-modal'; export const DAST_SITE_VALIDATION_MODAL_ID = 'dast-site-validation-modal';
export const DAST_SITE_VALIDATION_POLLING_INTERVAL = 1000;
query project($fullPath: ID!, $urls: [String!]) {
project(fullPath: $fullPath) {
validations: dastSiteValidations(normalizedTargetUrls: $urls) {
nodes {
normalizedTargetUrl
status
}
}
}
}
...@@ -4,7 +4,7 @@ import { mount, shallowMount, createWrapper } from '@vue/test-utils'; ...@@ -4,7 +4,7 @@ import { mount, shallowMount, createWrapper } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { siteProfiles as profiles } from './mock_data'; import { siteProfiles as profiles } from '../mocks/mock_data';
const TEST_ERROR_MESSAGE = 'something went wrong'; const TEST_ERROR_MESSAGE = 'something went wrong';
......
...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import Component from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue'; import Component from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { scannerProfiles } from './mock_data'; import { scannerProfiles } from '../mocks/mock_data';
describe('EE - DastScannerProfileList', () => { describe('EE - DastScannerProfileList', () => {
let wrapper; let wrapper;
......
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { within } from '@testing-library/dom'; import { within } from '@testing-library/dom';
import { merge } from 'lodash'; import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import createApolloProvider from 'helpers/mock_apollo_helper';
import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validations.query.graphql';
import Component from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue'; import Component from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { siteProfiles } from './mock_data'; import { updateSiteProfilesStatuses } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { siteProfiles } from '../mocks/mock_data';
import * as responses from '../mocks/apollo_mock';
jest.mock('ee/security_configuration/dast_profiles/graphql/cache_utils');
updateSiteProfilesStatuses.mockImplementation(() => ({
updateSiteProfilesStatuses: jest.fn(),
}));
describe('EE - DastSiteProfileList', () => { describe('EE - DastSiteProfileList', () => {
let localVue;
let wrapper; let wrapper;
let requestHandlers;
const defaultProps = { const defaultProps = {
profiles: [], profiles: [],
...@@ -20,7 +33,20 @@ describe('EE - DastSiteProfileList', () => { ...@@ -20,7 +33,20 @@ describe('EE - DastSiteProfileList', () => {
isLoading: false, isLoading: false,
}; };
const wrapperFactory = (mountFn = shallowMount) => (options = {}) => { const createMockApolloProvider = handlers => {
localVue.use(VueApollo);
requestHandlers = {
dastSiteValidations: jest.fn().mockResolvedValue(responses.dastSiteValidations()),
...handlers,
};
return createApolloProvider([[dastSiteValidationsQuery, requestHandlers.dastSiteValidations]]);
};
const wrapperFactory = (mountFn = shallowMount) => (options = {}, handlers) => {
localVue = createLocalVue();
const apolloProvider = handlers && createMockApolloProvider(handlers);
wrapper = mountFn( wrapper = mountFn(
Component, Component,
merge( merge(
...@@ -30,7 +56,7 @@ describe('EE - DastSiteProfileList', () => { ...@@ -30,7 +56,7 @@ describe('EE - DastSiteProfileList', () => {
glFeatures: { securityOnDemandScansSiteValidation: true }, glFeatures: { securityOnDemandScansSiteValidation: true },
}, },
}, },
options, { ...options, localVue, apolloProvider },
), ),
); );
}; };
...@@ -81,8 +107,30 @@ describe('EE - DastSiteProfileList', () => { ...@@ -81,8 +107,30 @@ describe('EE - DastSiteProfileList', () => {
}); });
describe('with site validation enabled', () => { describe('with site validation enabled', () => {
const [pendingValidation, inProgressValidation] = siteProfiles;
const urlsPendingValidation = [
pendingValidation.normalizedTargetUrl,
inProgressValidation.normalizedTargetUrl,
];
beforeEach(() => { beforeEach(() => {
createFullComponent({ propsData: { siteProfiles } }); createFullComponent(
{ propsData: { profiles: siteProfiles } },
{
dastSiteValidations: jest.fn().mockResolvedValue(
responses.dastSiteValidations([
{
normalizedTargetUrl: pendingValidation.normalizedTargetUrl,
status: 'FAILED_VALIDATION',
},
{
normalizedTargetUrl: inProgressValidation.normalizedTargetUrl,
status: 'PASSED_VALIDATION',
},
]),
),
},
);
}); });
describe.each` describe.each`
...@@ -112,6 +160,30 @@ describe('EE - DastSiteProfileList', () => { ...@@ -112,6 +160,30 @@ describe('EE - DastSiteProfileList', () => {
} }
}); });
}); });
it('fetches validation statuses for all profiles that are being validated and updates the cache', async () => {
expect(requestHandlers.dastSiteValidations).toHaveBeenCalledWith({
fullPath: defaultProps.fullPath,
urls: urlsPendingValidation,
});
expect(updateSiteProfilesStatuses).toHaveBeenCalledTimes(2);
});
it.each`
nthCall | normalizedTargetUrl | status
${1} | ${pendingValidation.normalizedTargetUrl} | ${'FAILED_VALIDATION'}
${2} | ${inProgressValidation.normalizedTargetUrl} | ${'PASSED_VALIDATION'}
`(
'in the local cache, profile with normalized URL $normalizedTargetUrl has its status set to $status',
({ nthCall, normalizedTargetUrl, status }) => {
expect(updateSiteProfilesStatuses).toHaveBeenNthCalledWith(nthCall, {
fullPath: defaultProps.fullPath,
normalizedTargetUrl,
status,
store: wrapper.vm.$apolloProvider.defaultClient,
});
},
);
}); });
describe('without site validation enabled', () => { describe('without site validation enabled', () => {
......
import gql from 'graphql-tag';
import { import {
appendToPreviousResult, appendToPreviousResult,
removeProfile, removeProfile,
dastProfilesDeleteResponse, dastProfilesDeleteResponse,
updateSiteProfilesStatuses,
} from 'ee/security_configuration/dast_profiles/graphql/cache_utils'; } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { siteProfiles } from '../mocks/mock_data';
describe('EE - DastProfiles GraphQL CacheUtils', () => { describe('EE - DastProfiles GraphQL CacheUtils', () => {
describe('appendToPreviousResult', () => { describe('appendToPreviousResult', () => {
...@@ -72,4 +75,43 @@ describe('EE - DastProfiles GraphQL CacheUtils', () => { ...@@ -72,4 +75,43 @@ describe('EE - DastProfiles GraphQL CacheUtils', () => {
}); });
}); });
}); });
describe('updateSiteProfilesStatuses', () => {
it.each`
siteProfile | status
${siteProfiles[0]} | ${'PASSED_VALIDATION'}
${siteProfiles[1]} | ${'FAILED_VALIDATION'}
`("set the profile's status in the cache", ({ siteProfile, status }) => {
const mockData = {
project: {
siteProfiles: {
edges: [{ node: siteProfile }],
},
},
};
const mockStore = {
readQuery: () => mockData,
writeFragment: jest.fn(),
};
updateSiteProfilesStatuses({
fullPath: 'full/path',
normalizedTargetUrl: siteProfile.normalizedTargetUrl,
status,
store: mockStore,
});
expect(mockStore.writeFragment).toHaveBeenCalledWith({
id: `DastSiteProfile:${siteProfile.id}`,
fragment: gql`
fragment profile on DastSiteProfile {
validationStatus
}
`,
data: {
validationStatus: status,
},
});
});
});
}); });
export const dastSiteValidations = (nodes = []) => ({
data: {
validations: {
nodes,
},
},
});
...@@ -3,6 +3,7 @@ export const siteProfiles = [ ...@@ -3,6 +3,7 @@ export const siteProfiles = [
id: 1, id: 1,
profileName: 'Profile 1', profileName: 'Profile 1',
targetUrl: 'http://example-1.com', targetUrl: 'http://example-1.com',
normalizedTargetUrl: 'http://example-1.com',
editPath: '/1/edit', editPath: '/1/edit',
validationStatus: 'PENDING_VALIDATION', validationStatus: 'PENDING_VALIDATION',
}, },
...@@ -10,6 +11,7 @@ export const siteProfiles = [ ...@@ -10,6 +11,7 @@ export const siteProfiles = [
id: 2, id: 2,
profileName: 'Profile 2', profileName: 'Profile 2',
targetUrl: 'http://example-2.com', targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/2/edit', editPath: '/2/edit',
validationStatus: 'INPROGRESS_VALIDATION', validationStatus: 'INPROGRESS_VALIDATION',
}, },
...@@ -17,6 +19,7 @@ export const siteProfiles = [ ...@@ -17,6 +19,7 @@ export const siteProfiles = [
id: 3, id: 3,
profileName: 'Profile 3', profileName: 'Profile 3',
targetUrl: 'http://example-2.com', targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/3/edit', editPath: '/3/edit',
validationStatus: 'PASSED_VALIDATION', validationStatus: 'PASSED_VALIDATION',
}, },
...@@ -24,6 +27,7 @@ export const siteProfiles = [ ...@@ -24,6 +27,7 @@ export const siteProfiles = [
id: 4, id: 4,
profileName: 'Profile 4', profileName: 'Profile 4',
targetUrl: 'http://example-3.com', targetUrl: 'http://example-3.com',
normalizedTargetUrl: 'http://example-3.com',
editPath: '/3/edit', editPath: '/3/edit',
validationStatus: 'FAILED_VALIDATION', validationStatus: 'FAILED_VALIDATION',
}, },
......
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