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';
import {
DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_POLLING_INTERVAL,
} from 'ee/security_configuration/dast_site_validation/constants';
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 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 {
components: {
......@@ -17,6 +21,44 @@ export default {
DastSiteValidationModal,
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: {
GlTooltip: GlTooltipDirective,
},
......@@ -26,13 +68,37 @@ export default {
type: String,
required: true,
},
profiles: {
type: Array,
required: true,
},
},
data() {
return {
validatingProfile: null,
urlsPendingValidation: [],
};
},
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: {
shouldShowValidationBtn(status) {
return (
......@@ -52,11 +118,19 @@ export default {
this.showValidationModal();
});
},
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apolloProvider.defaultClient,
});
},
},
};
</script>
<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 v-if="shouldShowValidationStatus(value)">
<span :class="$options.statuses[value].cssClass">
......@@ -87,6 +161,7 @@ export default {
ref="dast-site-validation-modal"
:full-path="fullPath"
:target-url="validatingProfile.targetUrl"
@primary="startValidatingProfile(validatingProfile)"
/>
</profiles-list>
</template>
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
* - to be used with $apollo.queries.x.fetchMore
......@@ -54,3 +57,32 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) =>
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 @@
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
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
}
......
query project($fullPath: ID!, $targetUrl: String!) {
project(fullPath: $fullPath) {
dastSiteValidation(targetUrl: $targetUrl) {
status
}
}
}
......@@ -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_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';
import { merge } from 'lodash';
import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
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';
......
......@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
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 { scannerProfiles } from './mock_data';
import { scannerProfiles } from '../mocks/mock_data';
describe('EE - DastScannerProfileList', () => {
let wrapper;
......
import { mount, shallowMount } from '@vue/test-utils';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { within } from '@testing-library/dom';
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 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', () => {
let localVue;
let wrapper;
let requestHandlers;
const defaultProps = {
profiles: [],
......@@ -20,7 +33,20 @@ describe('EE - DastSiteProfileList', () => {
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(
Component,
merge(
......@@ -30,7 +56,7 @@ describe('EE - DastSiteProfileList', () => {
glFeatures: { securityOnDemandScansSiteValidation: true },
},
},
options,
{ ...options, localVue, apolloProvider },
),
);
};
......@@ -81,8 +107,30 @@ describe('EE - DastSiteProfileList', () => {
});
describe('with site validation enabled', () => {
const [pendingValidation, inProgressValidation] = siteProfiles;
const urlsPendingValidation = [
pendingValidation.normalizedTargetUrl,
inProgressValidation.normalizedTargetUrl,
];
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`
......@@ -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', () => {
......
import gql from 'graphql-tag';
import {
appendToPreviousResult,
removeProfile,
dastProfilesDeleteResponse,
updateSiteProfilesStatuses,
} from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { siteProfiles } from '../mocks/mock_data';
describe('EE - DastProfiles GraphQL CacheUtils', () => {
describe('appendToPreviousResult', () => {
......@@ -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 = [
id: 1,
profileName: 'Profile 1',
targetUrl: 'http://example-1.com',
normalizedTargetUrl: 'http://example-1.com',
editPath: '/1/edit',
validationStatus: 'PENDING_VALIDATION',
},
......@@ -10,6 +11,7 @@ export const siteProfiles = [
id: 2,
profileName: 'Profile 2',
targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/2/edit',
validationStatus: 'INPROGRESS_VALIDATION',
},
......@@ -17,6 +19,7 @@ export const siteProfiles = [
id: 3,
profileName: 'Profile 3',
targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/3/edit',
validationStatus: 'PASSED_VALIDATION',
},
......@@ -24,6 +27,7 @@ export const siteProfiles = [
id: 4,
profileName: 'Profile 4',
targetUrl: 'http://example-3.com',
normalizedTargetUrl: 'http://example-3.com',
editPath: '/3/edit',
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