Commit 1f68649f authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by David O'Regan

Add DAST saved scans fields

Extends the on-demand scans form with new feature-flagged fields for the
upcoming saved scans feature.
parent f006fea3
...@@ -4,6 +4,9 @@ import { ...@@ -4,6 +4,9 @@ import {
GlButton, GlButton,
GlCard, GlCard,
GlForm, GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink, GlLink,
GlSkeletonLoader, GlSkeletonLoader,
GlSprintf, GlSprintf,
...@@ -14,6 +17,8 @@ import { ...@@ -14,6 +17,8 @@ import {
SCAN_TYPE, SCAN_TYPE,
} from 'ee/security_configuration/dast_scanner_profiles/constants'; } from 'ee/security_configuration/dast_scanner_profiles/constants';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants'; import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { initFormField } from 'ee/security_configuration/utils';
import validation from '~/vue_shared/directives/validation';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
...@@ -26,6 +31,7 @@ import { ...@@ -26,6 +31,7 @@ import {
SITE_PROFILES_QUERY, SITE_PROFILES_QUERY,
SITE_PROFILES_EXTENDED_QUERY, SITE_PROFILES_EXTENDED_QUERY,
} from '../settings'; } from '../settings';
import dastScanCreateMutation from '../graphql/dast_scan_create.mutation.graphql';
import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql'; import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql';
import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue'; import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue'; import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
...@@ -53,6 +59,8 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) => ...@@ -53,6 +59,8 @@ const createProfilesApolloOptions = (name, field, { fetchQuery, fetchError }) =>
export default { export default {
SCAN_TYPE_LABEL, SCAN_TYPE_LABEL,
saveAndRunScanBtnId: 'scan-submit-button',
saveScanBtnId: 'scan-save-button',
components: { components: {
ProfileSelectorSummaryCell, ProfileSelectorSummaryCell,
ScannerProfileSelector, ScannerProfileSelector,
...@@ -61,12 +69,16 @@ export default { ...@@ -61,12 +69,16 @@ export default {
GlButton, GlButton,
GlCard, GlCard,
GlForm, GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink, GlLink,
GlSkeletonLoader, GlSkeletonLoader,
GlSprintf, GlSprintf,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
validation: validation(),
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
apollo: { apollo: {
...@@ -118,7 +130,20 @@ export default { ...@@ -118,7 +130,20 @@ export default {
}, },
}, },
data() { data() {
const savedScansFields = this.glFeatures.dastSavedScans
? {
form: {
showValidation: false,
state: false,
fields: {
name: initFormField({ value: '' }),
description: initFormField({ value: '', required: false, skipValidation: true }),
},
},
}
: {};
return { return {
...savedScansFields,
scannerProfiles: [], scannerProfiles: [],
siteProfiles: [], siteProfiles: [],
selectedScannerProfileId: null, selectedScannerProfileId: null,
...@@ -167,40 +192,74 @@ export default { ...@@ -167,40 +192,74 @@ export default {
!this.isValidatedSiteProfile !this.isValidatedSiteProfile
); );
}, },
isSubmitButtonDisabled() { isFormInvalid() {
return this.someFieldEmpty || this.hasProfilesConflict; return this.someFieldEmpty || this.hasProfilesConflict;
}, },
isSubmitButtonDisabled() {
const {
isFormInvalid,
loading,
$options: { saveAndRunScanBtnId },
} = this;
return isFormInvalid || (loading && loading !== saveAndRunScanBtnId);
},
isSaveButtonDisabled() {
const {
isFormInvalid,
loading,
$options: { saveScanBtnId },
} = this;
return isFormInvalid || (loading && loading !== saveScanBtnId);
},
}, },
methods: { methods: {
onSubmit() { onSubmit(runAfterCreate = true, button = this.$options.saveAndRunScanBtnId) {
this.loading = true; if (this.glFeatures.dastSavedScans) {
this.form.showValidation = true;
if (!this.form.state) {
return;
}
}
this.loading = button;
this.hideErrors(); this.hideErrors();
let mutation = dastOnDemandScanCreateMutation;
let reponseType = 'dastOnDemandScanCreate';
let input = {
fullPath: this.projectPath,
dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
};
if (this.glFeatures.dastSavedScans) {
mutation = dastScanCreateMutation;
reponseType = 'dastScanCreate';
input = {
...input,
name: this.form.fields.name.value,
description: this.form.fields.description.value,
runAfterCreate,
};
}
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: dastOnDemandScanCreateMutation, mutation,
variables: { variables: {
input: { input,
fullPath: this.projectPath,
dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
},
}, },
}) })
.then( .then(({ data }) => {
({ const response = data[reponseType];
data: { const { errors } = response;
dastOnDemandScanCreate: { pipelineUrl, errors },
},
}) => {
if (errors?.length) { if (errors?.length) {
this.showErrors(ERROR_RUN_SCAN, errors); this.showErrors(ERROR_RUN_SCAN, errors);
this.loading = false; this.loading = false;
} else if (this.glFeatures.dastSavedScans && !runAfterCreate) {
redirectTo(response.dastScan.editPath);
} else { } else {
redirectTo(pipelineUrl); redirectTo(response.pipelineUrl);
} }
}, })
)
.catch((e) => { .catch((e) => {
Sentry.captureException(e); Sentry.captureException(e);
this.showErrors(ERROR_RUN_SCAN); this.showErrors(ERROR_RUN_SCAN);
...@@ -222,7 +281,7 @@ export default { ...@@ -222,7 +281,7 @@ export default {
</script> </script>
<template> <template>
<gl-form @submit.prevent="onSubmit"> <gl-form novalidate @submit.prevent="onSubmit()">
<header class="gl-mb-6"> <header class="gl-mb-6">
<h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2> <h2>{{ s__('OnDemandScans|New on-demand DAST scan') }}</h2>
<p> <p>
...@@ -257,6 +316,12 @@ export default { ...@@ -257,6 +316,12 @@ export default {
</gl-alert> </gl-alert>
<template v-if="isLoadingProfiles"> <template v-if="isLoadingProfiles">
<gl-skeleton-loader v-if="glFeatures.dastSavedScans" :width="1248" :height="180">
<rect x="0" y="0" width="100" height="15" rx="4" />
<rect x="0" y="24" width="460" height="32" rx="4" />
<rect x="0" y="71" width="100" height="15" rx="4" />
<rect x="0" y="95" width="460" height="72" rx="4" />
</gl-skeleton-loader>
<gl-card v-for="i in 2" :key="i" class="gl-mb-5"> <gl-card v-for="i in 2" :key="i" class="gl-mb-5">
<template #header> <template #header>
<gl-skeleton-loader :width="1248" :height="15"> <gl-skeleton-loader :width="1248" :height="15">
...@@ -272,6 +337,33 @@ export default { ...@@ -272,6 +337,33 @@ export default {
</gl-card> </gl-card>
</template> </template>
<template v-else-if="!failedToLoadProfiles"> <template v-else-if="!failedToLoadProfiles">
<template v-if="glFeatures.dastSavedScans">
<gl-form-group
:label="s__('OnDemandScans|Scan name')"
:invalid-feedback="form.fields.name.feedback"
>
<gl-form-input
v-model="form.fields.name.value"
v-validation:[form.showValidation]
class="mw-460"
data-testid="dast-scan-name-input"
type="text"
:placeholder="s__('OnDemandScans|My daily scan')"
:state="form.fields.name.state"
name="name"
required
/>
</gl-form-group>
<gl-form-group :label="s__('OnDemandScans|Description')">
<gl-form-textarea
v-model="form.fields.description.value"
class="mw-460"
data-testid="dast-scan-description-input"
:placeholder="s__(`OnDemandScans|For example: Tests the login page for SQL injections`)"
:state="form.fields.description.state"
/>
</gl-form-group>
</template>
<scanner-profile-selector <scanner-profile-selector
v-model="selectedScannerProfileId" v-model="selectedScannerProfileId"
class="gl-mb-5" class="gl-mb-5"
...@@ -390,9 +482,24 @@ export default { ...@@ -390,9 +482,24 @@ export default {
class="js-no-auto-disable" class="js-no-auto-disable"
data-testid="on-demand-scan-submit-button" data-testid="on-demand-scan-submit-button"
:disabled="isSubmitButtonDisabled" :disabled="isSubmitButtonDisabled"
:loading="loading" :loading="loading === $options.saveAndRunScanBtnId"
>
{{
glFeatures.dastSavedScans
? s__('OnDemandScans|Save and run scan')
: s__('OnDemandScans|Run scan')
}}
</gl-button>
<gl-button
v-if="glFeatures.dastSavedScans"
variant="success"
category="secondary"
data-testid="on-demand-scan-save-button"
:disabled="isSaveButtonDisabled"
:loading="loading === $options.saveScanBtnId"
@click="onSubmit(false, $options.saveScanBtnId)"
> >
{{ s__('OnDemandScans|Run scan') }} {{ s__('OnDemandScans|Save scan') }}
</gl-button> </gl-button>
</div> </div>
</template> </template>
......
mutation dastScanCreate(
$fullPath: ID!
$name: String!
$description: String
$dastSiteProfileId: DastSiteProfileID!
$dastScannerProfileID: DastScannerProfileID!
$runAfterCreate: Boolean
) {
dastScanCreate(
input: {
fullPath: $fullPath
name: $name
description: $description
dastSiteProfileId: $dastSiteProfileId
dastScannerProfileID: $dastScannerProfileID
runAfterCreate: $runAfterCreate
}
) {
dastScan {
editPath
}
pipelineUrl
errors
}
}
import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; import { GlForm, GlFormInput, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -6,9 +6,11 @@ import createApolloProvider from 'helpers/mock_apollo_helper'; ...@@ -6,9 +6,11 @@ import createApolloProvider from 'helpers/mock_apollo_helper';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue'; import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_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 SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import dastOnDemandScanCreate from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql'; import dastScanCreateMutation from 'ee/on_demand_scans/graphql/dast_scan_create.mutation.graphql';
import dastOnDemandScanCreateMutation from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql'; 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 dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import { stubComponent } from 'helpers/stub_component';
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';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
...@@ -28,6 +30,7 @@ const defaultProps = { ...@@ -28,6 +30,7 @@ const defaultProps = {
}; };
const pipelineUrl = `/${projectPath}/pipelines/123`; const pipelineUrl = `/${projectPath}/pipelines/123`;
const editPath = `/${projectPath}/on_demand_scans/1/edit`;
const [passiveScannerProfile, activeScannerProfile] = scannerProfiles; const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles; const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
...@@ -41,18 +44,33 @@ describe('OnDemandScansForm', () => { ...@@ -41,18 +44,33 @@ describe('OnDemandScansForm', () => {
let subject; let subject;
let requestHandlers; let requestHandlers;
const GlFormInputStub = stubComponent(GlFormInput, {
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 findAlert = () => findByTestId('on-demand-scan-error'); const findAlert = () => findByTestId('on-demand-scan-error');
const findProfilesConflictAlert = () => findByTestId('on-demand-scans-profiles-conflict-alert'); const findProfilesConflictAlert = () => findByTestId('on-demand-scans-profiles-conflict-alert');
const findSubmitButton = () => findByTestId('on-demand-scan-submit-button'); const findSubmitButton = () => findByTestId('on-demand-scan-submit-button');
const findSaveButton = () => findByTestId('on-demand-scan-save-button');
const setValidFormData = () => { const setValidFormData = () => {
findNameInput().vm.$emit('input', 'My daily scan');
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);
return subject.vm.$nextTick(); return subject.vm.$nextTick();
}; };
const setupSuccess = () => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockResolvedValue({
data: { dastScanCreate: { dastScan: { editPath }, pipelineUrl, errors: [] } },
});
return setValidFormData();
};
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} }); const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
const saveScan = () => findSaveButton().vm.$emit('click');
const createMockApolloProvider = (handlers) => { const createMockApolloProvider = (handlers) => {
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -100,6 +118,7 @@ describe('OnDemandScansForm', () => { ...@@ -100,6 +118,7 @@ describe('OnDemandScansForm', () => {
newSiteProfilePath, newSiteProfilePath,
glFeatures: { glFeatures: {
securityOnDemandScansSiteValidation: true, securityOnDemandScansSiteValidation: true,
dastSavedScans: true,
}, },
}, },
}, },
...@@ -180,44 +199,75 @@ describe('OnDemandScansForm', () => { ...@@ -180,44 +199,75 @@ describe('OnDemandScansForm', () => {
scannerProfiles, scannerProfiles,
siteProfiles, siteProfiles,
}, },
stubs: {
GlFormInput: GlFormInputStub,
},
}); });
}); });
describe('on success', () => { describe.each`
action | actionFunction | submitButtonLoading | saveButtonLoading | runAfterCreate | redirectPath
${'submit'} | ${submitForm} | ${true} | ${false} | ${true} | ${pipelineUrl}
${'save'} | ${saveScan} | ${false} | ${true} | ${false} | ${editPath}
`(
'on $action',
({
actionFunction,
submitButtonLoading,
saveButtonLoading,
runAfterCreate,
redirectPath,
}) => {
describe('with valid form data', () => {
beforeEach(async () => { beforeEach(async () => {
jest await setupSuccess();
.spyOn(subject.vm.$apollo, 'mutate') actionFunction();
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
await setValidFormData();
submitForm();
}); });
it('sets loading state', () => { it('sets loading state on correct button', async () => {
expect(subject.vm.loading).toBe(true); const [submitButton, saveButton] = [findSubmitButton(), findSaveButton()];
expect(submitButton.props('loading')).toBe(submitButtonLoading);
expect(submitButton.props('disabled')).toBe(!submitButtonLoading);
expect(saveButton.props('loading')).toBe(saveButtonLoading);
expect(saveButton.props('disabled')).toBe(!saveButtonLoading);
}); });
it('triggers GraphQL mutation', () => { it(`triggers GraphQL mutation with runAfterCreate set to ${runAfterCreate}`, async () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastOnDemandScanCreate, mutation: dastScanCreateMutation,
variables: { variables: {
input: { input: {
name: 'My daily scan',
description: '',
dastScannerProfileId: passiveScannerProfile.id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id, dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath, fullPath: projectPath,
runAfterCreate,
}, },
}, },
}); });
}); });
it('redirects to the URL provided in the response', () => { it('redirects to the URL provided in the response', async () => {
expect(redirectTo).toHaveBeenCalledWith(pipelineUrl); expect(redirectTo).toHaveBeenCalledWith(redirectPath);
}); });
it('does not show an alert', () => { it('does not show an alert', async () => {
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
}); });
}); });
it('does not run any mutation if name is empty', () => {
setValidFormData();
findNameInput().vm.$emit('input', '');
actionFunction();
expect(subject.vm.$apollo.mutate).not.toHaveBeenCalled();
});
},
);
describe('on top-level error', () => { describe('on top-level error', () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue(); jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue();
...@@ -242,7 +292,7 @@ describe('OnDemandScansForm', () => { ...@@ -242,7 +292,7 @@ describe('OnDemandScansForm', () => {
beforeEach(async () => { beforeEach(async () => {
jest jest
.spyOn(subject.vm.$apollo, 'mutate') .spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl: null, errors } } }); .mockResolvedValue({ data: { dastScanCreate: { pipelineUrl: null, errors } } });
await setValidFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -262,6 +312,44 @@ describe('OnDemandScansForm', () => { ...@@ -262,6 +312,44 @@ describe('OnDemandScansForm', () => {
}); });
}); });
describe('dastSavedScans feature flag disabled', () => {
beforeEach(async () => {
mountShallowSubject({
data: {
scannerProfiles,
siteProfiles,
},
stubs: {
GlFormInput: GlFormInputStub,
},
provide: {
glFeatures: {
dastSavedScans: false,
},
},
});
jest
.spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile.id);
subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile.id);
submitForm();
});
it('triggers GraphQL mutation', () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastOnDemandScanCreateMutation,
variables: {
input: {
dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath,
},
},
});
});
});
describe.each` describe.each`
description | selectedScannerProfile | selectedSiteProfile | hasConflict description | selectedScannerProfile | selectedSiteProfile | hasConflict
${'a passive scan and a non-validated site'} | ${passiveScannerProfile} | ${nonValidatedSiteProfile} | ${false} ${'a passive scan and a non-validated site'} | ${passiveScannerProfile} | ${nonValidatedSiteProfile} | ${false}
...@@ -295,7 +383,7 @@ describe('OnDemandScansForm', () => { ...@@ -295,7 +383,7 @@ describe('OnDemandScansForm', () => {
}, },
); );
describe('feature flag disabled', () => { describe('securityOnDemandScansSiteValidation feature flag disabled', () => {
beforeEach(() => { beforeEach(() => {
mountShallowSubject({ mountShallowSubject({
provide: { provide: {
......
...@@ -19559,12 +19559,21 @@ msgstr "" ...@@ -19559,12 +19559,21 @@ msgstr ""
msgid "OnDemandScans|Create a new site profile" msgid "OnDemandScans|Create a new site profile"
msgstr "" msgstr ""
msgid "OnDemandScans|Description"
msgstr ""
msgid "OnDemandScans|Edit on-demand DAST scan" msgid "OnDemandScans|Edit on-demand DAST scan"
msgstr "" msgstr ""
msgid "OnDemandScans|For example: Tests the login page for SQL injections"
msgstr ""
msgid "OnDemandScans|Manage profiles" msgid "OnDemandScans|Manage profiles"
msgstr "" msgstr ""
msgid "OnDemandScans|My daily scan"
msgstr ""
msgid "OnDemandScans|New on-demand DAST scan" msgid "OnDemandScans|New on-demand DAST scan"
msgstr "" msgstr ""
...@@ -19583,6 +19592,15 @@ msgstr "" ...@@ -19583,6 +19592,15 @@ msgstr ""
msgid "OnDemandScans|Run scan" msgid "OnDemandScans|Run scan"
msgstr "" msgstr ""
msgid "OnDemandScans|Save and run scan"
msgstr ""
msgid "OnDemandScans|Save scan"
msgstr ""
msgid "OnDemandScans|Scan name"
msgstr ""
msgid "OnDemandScans|Scanner profile" msgid "OnDemandScans|Scanner profile"
msgstr "" 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