Commit f62d6148 authored by Phil Hughes's avatar Phil Hughes

Merge branch '322872-on-demand-scans-branch-selector' into 'master'

Add branch selector to on-demand scans form

See merge request gitlab-org/gitlab!55684
parents 1d6eea46 1737a7c4
...@@ -4,7 +4,6 @@ import { ...@@ -4,7 +4,6 @@ import {
GlDropdownDivider, GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlSprintf, GlSprintf,
GlIcon,
GlLoadingIcon, GlLoadingIcon,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { debounce, isArray } from 'lodash'; import { debounce, isArray } from 'lodash';
...@@ -27,7 +26,6 @@ export default { ...@@ -27,7 +26,6 @@ export default {
GlDropdownDivider, GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlSprintf, GlSprintf,
GlIcon,
GlLoadingIcon, GlLoadingIcon,
RefResultsSection, RefResultsSection,
}, },
...@@ -111,7 +109,10 @@ export default { ...@@ -111,7 +109,10 @@ export default {
return this.enabledRefTypes.length > 1; return this.enabledRefTypes.length > 1;
}, },
toggleButtonClass() { toggleButtonClass() {
return { 'gl-inset-border-1-red-500!': !this.state }; return {
'gl-inset-border-1-red-500!': !this.state,
'gl-font-monospace': Boolean(this.selectedRef),
};
}, },
footerSlotProps() { footerSlotProps() {
return { return {
...@@ -120,6 +121,9 @@ export default { ...@@ -120,6 +121,9 @@ export default {
query: this.lastQuery, query: this.lastQuery,
}; };
}, },
buttonText() {
return this.selectedRef || this.i18n.noRefSelected;
},
}, },
watch: { watch: {
// Keep the Vuex store synchronized if the parent // Keep the Vuex store synchronized if the parent
...@@ -190,19 +194,12 @@ export default { ...@@ -190,19 +194,12 @@ export default {
<gl-dropdown <gl-dropdown
:header-text="i18n.dropdownHeader" :header-text="i18n.dropdownHeader"
:toggle-class="toggleButtonClass" :toggle-class="toggleButtonClass"
:text="buttonText"
class="ref-selector" class="ref-selector"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"
@shown="focusSearchBox" @shown="focusSearchBox"
> >
<template #button-content>
<span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" data-testid="button-content">
<span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span>
<span v-else>{{ i18n.noRefSelected }}</span>
</span>
<gl-icon name="chevron-down" />
</template>
<template #header> <template #header>
<gl-search-box-by-type <gl-search-box-by-type
ref="searchBox" ref="searchBox"
......
...@@ -23,6 +23,8 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; ...@@ -23,6 +23,8 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import { serializeFormObject } from '~/lib/utils/forms'; import { serializeFormObject } from '~/lib/utils/forms';
import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES } from '~/ref/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import validation from '~/vue_shared/directives/validation'; import validation from '~/vue_shared/directives/validation';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -68,9 +70,11 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ...@@ -68,9 +70,11 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) =>
export default { export default {
SCAN_TYPE_LABEL, SCAN_TYPE_LABEL,
REF_TYPE_BRANCHES,
saveAndRunScanBtnId: 'scan-submit-button', saveAndRunScanBtnId: 'scan-submit-button',
saveScanBtnId: 'scan-save-button', saveScanBtnId: 'scan-save-button',
components: { components: {
RefSelector,
ProfileSelectorSummaryCell, ProfileSelectorSummaryCell,
ScannerProfileSelector, ScannerProfileSelector,
SiteProfileSelector, SiteProfileSelector,
...@@ -156,6 +160,7 @@ export default { ...@@ -156,6 +160,7 @@ export default {
...savedScansFields, ...savedScansFields,
scannerProfiles: [], scannerProfiles: [],
siteProfiles: [], siteProfiles: [],
selectedBranch: this.dastScan?.branch?.name ?? this.defaultBranch,
selectedScannerProfileId: this.dastScan?.scannerProfileId || null, selectedScannerProfileId: this.dastScan?.scannerProfileId || null,
selectedSiteProfileId: this.dastScan?.siteProfileId || null, selectedSiteProfileId: this.dastScan?.siteProfileId || null,
loading: false, loading: false,
...@@ -277,6 +282,9 @@ export default { ...@@ -277,6 +282,9 @@ export default {
[this.isEdit ? 'runAfterUpdate' : 'runAfterCreate']: runAfter, [this.isEdit ? 'runAfterUpdate' : 'runAfterCreate']: runAfter,
}; };
} }
if (this.glFeatures.dastBranchSelection) {
input.branchName = this.selectedBranch;
}
this.$apollo this.$apollo
.mutate({ .mutate({
...@@ -431,6 +439,21 @@ export default { ...@@ -431,6 +439,21 @@ export default {
/> />
</gl-form-group> </gl-form-group>
</template> </template>
<gl-form-group v-if="glFeatures.dastBranchSelection" :label="__('Branch')">
<ref-selector
v-model="selectedBranch"
data-testid="dast-scan-branch-input"
no-flip
:enabled-ref-types="[$options.REF_TYPE_BRANCHES]"
:project-id="projectPath"
:translations="{
dropdownHeader: __('Select a branch'),
searchPlaceholder: __('Search'),
}"
/>
</gl-form-group>
<scanner-profile-selector <scanner-profile-selector
v-model="selectedScannerProfileId" v-model="selectedScannerProfileId"
class="gl-mb-5" class="gl-mb-5"
......
...@@ -7,6 +7,7 @@ module Projects ...@@ -7,6 +7,7 @@ module Projects
before_action do before_action do
push_frontend_feature_flag(:security_dast_site_profiles_additional_fields, @project, default_enabled: :yaml) push_frontend_feature_flag(:security_dast_site_profiles_additional_fields, @project, default_enabled: :yaml)
push_frontend_feature_flag(:dast_saved_scans, @project, default_enabled: :yaml) push_frontend_feature_flag(:dast_saved_scans, @project, default_enabled: :yaml)
push_frontend_feature_flag(:dast_branch_selection, @project, default_enabled: :yaml)
end end
before_action :authorize_read_on_demand_scans!, only: :index before_action :authorize_read_on_demand_scans!, only: :index
......
...@@ -14,6 +14,7 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; ...@@ -14,6 +14,7 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createApolloProvider from 'helpers/mock_apollo_helper'; import createApolloProvider from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
import RefSelector from '~/ref/components/ref_selector.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import * as responses from '../mocks/apollo_mocks'; import * as responses from '../mocks/apollo_mocks';
import { scannerProfiles, siteProfiles } from '../mocks/mock_data'; import { scannerProfiles, siteProfiles } from '../mocks/mock_data';
...@@ -40,6 +41,7 @@ const [passiveScannerProfile, activeScannerProfile] = scannerProfiles; ...@@ -40,6 +41,7 @@ const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles; const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
const dastScan = { const dastScan = {
id: 1, id: 1,
branch: { name: 'dev' },
name: 'My daily scan', name: 'My daily scan',
description: 'Tests for SQL injections', description: 'Tests for SQL injections',
scannerProfileId: passiveScannerProfile.id, scannerProfileId: passiveScannerProfile.id,
...@@ -64,10 +66,14 @@ describe('OnDemandScansForm', () => { ...@@ -64,10 +66,14 @@ describe('OnDemandScansForm', () => {
const GlFormInputStub = stubComponent(GlFormInput, { const GlFormInputStub = stubComponent(GlFormInput, {
template: '<input />', template: '<input />',
}); });
const RefSelectorStub = stubComponent(RefSelector, {
template: '<input />',
});
const findForm = () => subject.find(GlForm); const findForm = () => subject.find(GlForm);
const findByTestId = (testId) => subject.find(`[data-testid="${testId}"]`); const findByTestId = (testId) => subject.find(`[data-testid="${testId}"]`);
const findNameInput = () => findByTestId('dast-scan-name-input'); const findNameInput = () => findByTestId('dast-scan-name-input');
const findBranchInput = () => findByTestId('dast-scan-branch-input');
const findDescriptionInput = () => findByTestId('dast-scan-description-input'); const findDescriptionInput = () => findByTestId('dast-scan-description-input');
const findScannerProfilesSelector = () => subject.find(ScannerProfileSelector); const findScannerProfilesSelector = () => subject.find(ScannerProfileSelector);
const findSiteProfilesSelector = () => subject.find(SiteProfileSelector); const findSiteProfilesSelector = () => subject.find(SiteProfileSelector);
...@@ -79,12 +85,13 @@ describe('OnDemandScansForm', () => { ...@@ -79,12 +85,13 @@ describe('OnDemandScansForm', () => {
const setValidFormData = () => { const setValidFormData = () => {
findNameInput().vm.$emit('input', 'My daily scan'); findNameInput().vm.$emit('input', 'My daily scan');
findBranchInput().vm.$emit('input', 'some-other-branch');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id); findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id); findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
return subject.vm.$nextTick(); return subject.vm.$nextTick();
}; };
const setupSuccess = ({ edit = false } = {}) => { const setupSuccess = ({ edit = false } = {}) => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockResolvedValue({ subject.vm.$apollo.mutate.mockResolvedValue({
data: { data: {
[edit ? 'dastProfileUpdate' : 'dastProfileCreate']: { [edit ? 'dastProfileUpdate' : 'dastProfileCreate']: {
dastProfile: { editPath }, dastProfile: { editPath },
...@@ -146,10 +153,12 @@ describe('OnDemandScansForm', () => { ...@@ -146,10 +153,12 @@ describe('OnDemandScansForm', () => {
newSiteProfilePath, newSiteProfilePath,
glFeatures: { glFeatures: {
dastSavedScans: true, dastSavedScans: true,
dastBranchSelection: true,
}, },
}, },
stubs: { stubs: {
GlFormInput: GlFormInputStub, GlFormInput: GlFormInputStub,
RefSelector: RefSelectorStub,
LocalStorageSync, LocalStorageSync,
}, },
}, },
...@@ -181,34 +190,43 @@ describe('OnDemandScansForm', () => { ...@@ -181,34 +190,43 @@ describe('OnDemandScansForm', () => {
localStorage.clear(); localStorage.clear();
}); });
it('renders properly', () => { describe('when creating a new scan', () => {
mountSubject(); it('renders properly', () => {
expect(subject.text()).toContain('New on-demand DAST scan'); mountSubject();
});
it.each` expect(subject.text()).toContain('New on-demand DAST scan');
scannerProfilesLoading | siteProfilesLoading | isLoading });
${true} | ${true} | ${true}
${false} | ${true} | ${true} it('populates the branch input with the default branch', () => {
${true} | ${false} | ${true} mountSubject();
${false} | ${false} | ${false}
`( expect(findBranchInput().props('value')).toBe(defaultBranch);
'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading', });
({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => {
mountShallowSubject({ it.each`
mocks: { scannerProfilesLoading | siteProfilesLoading | isLoading
$apollo: { ${true} | ${true} | ${true}
queries: { ${false} | ${true} | ${true}
scannerProfiles: { loading: scannerProfilesLoading }, ${true} | ${false} | ${true}
siteProfiles: { loading: siteProfilesLoading }, ${false} | ${false} | ${false}
`(
'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading',
({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => {
mountShallowSubject({
mocks: {
$apollo: {
queries: {
scannerProfiles: { loading: scannerProfilesLoading },
siteProfiles: { loading: siteProfilesLoading },
},
}, },
}, },
}, });
});
expect(subject.find(GlSkeletonLoader).exists()).toBe(isLoading); expect(subject.find(GlSkeletonLoader).exists()).toBe(isLoading);
}, },
); );
});
describe('when editing an existing scan', () => { describe('when editing an existing scan', () => {
beforeEach(() => { beforeEach(() => {
...@@ -225,6 +243,7 @@ describe('OnDemandScansForm', () => { ...@@ -225,6 +243,7 @@ describe('OnDemandScansForm', () => {
it('populates the fields with passed values', () => { it('populates the fields with passed values', () => {
expect(findNameInput().attributes('value')).toBe(dastScan.name); expect(findNameInput().attributes('value')).toBe(dastScan.name);
expect(findBranchInput().props('value')).toBe(dastScan.branch.name);
expect(findDescriptionInput().attributes('value')).toBe(dastScan.description); expect(findDescriptionInput().attributes('value')).toBe(dastScan.description);
expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId); expect(findScannerProfilesSelector().attributes('value')).toBe(dastScan.scannerProfileId);
expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId); expect(findSiteProfilesSelector().attributes('value')).toBe(dastScan.siteProfileId);
...@@ -324,6 +343,7 @@ describe('OnDemandScansForm', () => { ...@@ -324,6 +343,7 @@ describe('OnDemandScansForm', () => {
variables: { variables: {
input: { input: {
name: 'My daily scan', name: 'My daily scan',
branchName: 'some-other-branch',
dastScannerProfileId: passiveScannerProfile.id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id, dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath, fullPath: projectPath,
...@@ -362,6 +382,7 @@ describe('OnDemandScansForm', () => { ...@@ -362,6 +382,7 @@ describe('OnDemandScansForm', () => {
input: { input: {
id: 1, id: 1,
name: 'My daily scan', name: 'My daily scan',
branchName: 'some-other-branch',
description: 'Tests for SQL injections', description: 'Tests for SQL injections',
dastScannerProfileId: passiveScannerProfile.id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id, dastSiteProfileId: nonValidatedSiteProfile.id,
...@@ -387,7 +408,7 @@ describe('OnDemandScansForm', () => { ...@@ -387,7 +408,7 @@ describe('OnDemandScansForm', () => {
describe('on top-level error', () => { describe('on top-level error', () => {
beforeEach(async () => { beforeEach(async () => {
mountShallowSubject(); mountShallowSubject();
jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue(); subject.vm.$apollo.mutate.mockRejectedValue();
await setValidFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -408,9 +429,9 @@ describe('OnDemandScansForm', () => { ...@@ -408,9 +429,9 @@ describe('OnDemandScansForm', () => {
beforeEach(async () => { beforeEach(async () => {
mountShallowSubject(); mountShallowSubject();
jest subject.vm.$apollo.mutate.mockResolvedValue({
.spyOn(subject.vm.$apollo, 'mutate') data: { dastProfileCreate: { pipelineUrl: null, errors } },
.mockResolvedValue({ data: { dastProfileCreate: { pipelineUrl: null, errors } } }); });
await setValidFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -452,9 +473,9 @@ describe('OnDemandScansForm', () => { ...@@ -452,9 +473,9 @@ describe('OnDemandScansForm', () => {
}, },
}, },
}); });
jest subject.vm.$apollo.mutate.mockResolvedValue({
.spyOn(subject.vm.$apollo, 'mutate') data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } },
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } }); });
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id); subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id); subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
submitForm(); submitForm();
...@@ -465,6 +486,7 @@ describe('OnDemandScansForm', () => { ...@@ -465,6 +486,7 @@ describe('OnDemandScansForm', () => {
mutation: dastOnDemandScanCreateMutation, mutation: dastOnDemandScanCreateMutation,
variables: { variables: {
input: { input: {
branchName: defaultBranch,
dastScannerProfileId: passiveScannerProfile.id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id, dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath, fullPath: projectPath,
...@@ -609,4 +631,99 @@ describe('OnDemandScansForm', () => { ...@@ -609,4 +631,99 @@ describe('OnDemandScansForm', () => {
expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id); expect(findSiteProfilesSelector().attributes('value')).toBe(siteProfile.id);
}); });
}); });
describe('dastBranchSelection feature flag disabled', () => {
describe.each`
action | actionFunction | runAfter
${'submit'} | ${submitForm} | ${true}
${'save'} | ${saveScan} | ${false}
`('on $action', ({ actionFunction, runAfter }) => {
describe('when creating a new scan', () => {
beforeEach(async () => {
mountShallowSubject({
provide: {
glFeatures: {
dastBranchSelection: false,
},
},
});
subject.vm.$apollo.mutate.mockResolvedValue({
data: {
dastProfileCreate: {
dastProfile: { editPath },
pipelineUrl,
errors: [],
},
},
});
findNameInput().vm.$emit('input', 'My daily scan');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
await subject.vm.$nextTick();
actionFunction();
});
it(`triggers dastProfileCreateMutation mutation without the branch name and runAfterCreate set to ${runAfter}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastProfileCreateMutation,
variables: {
input: {
name: 'My daily scan',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterCreate: runAfter,
},
},
});
});
});
describe('when editing an existing scan', () => {
beforeEach(async () => {
mountShallowSubject({
propsData: {
dastScan,
},
provide: {
glFeatures: {
dastBranchSelection: false,
},
},
});
subject.vm.$apollo.mutate.mockResolvedValue({
data: {
dastProfileUpdate: {
dastProfile: { editPath },
pipelineUrl,
errors: [],
},
},
});
findNameInput().vm.$emit('input', 'My daily scan');
findScannerProfilesSelector().vm.$emit('input', passiveScannerProfile.id);
findSiteProfilesSelector().vm.$emit('input', nonValidatedSiteProfile.id);
await subject.vm.$nextTick();
actionFunction();
});
it(`triggers dastProfileUpdateMutation mutation without the branch name and runAfterUpdate set to ${runAfter}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastProfileUpdateMutation,
variables: {
input: {
id: 1,
name: 'My daily scan',
description: 'Tests for SQL injections',
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
runAfterUpdate: runAfter,
},
},
});
});
});
});
});
}); });
...@@ -26965,6 +26965,9 @@ msgstr "" ...@@ -26965,6 +26965,9 @@ msgstr ""
msgid "Select Stack" msgid "Select Stack"
msgstr "" msgstr ""
msgid "Select a branch"
msgstr ""
msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes." msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes."
msgstr "" msgstr ""
......
...@@ -91,7 +91,7 @@ describe('Ref selector component', () => { ...@@ -91,7 +91,7 @@ describe('Ref selector component', () => {
// //
// Finders // Finders
// //
const findButtonContent = () => wrapper.find('[data-testid="button-content"]'); const findButtonContent = () => wrapper.find('button');
const findNoResults = () => wrapper.find('[data-testid="no-results"]'); const findNoResults = () => wrapper.find('[data-testid="no-results"]');
......
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