Commit f30da9f2 authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Kushal Pandya

Add Profile Selection to DAST Configuration Form

parent 1c737e46
<script>
import { GlCard, GlSkeletonLoader, GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { SCAN_TYPE } from 'ee/security_configuration/dast_scanner_profiles/constants';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { TYPE_SCANNER_PROFILE, TYPE_SITE_PROFILE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { queryToObject } from '~/lib/utils/url_utility';
import { ERROR_MESSAGES, SCANNER_PROFILES_QUERY, SITE_PROFILES_QUERY } from '../../settings';
import ScannerProfileSelector from './scanner_profile_selector.vue';
import SiteProfileSelector from './site_profile_selector.vue';
const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ({
query: fetchQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
const edges = data?.project?.[name]?.edges ?? [];
if (edges.length === 1) {
this[field] = edges[0].node.id;
}
return edges.map(({ node }) => node);
},
error(e) {
Sentry.captureException(e);
this.$emit('error', ERROR_MESSAGES[fetchError]);
},
});
export default {
name: 'DastProfilesSelector',
components: {
GlCard,
GlSkeletonLoader,
GlAlert,
GlSprintf,
ScannerProfileSelector,
SiteProfileSelector,
GlLink,
},
inject: ['fullPath'],
apollo: {
scannerProfiles: createProfilesApolloOptions(
'scannerProfiles',
'selectedScannerProfileId',
SCANNER_PROFILES_QUERY,
),
siteProfiles: createProfilesApolloOptions(
'siteProfiles',
'selectedSiteProfileId',
SITE_PROFILES_QUERY,
),
},
data() {
return {
scannerProfiles: [],
siteProfiles: [],
selectedScannerProfileId: null,
selectedSiteProfileId: null,
errorType: null,
errors: [],
dastSiteValidationDocsPath: helpPagePath('user/application_security/dast/index', {
anchor: 'site-profile-validation',
}),
};
},
computed: {
selectedScannerProfile() {
return this.selectedScannerProfileId
? this.scannerProfiles.find(({ id }) => id === this.selectedScannerProfileId)
: null;
},
selectedSiteProfile() {
return this.selectedSiteProfileId
? this.siteProfiles.find(({ id }) => id === this.selectedSiteProfileId)
: null;
},
isActiveScannerProfile() {
return this.selectedScannerProfile?.scanType === SCAN_TYPE.ACTIVE;
},
isNonValidatedSiteProfile() {
return (
this.selectedSiteProfile &&
this.selectedSiteProfile.validationStatus !== DAST_SITE_VALIDATION_STATUS.PASSED
);
},
hasProfilesConflict() {
return this.isActiveScannerProfile && this.isNonValidatedSiteProfile;
},
isLoadingProfiles() {
return ['scannerProfiles', 'siteProfiles'].some((name) => this.$apollo.queries[name].loading);
},
},
watch: {
selectedScannerProfileId: 'updateProfiles',
selectedSiteProfileId: 'updateProfiles',
hasProfilesConflict: 'updateConflictingProfiles',
},
created() {
const params = queryToObject(window.location.search, { legacySpacesDecode: true });
this.selectedSiteProfileId = params.site_profile_id
? convertToGraphQLId(TYPE_SITE_PROFILE, params.site_profile_id)
: this.selectedSiteProfileId;
this.selectedScannerProfileId = params.scanner_profile_id
? convertToGraphQLId(TYPE_SCANNER_PROFILE, params.scanner_profile_id)
: this.selectedScannerProfileId;
},
methods: {
updateProfiles() {
this.$emit('profiles-selected', {
scannerProfile: this.selectedScannerProfile,
siteProfile: this.selectedSiteProfile,
});
},
updateConflictingProfiles(hasProfilesConflict) {
this.$emit('profiles-has-conflict', hasProfilesConflict);
},
},
};
</script>
<template>
<div data-test-id="dast-profiles-selector">
<template v-if="isLoadingProfiles">
<gl-card v-for="i in 2" :key="i" class="gl-mb-5">
<template #header>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="300" height="15" rx="4" />
</gl-skeleton-loader>
</template>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="600" height="15" rx="4" />
</gl-skeleton-loader>
<gl-skeleton-loader :width="1248" :height="15">
<rect x="0" y="0" width="300" height="15" rx="4" />
</gl-skeleton-loader>
</gl-card>
</template>
<template v-else>
<scanner-profile-selector
v-model="selectedScannerProfileId"
class="gl-mb-5"
:profiles="scannerProfiles"
:selected-profile="selectedScannerProfile"
:has-conflict="hasProfilesConflict"
/>
<site-profile-selector
v-model="selectedSiteProfileId"
class="gl-mb-5"
:profiles="siteProfiles"
:selected-profile="selectedSiteProfile"
:has-conflict="hasProfilesConflict"
/>
<gl-alert
v-if="hasProfilesConflict"
:title="s__('DastProfiles|You cannot run an active scan against an unvalidated site.')"
class="gl-mb-5"
:dismissible="false"
variant="danger"
data-testid="dast-profiles-conflict-alert"
>
<gl-sprintf
:message="
s__(
'DastProfiles|You can either choose a passive scan or validate the target site in your chosen site profile. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}',
)
"
>
<template #docsLink="{ content }">
<gl-link :href="dastSiteValidationDocsPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
</template>
</div>
</template>
<script>
import { GlLink, GlSprintf, GlButton, GlForm } from '@gitlab/ui';
import { GlLink, GlSprintf, GlButton, GlForm, GlAlert } from '@gitlab/ui';
import DastProfilesSelector from 'ee/on_demand_scans/components/profile_selector/dast_profiles_selector.vue';
import ConfigurationSnippetModal from 'ee/security_configuration/components/configuration_snippet_modal.vue';
import { CONFIGURATION_SNIPPET_MODAL_ID } from 'ee/security_configuration/components/constants';
import { s__, __ } from '~/locale';
......@@ -20,7 +21,9 @@ export default {
GlSprintf,
GlButton,
GlForm,
GlAlert,
ConfigurationSnippetModal,
DastProfilesSelector,
},
inject: ['gitlabCiYamlEditPath', 'securityConfigurationPath'],
i18n: {
......@@ -31,11 +34,12 @@ export default {
},
data() {
return {
// eslint-disable-next-line @gitlab/require-i18n-strings
selectedScannerProfileName: 'My DAST Scanner Profile',
// eslint-disable-next-line @gitlab/require-i18n-strings
selectedSiteProfileName: 'My DAST Site Profile',
selectedScannerProfileName: '',
selectedSiteProfileName: '',
isLoading: false,
hasProfilesConflict: false,
errorMessage: '',
showAlert: false,
};
},
computed: {
......@@ -45,13 +49,25 @@ export default {
.replace(DAST_SCANNER_PROFILE_PLACEHOLDER, this.selectedScannerProfileName);
},
isSubmitDisabled() {
return !this.selectedScannerProfileName || !this.selectedSiteProfileName;
return (
!this.selectedScannerProfileName ||
!this.selectedSiteProfileName ||
this.hasProfilesConflict
);
},
},
methods: {
onSubmit() {
this.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show();
},
updateProfiles(profiles) {
this.selectedScannerProfileName = profiles.scannerProfile?.profileName;
this.selectedSiteProfileName = profiles.siteProfile?.profileName;
},
showErrors(error) {
this.errorMessage = error;
this.showAlert = true;
},
},
};
</script>
......@@ -68,6 +84,22 @@ export default {
</p>
</section>
<gl-alert
v-if="showAlert"
variant="danger"
class="gl-mb-5"
data-testid="dast-configuration-error"
:dismissible="false"
>
{{ errorMessage }}
</gl-alert>
<dast-profiles-selector
@profiles="updateProfiles"
@error="showErrors"
@hasProfilesConflict="hasProfilesConflict = $event"
/>
<gl-button
:disabled="isSubmitDisabled"
:loading="isLoading"
......
......@@ -16,7 +16,15 @@ export default function init() {
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
const { securityConfigurationPath, fullPath, gitlabCiYamlEditPath } = el.dataset;
const {
securityConfigurationPath,
fullPath,
gitlabCiYamlEditPath,
siteProfilesLibraryPath,
scannerProfilesLibraryPath,
newScannerProfilePath,
newSiteProfilePath,
} = el.dataset;
return new Vue({
el,
......@@ -25,6 +33,10 @@ export default function init() {
securityConfigurationPath,
fullPath,
gitlabCiYamlEditPath,
siteProfilesLibraryPath,
scannerProfilesLibraryPath,
newScannerProfilePath,
newSiteProfilePath,
},
render(createElement) {
return createElement(DastConfigurationApp);
......
......@@ -5,7 +5,11 @@ module Projects::Security::DastConfigurationHelper
{
security_configuration_path: project_security_configuration_path(project),
full_path: project.full_path,
gitlab_ci_yaml_edit_path: Rails.application.routes.url_helpers.project_ci_pipeline_editor_path(project)
gitlab_ci_yaml_edit_path: Rails.application.routes.url_helpers.project_ci_pipeline_editor_path(project),
scanner_profiles_library_path: project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles'),
site_profiles_library_path: project_security_configuration_dast_scans_path(project, anchor: 'site-profiles'),
new_scanner_profile_path: new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
new_site_profile_path: new_project_security_configuration_dast_scans_dast_site_profile_path(project)
}
end
end
import { GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import DastProfilesSelector from 'ee/on_demand_scans/components/profile_selector/dast_profiles_selector.vue';
import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import createApolloProvider from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { setUrlParams } from '~/lib/utils/url_utility';
import * as responses from '../../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../../mocks/mock_data';
const URL_HOST = 'https://localhost/';
const fullPath = '/project/path';
const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
describe('EE - DAST Profiles Selector', () => {
let wrapper;
let localVue;
let requestHandlers;
const findScannerProfilesSelector = () => wrapper.findComponent(ScannerProfileSelector);
const findSiteProfilesSelector = () => wrapper.findComponent(SiteProfileSelector);
const findProfilesConflictAlert = () => wrapper.findByTestId('dast-profiles-conflict-alert');
const createMockApolloProvider = (handlers) => {
localVue.use(VueApollo);
requestHandlers = {
dastScannerProfiles: jest.fn().mockResolvedValue(responses.dastScannerProfiles()),
dastSiteProfiles: jest.fn().mockResolvedValue(responses.dastSiteProfiles()),
...handlers,
};
return createApolloProvider([
[dastScannerProfilesQuery, requestHandlers.dastScannerProfiles],
[dastSiteProfilesQuery, requestHandlers.dastSiteProfiles],
]);
};
const createComponentFactory = (mountFn = shallowMountExtended) => (
options = {},
withHandlers,
) => {
localVue = createLocalVue();
let defaultMocks = {
$apollo: {
queries: {
siteProfiles: {},
scannerProfiles: {},
},
},
};
let apolloProvider;
if (withHandlers) {
apolloProvider = createMockApolloProvider(withHandlers);
defaultMocks = {};
}
wrapper = mountFn(
DastProfilesSelector,
merge(
{},
{
mocks: defaultMocks,
provide: {
fullPath,
},
stubs: {
GlSprintf,
},
},
{ ...options, localVue, apolloProvider },
{
data() {
return {
scannerProfiles,
siteProfiles,
...options.data,
};
},
},
),
);
};
const createComponent = createComponentFactory();
afterEach(() => {
wrapper.destroy();
});
describe('loading state', () => {
it.each`
scannerProfilesLoading | siteProfilesLoading | isLoading
${true} | ${true} | ${true}
${false} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${false} | ${false}
`(
'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading',
({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => {
createComponent({
mocks: {
$apollo: {
queries: {
scannerProfiles: { loading: scannerProfilesLoading },
siteProfiles: { loading: siteProfilesLoading },
},
},
},
});
expect(wrapper.find(GlSkeletonLoader).exists()).toBe(isLoading);
},
);
});
describe.each`
description | selectedScannerProfile | selectedSiteProfile | hasConflict
${'a passive scan and a non-validated site'} | ${passiveScannerProfile} | ${nonValidatedSiteProfile} | ${false}
${'a passive scan and a validated site'} | ${passiveScannerProfile} | ${validatedSiteProfile} | ${false}
${'an active scan and a non-validated site'} | ${activeScannerProfile} | ${nonValidatedSiteProfile} | ${true}
${'an active scan and a validated site'} | ${activeScannerProfile} | ${validatedSiteProfile} | ${false}
`(
'profiles conflict prevention',
({ description, selectedScannerProfile, selectedSiteProfile, hasConflict }) => {
const setFormData = () => {
findScannerProfilesSelector().vm.$emit('input', selectedScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', selectedSiteProfile.id);
return wrapper.vm.$nextTick();
};
it(
hasConflict
? `warns about conflicting profiles when user selects ${description}`
: `does not report any conflict when user selects ${description}`,
async () => {
createComponent();
await setFormData();
expect(findProfilesConflictAlert().exists()).toBe(hasConflict);
},
);
},
);
describe.each`
profileType | query | selector | profiles
${'scanner'} | ${'dastScannerProfiles'} | ${ScannerProfileSelector} | ${scannerProfiles}
${'site'} | ${'dastSiteProfiles'} | ${SiteProfileSelector} | ${siteProfiles}
`('when there is a single $profileType profile', ({ query, selector, profiles }) => {
const [profile] = profiles;
beforeEach(async () => {
createComponent(
{},
{
[query]: jest.fn().mockResolvedValue(responses[query]([profile])),
},
);
await waitForPromises();
});
it('automatically selects the only available profile', () => {
expect(wrapper.find(selector).attributes('value')).toBe(profile.id);
});
});
describe('populate profiles from query params', () => {
const [siteProfile] = siteProfiles;
const [scannerProfile] = scannerProfiles;
it('scanner profile', () => {
global.jsdom.reconfigure({
url: setUrlParams({ scanner_profile_id: 1 }, URL_HOST),
});
createComponent();
expect(findScannerProfilesSelector().attributes('value')).toBe(scannerProfile.id);
});
it('site profile', () => {
global.jsdom.reconfigure({
url: setUrlParams({ site_profile_id: 1 }, URL_HOST),
});
createComponent();
expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id);
});
it('both scanner & site profile', () => {
global.jsdom.reconfigure({
url: setUrlParams({ site_profile_id: 1, scanner_profile_id: 1 }, URL_HOST),
});
createComponent();
expect(wrapper.find(SiteProfileSelector).attributes('value')).toBe(siteProfile.id);
expect(wrapper.find(ScannerProfileSelector).attributes('value')).toBe(scannerProfile.id);
});
});
});
import { GlSprintf } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import DastProfilesSelector from 'ee/on_demand_scans/components/profile_selector/dast_profiles_selector.vue';
import ConfigurationSnippetModal from 'ee/security_configuration/components/configuration_snippet_modal.vue';
import { CONFIGURATION_SNIPPET_MODAL_ID } from 'ee/security_configuration/components/constants';
import ConfigurationForm from 'ee/security_configuration/dast/components/configuration_form.vue';
......@@ -9,6 +11,11 @@ import { DAST_HELP_PATH } from '~/security_configuration/components/constants';
const securityConfigurationPath = '/security/configuration';
const gitlabCiYamlEditPath = '/ci/editor';
const fullPath = '/project/path';
const selectedScannerProfileName = 'My Scan profile';
const selectedSiteProfileName = 'My site profile';
const template = `# Add \`dast\` to your \`stages:\` configuration
stages:
- dast
......@@ -21,48 +28,127 @@ include:
dast:
stage: dast
dast_configuration:
site_profile: "My DAST Site Profile"
scanner_profile: "My DAST Scanner Profile"
site_profile: "${selectedSiteProfileName}"
scanner_profile: "${selectedScannerProfileName}"
`;
describe('EE - DAST Configuration Form', () => {
let wrapper;
const findSubmitButton = () => wrapper.findByTestId('dast-configuration-submit-button');
const findCancelButton = () => wrapper.findByTestId('dast-configuration-cancel-button');
const findConfigurationSnippetModal = () => wrapper.findComponent(ConfigurationSnippetModal);
const findDastProfilesSelector = () => wrapper.findComponent(DastProfilesSelector);
const findAlert = () => wrapper.findByTestId('dast-configuration-error');
const createComponent = () => {
wrapper = extendedWrapper(
mount(ConfigurationForm, {
provide: {
securityConfigurationPath,
gitlabCiYamlEditPath,
},
stubs: {
GlSprintf,
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultMocks = {
$apollo: {
queries: {
siteProfiles: {},
scannerProfiles: {},
},
}),
},
};
wrapper = extendedWrapper(
mountFn(
ConfigurationForm,
merge(
{},
{
mocks: defaultMocks,
provide: {
securityConfigurationPath,
gitlabCiYamlEditPath,
fullPath,
},
stubs: {
GlSprintf,
},
},
options,
{
data() {
return {
...options.data,
};
},
},
),
),
);
};
beforeEach(() => {
createComponent();
});
const createComponent = createComponentFactory();
const createFullComponent = createComponentFactory(mount);
afterEach(() => {
wrapper.destroy();
});
it('mounts', () => {
expect(wrapper.exists()).toBe(true);
describe('form renders properly', () => {
beforeEach(() => {
createComponent();
});
it('mounts correctly', () => {
expect(wrapper.exists()).toBe(true);
});
it('includes a link to DAST Configuration documentation', () => {
expect(wrapper.html()).toContain(DAST_HELP_PATH);
});
it('loads DAST Profiles Component', () => {
expect(findDastProfilesSelector().exists()).toBe(true);
});
it('does not show an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('error when loading profiles', () => {
const errorMsg = 'error message';
beforeEach(async () => {
createComponent();
await findDastProfilesSelector().vm.$emit('error', errorMsg);
});
it('renders an alert', () => {
expect(findAlert().exists()).toBe(true);
});
it('shows correct error message', () => {
expect(findAlert().text()).toContain(errorMsg);
});
});
it('includes a link to DAST Configuration documentation', () => {
expect(wrapper.html()).toContain(DAST_HELP_PATH);
describe.each`
description | data | isDisabled
${'by default'} | ${{}} | ${true}
${'when conflicting profiles are selected'} | ${{ hasProfilesConflict: true }} | ${true}
${'when form is valid'} | ${{ selectedSiteProfileName, selectedScannerProfileName }} | ${false}
`('submit button', ({ description, data, isDisabled }) => {
it(`is ${isDisabled ? '' : 'not '}disabled ${description}`, () => {
createComponent({
data,
});
expect(findSubmitButton().props('disabled')).toBe(isDisabled);
});
});
describe('form', () => {
describe('form actions are configured correctly', () => {
it('submit button should open the model with correct props', () => {
createFullComponent({
data: {
selectedSiteProfileName,
selectedScannerProfileName,
},
});
jest.spyOn(wrapper.vm.$refs[CONFIGURATION_SNIPPET_MODAL_ID], 'show');
wrapper.find('form').trigger('submit');
......@@ -78,7 +164,8 @@ describe('EE - DAST Configuration Form', () => {
});
it('cancel button points to Security Configuration page', () => {
expect(findCancelButton().attributes('href')).toBe('/security/configuration');
createComponent();
expect(findCancelButton().attributes('href')).toBe(securityConfigurationPath);
});
});
});
......@@ -8,6 +8,10 @@ RSpec.describe Projects::Security::DastConfigurationHelper do
let(:security_configuration_path) { project_security_configuration_path(project) }
let(:full_path) { project.full_path }
let(:gitlab_ci_yaml_edit_path) { Rails.application.routes.url_helpers.project_ci_pipeline_editor_path(project) }
let(:scanner_profiles_library_path) { project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles')}
let(:site_profiles_library_path) { project_security_configuration_dast_scans_path(project, anchor: 'site-profiles')}
let(:new_scanner_profile_path) { new_project_security_configuration_dast_scans_dast_scanner_profile_path(project) }
let(:new_site_profile_path) { new_project_security_configuration_dast_scans_dast_site_profile_path(project) }
describe '#dast_configuration_data' do
subject { helper.dast_configuration_data(project) }
......@@ -16,7 +20,11 @@ RSpec.describe Projects::Security::DastConfigurationHelper do
is_expected.to eq({
security_configuration_path: security_configuration_path,
full_path: full_path,
gitlab_ci_yaml_edit_path: gitlab_ci_yaml_edit_path
gitlab_ci_yaml_edit_path: gitlab_ci_yaml_edit_path,
scanner_profiles_library_path: scanner_profiles_library_path,
site_profiles_library_path: site_profiles_library_path,
new_scanner_profile_path: new_scanner_profile_path,
new_site_profile_path: new_site_profile_path
})
}
end
......
......@@ -10218,6 +10218,12 @@ msgstr ""
msgid "DastProfiles|Website"
msgstr ""
msgid "DastProfiles|You can either choose a passive scan or validate the target site in your chosen site profile. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}"
msgstr ""
msgid "DastProfiles|You cannot run an active scan against an unvalidated site."
msgstr ""
msgid "DastSiteValidation|Copy HTTP header to clipboard"
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