Commit 97b71aa8 authored by Mark Florian's avatar Mark Florian

Merge branch '280570-profiles-library-validation-status-polling' into 'master'

Validation statuses polling

See merge request gitlab-org/gitlab!48705
parents ffeb9908 4e230bcd
......@@ -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 } = DAST_SITE_VALIDATION_STATUS;
export default {
components: {
......@@ -17,6 +21,42 @@ 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.glFeatures.securityOnDemandScansSiteValidation || !this.urlsPendingValidation.length
);
},
result({
data: {
project: {
validations: { nodes = [] },
},
},
}) {
const store = this.$apollo.getClient();
nodes.forEach(({ normalizedTargetUrl, status }) => {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status,
store,
});
});
},
},
},
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -26,6 +66,10 @@ export default {
type: String,
required: true,
},
profiles: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -33,6 +77,19 @@ export default {
};
},
statuses: DAST_SITE_VALIDATION_STATUS_PROPS,
computed: {
urlsPendingValidation() {
return this.profiles.reduce((acc, { validationStatus, normalizedTargetUrl }) => {
if (
[PENDING, INPROGRESS].includes(validationStatus) &&
!acc.includes(normalizedTargetUrl)
) {
return [...acc, normalizedTargetUrl];
}
return acc;
}, []);
},
},
methods: {
shouldShowValidationBtn(status) {
return (
......@@ -52,11 +109,19 @@ export default {
this.showValidationModal();
});
},
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apollo.getClient(),
});
},
},
};
</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 +152,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,34 @@ 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
__typename
}
`,
data: {
validationStatus: status,
__typename: 'DastSiteProfile',
},
});
});
};
......@@ -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
}
......@@ -11,6 +12,7 @@ query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first:
node {
id
profileName
normalizedTargetUrl
targetUrl
editPath
validationStatus
......
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 = 3000;
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 { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { siteProfiles } from '../mocks/mock_data';
import * as responses from '../mocks/apollo_mock';
jest.mock('ee/security_configuration/dast_profiles/graphql/cache_utils', () => ({
updateSiteProfilesStatuses: jest.fn(),
}));
describe('EE - DastSiteProfileList', () => {
let localVue;
let wrapper;
let requestHandlers;
let apolloProvider;
const defaultProps = {
profiles: [],
......@@ -20,7 +33,15 @@ describe('EE - DastSiteProfileList', () => {
isLoading: false,
};
const wrapperFactory = (mountFn = shallowMount) => (options = {}) => {
const createMockApolloProvider = handlers => {
localVue.use(VueApollo);
requestHandlers = handlers;
return createApolloProvider([[dastSiteValidationsQuery, requestHandlers.dastSiteValidations]]);
};
const wrapperFactory = (mountFn = shallowMount) => (options = {}, handlers) => {
localVue = createLocalVue();
apolloProvider = handlers && createMockApolloProvider(handlers);
wrapper = mountFn(
Component,
merge(
......@@ -30,7 +51,7 @@ describe('EE - DastSiteProfileList', () => {
glFeatures: { securityOnDemandScansSiteValidation: true },
},
},
options,
{ ...options, localVue, apolloProvider },
),
);
};
......@@ -52,6 +73,7 @@ describe('EE - DastSiteProfileList', () => {
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
});
it('renders profile list properly', () => {
......@@ -81,16 +103,38 @@ 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: DAST_SITE_VALIDATION_STATUS.FAILED,
},
{
normalizedTargetUrl: inProgressValidation.normalizedTargetUrl,
status: DAST_SITE_VALIDATION_STATUS.PASSED,
},
]),
),
},
);
});
describe.each`
status | statusEnum | label | hasValidateButton
${'pending'} | ${'PENDING_VALIDATION'} | ${''} | ${true}
${'in-progress'} | ${'INPROGRESS_VALIDATION'} | ${'Validating...'} | ${false}
${'passed'} | ${'PASSED_VALIDATION'} | ${'Validated'} | ${false}
${'failed'} | ${'FAILED_VALIDATION'} | ${'Validation failed'} | ${true}
status | statusEnum | label | hasValidateButton
${'pending'} | ${DAST_SITE_VALIDATION_STATUS.PENDING} | ${''} | ${true}
${'in-progress'} | ${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validating...'} | ${false}
${'passed'} | ${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validated'} | ${false}
${'failed'} | ${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed'} | ${true}
`('profile with validation $status', ({ statusEnum, label, hasValidateButton }) => {
const profile = siteProfiles.find(({ validationStatus }) => validationStatus === statusEnum);
......@@ -112,6 +156,30 @@ describe('EE - DastSiteProfileList', () => {
}
});
});
it('fetches validation statuses for all profiles that are being validated and updates the cache', () => {
expect(requestHandlers.dastSiteValidations).toHaveBeenCalledWith({
fullPath: defaultProps.fullPath,
urls: urlsPendingValidation,
});
expect(updateSiteProfilesStatuses).toHaveBeenCalledTimes(2);
});
it.each`
nthCall | normalizedTargetUrl | status
${1} | ${pendingValidation.normalizedTargetUrl} | ${DAST_SITE_VALIDATION_STATUS.FAILED}
${2} | ${inProgressValidation.normalizedTargetUrl} | ${DAST_SITE_VALIDATION_STATUS.PASSED}
`(
'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: 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,45 @@ 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
__typename
}
`,
data: {
validationStatus: status,
__typename: 'DastSiteProfile',
},
});
});
});
});
export const dastSiteValidations = (nodes = []) => ({
data: {
project: {
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