Commit 93aff4b0 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Kushal Pandya

Implement API Fuzzing saving

- Clicking on `Generate code snippet` triggers a mutation to save the
configuration
- It also opens a modal containing a code snippet for the chosen
configuration
- User can simply copy the snippet to the clipboard and be redirected to
the code editor to apply the config
parent db2a50fa
......@@ -3,3 +3,4 @@ filenames:
- ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/api_fuzzing_ci_configuration.query.graphql
- ee/app/assets/javascripts/on_demand_scans/graphql/dast_profile_update.mutation.graphql
- ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql
......@@ -11,6 +11,7 @@ export const initApiFuzzingConfiguration = () => {
}
const {
securityConfigurationPath,
fullPath,
apiFuzzingDocumentationPath,
apiFuzzingAuthenticationDocumentationPath,
......@@ -23,6 +24,7 @@ export const initApiFuzzingConfiguration = () => {
el,
apolloProvider,
provide: {
securityConfigurationPath,
fullPath,
apiFuzzingDocumentationPath,
apiFuzzingAuthenticationDocumentationPath,
......
......@@ -10,13 +10,18 @@ import {
GlLink,
GlSprintf,
} from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import { __, s__ } from '~/locale';
import { SCAN_MODES } from '../constants';
import { isEmptyValue } from '~/lib/utils/forms';
import { SCAN_MODES, CONFIGURATION_SNIPPET_MODAL_ID } from '../constants';
import createApiFuzzingConfigurationMutation from '../graphql/create_api_fuzzing_configuration.mutation.graphql';
import DropdownInput from '../../components/dropdown_input.vue';
import DynamicFields from '../../components/dynamic_fields.vue';
import FormInput from '../../components/form_input.vue';
import ConfigurationSnippetModal from './configuration_snippet_modal.vue';
export default {
CONFIGURATION_SNIPPET_MODAL_ID,
components: {
GlAccordion,
GlAccordionItem,
......@@ -27,24 +32,19 @@ export default {
GlFormCheckbox,
GlLink,
GlSprintf,
ConfigurationSnippetModal,
DropdownInput,
DynamicFields,
FormInput,
},
inject: {
apiFuzzingAuthenticationDocumentationPath: {
from: 'apiFuzzingAuthenticationDocumentationPath',
},
ciVariablesDocumentationPath: {
from: 'ciVariablesDocumentationPath',
},
projectCiSettingsPath: {
from: 'projectCiSettingsPath',
},
canSetProjectCiVariables: {
from: 'canSetProjectCiVariables',
},
},
inject: [
'securityConfigurationPath',
'fullPath',
'apiFuzzingAuthenticationDocumentationPath',
'ciVariablesDocumentationPath',
'projectCiSettingsPath',
'canSetProjectCiVariables',
],
props: {
apiFuzzingCiConfiguration: {
type: Object,
......@@ -53,6 +53,8 @@ export default {
},
data() {
return {
isLoading: false,
showError: false,
targetUrl: {
field: 'targetUrl',
label: s__('APIFuzzing|Target URL'),
......@@ -111,6 +113,8 @@ export default {
}),
),
},
ciYamlEditUrl: '',
configurationYaml: '',
};
},
computed: {
......@@ -142,9 +146,60 @@ export default {
({ name }) => name === this.scanProfile.value,
)?.yaml;
},
someFieldEmpty() {
const fields = [this.targetUrl, this.scanMode, this.apiSpecificationFile, this.scanProfile];
if (this.authenticationEnabled) {
fields.push(...this.authenticationSettings);
}
return fields.some(({ value }) => isEmptyValue(value));
},
},
methods: {
onSubmit() {},
async onSubmit() {
this.isLoading = true;
this.showError = false;
try {
const input = {
projectPath: this.fullPath,
target: this.targetUrl.value,
scanMode: this.scanMode.value,
apiSpecificationFile: this.apiSpecificationFile.value,
scanProfile: this.scanProfile.value,
};
if (this.authenticationEnabled) {
const [authUsername, authPassword] = this.authenticationSettings;
input.authUsername = authUsername.value;
input.authPassword = authPassword.value;
}
const {
data: {
createApiFuzzingCiConfiguration: {
gitlabCiYamlEditUrl,
configurationYaml,
errors = [],
},
},
} = await this.$apollo.mutate({
mutation: createApiFuzzingConfigurationMutation,
variables: { input },
});
if (errors.length) {
this.showError = true;
} else {
this.ciYamlEditUrl = gitlabCiYamlEditUrl;
this.configurationYaml = configurationYaml;
this.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show();
}
} catch (e) {
this.showError = true;
Sentry.captureException(e);
} finally {
this.isLoading = false;
}
},
dismissError() {
this.showError = false;
},
},
SCAN_MODES,
};
......@@ -152,6 +207,10 @@ export default {
<template>
<form @submit.prevent="onSubmit">
<gl-alert v-if="showError" variant="danger" class="gl-mb-5" @dismiss="dismissError">
{{ s__('APIFuzzing|The configuration could not be saved, please try again later.') }}
</gl-alert>
<form-input v-model="targetUrl.value" v-bind="targetUrl" class="gl-mb-7" />
<dropdown-input v-model="scanMode.value" v-bind="scanMode" />
......@@ -223,9 +282,26 @@ export default {
<hr />
<gl-button type="submit" variant="confirm">{{
s__('APIFuzzing|Generate code snippet')
}}</gl-button>
<gl-button>{{ __('Cancel') }}</gl-button>
<gl-button
:disabled="someFieldEmpty"
:loading="isLoading"
type="submit"
variant="confirm"
class="js-no-auto-disable"
data-testid="api-fuzzing-configuration-submit-button"
>{{ s__('APIFuzzing|Generate code snippet') }}</gl-button
>
<gl-button
:disabled="isLoading"
:href="securityConfigurationPath"
data-testid="api-fuzzing-configuration-cancel-button"
>{{ __('Cancel') }}</gl-button
>
<configuration-snippet-modal
:ref="$options.CONFIGURATION_SNIPPET_MODAL_ID"
:ci-yaml-edit-url="ciYamlEditUrl"
:yaml="configurationYaml"
/>
</form>
</template>
<script>
import { GlModal } from '@gitlab/ui';
import Clipboard from 'clipboard';
import { redirectTo } from '~/lib/utils/url_utility';
import { CONFIGURATION_SNIPPET_MODAL_ID } from '../constants';
export default {
CONFIGURATION_SNIPPET_MODAL_ID,
components: {
GlModal,
},
props: {
ciYamlEditUrl: {
type: String,
required: true,
},
yaml: {
type: String,
required: true,
},
},
methods: {
show() {
this.$refs.modal.show();
},
onHide() {
this.clipboard?.destroy();
},
copySnippet(andRedirect = true) {
const id = andRedirect ? 'copy-yaml-snippet-and-edit-button' : 'copy-yaml-snippet-button';
const clipboard = new Clipboard(`#${id}`, {
text: () => this.yaml,
});
clipboard.on('success', () => {
if (andRedirect) {
redirectTo(this.ciYamlEditUrl);
}
});
},
},
};
</script>
<template>
<gl-modal
ref="modal"
:action-primary="{
text: s__('APIFuzzing|Copy code and open .gitlab-ci.yml file'),
attributes: [{ variant: 'confirm' }, { id: 'copy-yaml-snippet-and-edit-button' }],
}"
:action-secondary="{
text: s__('APIFuzzing|Copy code only'),
attributes: [{ variant: 'default' }, { id: 'copy-yaml-snippet-button' }],
}"
:action-cancel="{
text: __('Cancel'),
}"
:modal-id="$options.CONFIGURATION_SNIPPET_MODAL_ID"
:title="s__('APIFuzzing|Code snippet for the API Fuzzing configuration')"
@hide="onHide"
@primary="copySnippet"
@secondary="copySnippet(false)"
>
<pre><code data-testid="api-fuzzing-modal-yaml-snippet" v-text="yaml"></code></pre>
</gl-modal>
</template>
......@@ -18,3 +18,5 @@ export const SCAN_MODES = {
),
},
};
export const CONFIGURATION_SNIPPET_MODAL_ID = 'CONFIGURATION_SNIPPET_MODAL_ID';
mutation($input: CreateApiFuzzingCiConfigurationInput!) {
createApiFuzzingCiConfiguration(input: $input) {
configurationYaml
gitlabCiYamlEditUrl
errors
}
}
......@@ -3,6 +3,7 @@
module Projects::Security::ApiFuzzingConfigurationHelper
def api_fuzzing_configuration_data(project)
{
security_configuration_path: project_security_configuration_path(project),
full_path: project.full_path,
api_fuzzing_documentation_path: help_page_path('user/application_security/api_fuzzing/index'),
api_fuzzing_authentication_documentation_path: help_page_path('user/application_security/api_fuzzing/index', anchor: 'authentication'),
......
import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import { stripTypenames } from 'helpers/graphql_helpers';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { SCAN_MODES } from 'ee/security_configuration/api_fuzzing/constants';
import waitForPromises from 'helpers/wait_for_promises';
import {
SCAN_MODES,
CONFIGURATION_SNIPPET_MODAL_ID,
} from 'ee/security_configuration/api_fuzzing/constants';
import ConfigurationForm from 'ee/security_configuration/api_fuzzing/components/configuration_form.vue';
import ConfigurationSnippetModal from 'ee/security_configuration/api_fuzzing/components/configuration_snippet_modal.vue';
import DynamicFields from 'ee/security_configuration/components/dynamic_fields.vue';
import FormInput from 'ee/security_configuration/components/form_input.vue';
import DropdownInput from 'ee/security_configuration/components/dropdown_input.vue';
const makeScanProfile = (name) => ({
name,
description: `${name} description`,
yaml: `
---
:Name: ${name}
`.trim(),
});
import {
apiFuzzingConfigurationQueryResponse,
createApiFuzzingConfigurationMutationResponse,
} from '../mock_data';
describe('EE - ApiFuzzingConfigurationForm', () => {
let wrapper;
const apiFuzzingCiConfiguration = {
scanModes: Object.keys(SCAN_MODES),
scanProfiles: [makeScanProfile('Quick-10'), makeScanProfile('Medium-20')],
};
const apiFuzzingCiConfiguration = stripTypenames(
apiFuzzingConfigurationQueryResponse.data.project.apiFuzzingCiConfiguration,
);
const findAlert = () => wrapper.find(GlAlert);
const findEnableAuthenticationCheckbox = () =>
wrapper.findByTestId('api-fuzzing-enable-authentication-checkbox');
const findTargetUrlInput = () => wrapper.findAll(FormInput).at(0);
const findScanModeInput = () => wrapper.findAll(DropdownInput).at(0);
const findSpecificationFileInput = () => wrapper.findAll(FormInput).at(1);
const findAuthenticationNotice = () => wrapper.findByTestId('api-fuzzing-authentication-notice');
const findAuthenticationFields = () => wrapper.find(DynamicFields);
const findScanProfileDropdownInput = () => wrapper.findAll(DropdownInput).at(1);
const findScanProfileYamlViewer = () =>
wrapper.findByTestId('api-fuzzing-scan-profile-yaml-viewer');
const findSubmitButton = () => wrapper.findByTestId('api-fuzzing-configuration-submit-button');
const findCancelButton = () => wrapper.findByTestId('api-fuzzing-configuration-cancel-button');
const findConfigurationSnippetModal = () => wrapper.find(ConfigurationSnippetModal);
const setFormData = async () => {
findTargetUrlInput().vm.$emit('input', 'https://gitlab.com');
await findScanModeInput().vm.$emit('input', 'HAR');
findSpecificationFileInput().vm.$emit('input', '/specification/file/path');
return findScanProfileDropdownInput().vm.$emit(
'input',
apiFuzzingCiConfiguration.scanProfiles[0].name,
);
};
const createWrapper = (options = {}) => {
wrapper = extendedWrapper(
......@@ -39,6 +57,8 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
merge(
{
provide: {
fullPath: 'namespace/project',
securityConfigurationPath: '/security/configuration',
apiFuzzingAuthenticationDocumentationPath:
'api_fuzzing_authentication/documentation/path',
ciVariablesDocumentationPath: '/ci_cd_variables/documentation/path',
......@@ -48,6 +68,11 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
propsData: {
apiFuzzingCiConfiguration,
},
mocks: {
$apollo: {
mutate: jest.fn(),
},
},
},
options,
),
......@@ -156,12 +181,112 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
});
it('when a scan profile is selected, its YAML is visible', async () => {
const selectedScanProfile = apiFuzzingCiConfiguration.scanProfiles[0];
wrapper.findAll(DropdownInput).at(1).vm.$emit('input', selectedScanProfile.name);
const [selectedScanProfile] = apiFuzzingCiConfiguration.scanProfiles;
findScanProfileDropdownInput().vm.$emit('input', selectedScanProfile.name);
await wrapper.vm.$nextTick();
expect(findScanProfileYamlViewer().exists()).toBe(true);
expect(findScanProfileYamlViewer().text()).toBe(selectedScanProfile.yaml);
expect(findScanProfileYamlViewer().text()).toBe(selectedScanProfile.yaml.trim());
});
});
describe('form submission', () => {
it('cancel button points to Security Configuration page', () => {
createWrapper();
expect(findCancelButton().attributes('href')).toBe('/security/configuration');
});
it('submit button is disabled until all fields are filled', async () => {
createWrapper();
expect(findSubmitButton().props('disabled')).toBe(true);
await setFormData();
expect(findSubmitButton().props('disabled')).toBe(false);
await findEnableAuthenticationCheckbox().trigger('click');
expect(findSubmitButton().props('disabled')).toBe(true);
await findAuthenticationFields().vm.$emit('input', [
{
...wrapper.vm.authenticationSettings[0],
value: '$UsernameVariable',
},
{
...wrapper.vm.authenticationSettings[1],
value: '$PasswordVariable',
},
]);
expect(findSubmitButton().props('disabled')).toBe(false);
});
it('triggers the createApiFuzzingConfiguration mutation on submit and opens the modal with the correct props', async () => {
createWrapper();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue(createApiFuzzingConfigurationMutationResponse);
jest.spyOn(wrapper.vm.$refs[CONFIGURATION_SNIPPET_MODAL_ID], 'show');
await setFormData();
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().exists()).toBe(false);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
expect(wrapper.vm.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show).toHaveBeenCalled();
expect(findConfigurationSnippetModal().props()).toEqual({
ciYamlEditUrl:
createApiFuzzingConfigurationMutationResponse.data.createApiFuzzingCiConfiguration
.gitlabCiYamlEditUrl,
yaml:
createApiFuzzingConfigurationMutationResponse.data.createApiFuzzingCiConfiguration
.configurationYaml,
});
});
it('shows an error on top-level error', async () => {
createWrapper({
mocks: {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
},
});
await setFormData();
expect(findAlert().exists()).toBe(false);
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
it('shows an error on error-as-data', async () => {
createWrapper({
mocks: {
$apollo: {
mutate: jest.fn().mockResolvedValue({
data: {
createApiFuzzingCiConfiguration: {
errors: ['error#1'],
},
},
}),
},
},
});
await setFormData();
expect(findAlert().exists()).toBe(false);
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import Clipboard from 'clipboard';
import { GlModal } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ConfigurationSnippetModal from 'ee/security_configuration/api_fuzzing/components/configuration_snippet_modal.vue';
import { createApiFuzzingConfigurationMutationResponse } from '../mock_data';
jest.mock('clipboard', () =>
jest.fn().mockImplementation(() => ({
on: jest.fn().mockImplementation((_event, cb) => cb()),
})),
);
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
const {
gitlabCiYamlEditUrl,
configurationYaml,
} = createApiFuzzingConfigurationMutationResponse.data.createApiFuzzingCiConfiguration;
describe('EE - ApiFuzzingConfigurationSnippetModal', () => {
let wrapper;
const findModal = () => wrapper.find(GlModal);
const findYamlSnippet = () => wrapper.findByTestId('api-fuzzing-modal-yaml-snippet');
const createWrapper = (options) => {
wrapper = extendedWrapper(
shallowMount(
ConfigurationSnippetModal,
merge(
{
propsData: {
ciYamlEditUrl: gitlabCiYamlEditUrl,
yaml: configurationYaml,
},
attrs: {
static: true,
visible: true,
},
},
options,
),
),
);
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the YAML snippet', () => {
expect(findYamlSnippet().text()).toBe(configurationYaml);
});
it('on primary event, text is copied to the clipbard and user is redirected to CI editor', async () => {
findModal().vm.$emit('primary');
expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-and-edit-button', {
text: expect.any(Function),
});
expect(redirectTo).toHaveBeenCalledWith(gitlabCiYamlEditUrl);
});
it('on secondary event, text is copied to the clipbard', async () => {
findModal().vm.$emit('secondary');
expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-button', {
text: expect.any(Function),
});
});
});
......@@ -39,3 +39,14 @@ export const apiFuzzingConfigurationQueryResponse = {
},
},
};
export const createApiFuzzingConfigurationMutationResponse = {
data: {
createApiFuzzingCiConfiguration: {
configurationYaml: 'yaml snippet',
gitlabCiYamlEditUrl: '/ci/editor',
errors: [],
__typename: 'ApiFuzzingCiConfiguration',
},
},
};
......@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let(:security_configuration_path) { project_security_configuration_path(project) }
let(:full_path) { project.full_path }
let(:api_fuzzing_documentation_path) { help_page_path('user/application_security/api_fuzzing/index') }
let(:api_fuzzing_authentication_documentation_path) { help_page_path('user/application_security/api_fuzzing/index', anchor: 'authentication') }
......@@ -25,6 +26,7 @@ RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
it {
is_expected.to eq(
security_configuration_path: security_configuration_path,
full_path: full_path,
api_fuzzing_documentation_path: api_fuzzing_documentation_path,
api_fuzzing_authentication_documentation_path: api_fuzzing_authentication_documentation_path,
......@@ -42,6 +44,7 @@ RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
it {
is_expected.to eq(
security_configuration_path: security_configuration_path,
full_path: full_path,
api_fuzzing_documentation_path: api_fuzzing_documentation_path,
api_fuzzing_authentication_documentation_path: api_fuzzing_authentication_documentation_path,
......
......@@ -1408,6 +1408,15 @@ msgstr ""
msgid "APIFuzzing|Choose a profile"
msgstr ""
msgid "APIFuzzing|Code snippet for the API Fuzzing configuration"
msgstr ""
msgid "APIFuzzing|Copy code and open .gitlab-ci.yml file"
msgstr ""
msgid "APIFuzzing|Copy code only"
msgstr ""
msgid "APIFuzzing|Customize common API fuzzing settings to suit your requirements. For details of more advanced configuration options, see the %{docsLinkStart}GitLab API Fuzzing documentation%{docsLinkEnd}."
msgstr ""
......@@ -1456,6 +1465,9 @@ msgstr ""
msgid "APIFuzzing|Target URL"
msgstr ""
msgid "APIFuzzing|The configuration could not be saved, please try again later."
msgstr ""
msgid "APIFuzzing|There are two ways to perform scans."
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