Commit 7e866ccf authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '341932-update-policy-drawer-summary' into 'master'

Update policy summary to be more readable

See merge request gitlab-org/gitlab!81048
parents 23abd630 d463d882
...@@ -11,3 +11,5 @@ export const NOT_ENABLED_LABEL = s__('SecurityOrchestration|Not enabled'); ...@@ -11,3 +11,5 @@ export const NOT_ENABLED_LABEL = s__('SecurityOrchestration|Not enabled');
export const TYPE_TITLE = s__('SecurityOrchestration|Policy Type'); export const TYPE_TITLE = s__('SecurityOrchestration|Policy Type');
export const STATUS_TITLE = s__('SecurityOrchestration|Status'); export const STATUS_TITLE = s__('SecurityOrchestration|Status');
export const SUMMARY_TITLE = s__('SecurityOrchestration|Summary');
<script> <script>
import { GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { import {
fromYaml, fromYaml,
humanizeActions, humanizeActions,
humanizeRules, humanizeRules,
} from '../policy_editor/scan_execution_policy/lib'; } from '../policy_editor/scan_execution_policy/lib';
import { SUMMARY_TITLE } from './constants';
import PolicyDrawerLayout from './policy_drawer_layout.vue'; import PolicyDrawerLayout from './policy_drawer_layout.vue';
import PolicyInfoRow from './policy_info_row.vue'; import PolicyInfoRow from './policy_info_row.vue';
export default { export default {
i18n: { i18n: {
action: s__('SecurityOrchestration|Action'), multipleActionMessage: s__('SecurityOrchestration|Runs %{actions} and %{lastAction} scans'),
rule: s__('SecurityOrchestration|Rule'), noActionMessage: s__('SecurityOrchestration|No actions defined - policy will not run.'),
singleActionMessage: s__(`SecurityOrchestration|Runs a %{action} scan`),
scanExecution: s__('SecurityOrchestration|Scan execution'), scanExecution: s__('SecurityOrchestration|Scan execution'),
summary: SUMMARY_TITLE,
}, },
components: { components: {
GlSprintf,
PolicyDrawerLayout, PolicyDrawerLayout,
PolicyInfoRow, PolicyInfoRow,
}, },
...@@ -38,6 +43,21 @@ export default { ...@@ -38,6 +43,21 @@ export default {
return null; return null;
} }
}, },
hasOnlyOneAction() {
return this.humanizedActions.length === 1;
},
hasMultipleActions() {
return this.humanizedActions.length > 1;
},
firstAction() {
return this.hasOnlyOneAction ? this.humanizedActions[0] : '';
},
allButLastActions() {
return this.hasMultipleActions ? this.humanizedActions.slice(0, -1).join(', ') : '';
},
lastAction() {
return this.hasMultipleActions ? [...this.humanizedActions].pop() : '';
},
}, },
}; };
</script> </script>
...@@ -50,12 +70,28 @@ export default { ...@@ -50,12 +70,28 @@ export default {
:type="$options.i18n.scanExecution" :type="$options.i18n.scanExecution"
> >
<template v-if="parsedYaml" #summary> <template v-if="parsedYaml" #summary>
<policy-info-row data-testid="policy-rules" :label="$options.i18n.rule"> <policy-info-row data-testid="policy-summary" :label="$options.i18n.summary">
<p v-for="rule in humanizedRules" :key="rule">{{ rule }}</p> <p>
</policy-info-row> <template v-if="!humanizedActions.length">{{ $options.i18n.noActionMessage }}</template>
<gl-sprintf v-else-if="hasOnlyOneAction" :message="$options.i18n.singleActionMessage">
<policy-info-row data-testid="policy-actions" :label="$options.i18n.action"> <template #action>
<p v-for="action in humanizedActions" :key="action">{{ action }}</p> <strong>{{ firstAction }}</strong>
</template>
</gl-sprintf>
<gl-sprintf v-else :message="$options.i18n.multipleActionMessage">
<template #actions>
<strong>{{ allButLastActions }}</strong>
</template>
<template #lastAction>
<strong>{{ lastAction }}</strong>
</template>
</gl-sprintf>
</p>
<ul>
<li v-for="(rule, idx) in humanizedRules" :key="idx">
{{ rule }}
</li>
</ul>
</policy-info-row> </policy-info-row>
</template> </template>
</policy-drawer-layout> </policy-drawer-layout>
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { fromYaml, humanizeRules, humanizeAction } from '../policy_editor/scan_result_policy/lib'; import { fromYaml, humanizeRules, humanizeAction } from '../policy_editor/scan_result_policy/lib';
import { SUMMARY_TITLE } from './constants';
import PolicyDrawerLayout from './policy_drawer_layout.vue'; import PolicyDrawerLayout from './policy_drawer_layout.vue';
import PolicyInfoRow from './policy_info_row.vue'; import PolicyInfoRow from './policy_info_row.vue';
export default { export default {
i18n: { i18n: {
action: s__('SecurityOrchestration|Action'), summary: SUMMARY_TITLE,
rule: s__('SecurityOrchestration|Rule'),
scanResult: s__('SecurityOrchestration|Scan result'), scanResult: s__('SecurityOrchestration|Scan result'),
}, },
components: { components: {
...@@ -51,7 +51,7 @@ export default { ...@@ -51,7 +51,7 @@ export default {
:type="$options.i18n.scanResult" :type="$options.i18n.scanResult"
> >
<template v-if="parsedYaml" #summary> <template v-if="parsedYaml" #summary>
<policy-info-row data-testid="policy-rules" :label="$options.i18n.rule"> <policy-info-row data-testid="policy-summary" :label="$options.i18n.summary">
<p>{{ humanizedAction }}</p> <p>{{ humanizedAction }}</p>
<ul> <ul>
<li v-for="(rule, idx) in humanizedRules" :key="idx"> <li v-for="(rule, idx) in humanizedRules" :key="idx">
......
import cronstrue from 'cronstrue/i18n'; import cronstrue from 'cronstrue/i18n';
import { convertToTitleCase, humanize } from '~/lib/utils/text_utility';
import { getPreferredLocales, sprintf, s__, n__ } from '~/locale'; import { getPreferredLocales, sprintf, s__, n__ } from '~/locale';
import { NO_RULE_MESSAGE } from '../../constants'; import { NO_RULE_MESSAGE } from '../../constants';
import { convertScannersToTitleCase } from '../../utils';
const getActionText = (scanType) =>
sprintf(s__('SecurityOrchestration|Executes a %{scanType} scan'), {
scanType: convertToTitleCase(humanize(scanType)),
});
/** /**
* Create a human-readable list of strings, adding the necessary punctuation and conjunctions * Create a human-readable list of strings, adding the necessary punctuation and conjunctions
...@@ -67,10 +62,11 @@ const HUMANIZE_RULES_METHODS = { ...@@ -67,10 +62,11 @@ const HUMANIZE_RULES_METHODS = {
/** /**
* Create a human-readable version of the actions * Create a human-readable version of the actions
* @param {Array} actions [{"scan":"dast","scanner_profile":"Scanner Profile","site_profile":"Site Profile"},{"type":"secret_detection"}] * @param {Array} actions [{"scan":"dast","scanner_profile":"Scanner Profile","site_profile":"Site Profile"},{"type":"secret_detection"}]
* @returns {Set} * @returns {Array}
*/ */
export const humanizeActions = (actions) => { export const humanizeActions = (actions) => {
return new Set(actions.map((action) => getActionText(action.scan))); const scanners = actions.map((a) => a.scan);
return [...new Set(convertScannersToTitleCase(scanners))];
}; };
/** /**
......
import { sprintf, s__, n__ } from '~/locale'; import { sprintf, s__, n__ } from '~/locale';
import { NO_RULE_MESSAGE } from '../../constants'; import { NO_RULE_MESSAGE } from '../../constants';
import { convertScannersToTitleCase } from '../../utils';
/** /**
* Simple logic for indefinite articles which does not include the exceptions * Simple logic for indefinite articles which does not include the exceptions
...@@ -171,7 +172,7 @@ const humanizeRule = (rule) => { ...@@ -171,7 +172,7 @@ const humanizeRule = (rule) => {
), ),
{ {
scanners: humanizeItems({ scanners: humanizeItems({
items: rule.scanners, items: convertScannersToTitleCase(rule.scanners),
singular: s__('SecurityOrchestration|scanner finds'), singular: s__('SecurityOrchestration|scanner finds'),
plural: s__('SecurityOrchestration|scanners find'), plural: s__('SecurityOrchestration|scanners find'),
}), }),
......
import { convertToTitleCase, humanize } from '~/lib/utils/text_utility';
import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql'; import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql';
import createScanExecutionPolicy from 'ee/threat_monitoring/graphql/mutations/create_scan_execution_policy.mutation.graphql'; import createScanExecutionPolicy from 'ee/threat_monitoring/graphql/mutations/create_scan_execution_policy.mutation.graphql';
import { gqClient } from 'ee/threat_monitoring/utils'; import { gqClient } from 'ee/threat_monitoring/utils';
...@@ -123,3 +124,11 @@ export const assignSecurityPolicyProject = async (projectPath) => { ...@@ -123,3 +124,11 @@ export const assignSecurityPolicyProject = async (projectPath) => {
return { ...project, branch: project?.branch?.rootRef, errors }; return { ...project, branch: project?.branch?.rootRef, errors };
}; };
/**
* Converts scanner strings to title case
* @param {Array} scanners (e.g. 'container_scanning', `dast`, etcetera)
* @returns {Array} (e.g. 'Container Scanning', `Dast`, etcetera)
*/
export const convertScannersToTitleCase = (scanners = []) =>
scanners.map((scanner) => convertToTitleCase(humanize(scanner)));
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScanExecutionPolicy component default policy renders the correct policy action message 1`] = `
<div
data-testid="policy-summary"
>
<div
data-testid="policy-summary"
>
<h5
class="gl-mt-6"
data-testid="label"
>
Summary
</h5>
<p
data-testid="content"
>
<p>
Runs a
<strong>
Dast
</strong>
scan
</p>
<ul>
<li>
Scan to be performed on every pipeline on the main branch
</li>
</ul>
</p>
</div>
</div>
`;
exports[`ScanExecutionPolicy component multiple action policy renders the correct policy action message 1`] = `
<div
data-testid="policy-summary"
>
<div
data-testid="policy-summary"
>
<h5
class="gl-mt-6"
data-testid="label"
>
Summary
</h5>
<p
data-testid="content"
>
<p>
Runs
<strong>
Container Scanning, Secret Detection
</strong>
and
<strong>
Sast
</strong>
scans
</p>
<ul>
<li>
Scan to be performed on every pipeline on the main branch
</li>
</ul>
</p>
</div>
</div>
`;
exports[`ScanExecutionPolicy component no action policy renders the correct policy action message 1`] = `
<div
data-testid="policy-summary"
>
<div
data-testid="policy-summary"
>
<h5
class="gl-mt-6"
data-testid="label"
>
Summary
</h5>
<p
data-testid="content"
>
<p>
No actions defined - policy will not run.
</p>
<ul>
<li>
Scan to be performed on every pipeline on the main branch
</li>
</ul>
</p>
</div>
</div>
`;
import PolicyDrawerLayout from 'ee/threat_monitoring/components/policy_drawer/policy_drawer_layout.vue';
import ScanExecutionPolicy from 'ee/threat_monitoring/components/policy_drawer/scan_execution_policy.vue'; import ScanExecutionPolicy from 'ee/threat_monitoring/components/policy_drawer/scan_execution_policy.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mockScanExecutionPolicy } from '../../mocks/mock_data'; import {
mockScanExecutionPolicy,
mockScanExecutionManifestNoActions,
mockScanExecutionManifestMultipleActions,
} from '../../mocks/mock_data';
describe('ScanExecutionPolicy component', () => { describe('ScanExecutionPolicy component', () => {
let wrapper; let wrapper;
const findActions = () => wrapper.findByTestId('policy-actions'); const findSummary = () => wrapper.findByTestId('policy-summary');
const findRules = () => wrapper.findByTestId('policy-rules');
const factory = ({ propsData } = {}) => { const factory = ({ propsData } = {}) => {
wrapper = shallowMountExtended(ScanExecutionPolicy, { wrapper = mountExtended(ScanExecutionPolicy, {
propsData, propsData,
stubs: {
PolicyDrawerLayout,
},
}); });
}; };
...@@ -22,21 +21,18 @@ describe('ScanExecutionPolicy component', () => { ...@@ -22,21 +21,18 @@ describe('ScanExecutionPolicy component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('default policy', () => { describe.each`
title | propsData
${'default policy'} | ${{ policy: mockScanExecutionPolicy }}
${'no action policy'} | ${{ policy: { ...mockScanExecutionPolicy, yaml: mockScanExecutionManifestNoActions } }}
${'multiple action policy'} | ${{ policy: { ...mockScanExecutionPolicy, yaml: mockScanExecutionManifestMultipleActions } }}
`('$title', ({ propsData }) => {
beforeEach(() => { beforeEach(() => {
factory({ propsData: { policy: mockScanExecutionPolicy } }); factory({ propsData });
}); });
it.each` it('renders the correct policy action message', () => {
component | finder | text expect(findSummary().element).toMatchSnapshot();
${'actions'} | ${findActions} | ${''}
${'rules'} | ${findRules} | ${''}
`('does render the policy $component', ({ finder, text }) => {
const component = finder();
expect(component.exists()).toBe(true);
if (text) {
expect(component.text()).toBe(text);
}
}); });
}); });
}); });
...@@ -6,7 +6,7 @@ import { mockScanResultPolicy } from '../../mocks/mock_data'; ...@@ -6,7 +6,7 @@ import { mockScanResultPolicy } from '../../mocks/mock_data';
describe('ScanResultPolicy component', () => { describe('ScanResultPolicy component', () => {
let wrapper; let wrapper;
const findRules = () => wrapper.findByTestId('policy-rules'); const findSummary = () => wrapper.findByTestId('policy-summary');
const factory = ({ propsData } = {}) => { const factory = ({ propsData } = {}) => {
wrapper = shallowMountExtended(ScanResultPolicy, { wrapper = shallowMountExtended(ScanResultPolicy, {
...@@ -26,8 +26,8 @@ describe('ScanResultPolicy component', () => { ...@@ -26,8 +26,8 @@ describe('ScanResultPolicy component', () => {
factory({ propsData: { policy: mockScanResultPolicy } }); factory({ propsData: { policy: mockScanResultPolicy } });
}); });
it('does render the policy rules', () => { it('does render the policy summary', () => {
expect(findRules().exists()).toBe(true); expect(findSummary().exists()).toBe(true);
}); });
}); });
}); });
...@@ -17,6 +17,7 @@ const mockActions = [ ...@@ -17,6 +17,7 @@ const mockActions = [
{ scan: 'dast', scanner_profile: 'Scanner Profile', site_profile: 'Site Profile' }, { scan: 'dast', scanner_profile: 'Scanner Profile', site_profile: 'Site Profile' },
{ scan: 'dast', scanner_profile: 'Scanner Profile 01', site_profile: 'Site Profile 01' }, { scan: 'dast', scanner_profile: 'Scanner Profile 01', site_profile: 'Site Profile 01' },
{ scan: 'secret_detection' }, { scan: 'secret_detection' },
{ scan: 'container_image_scanning' },
]; ];
const mockRules = [ const mockRules = [
...@@ -33,17 +34,19 @@ const mockRules = [ ...@@ -33,17 +34,19 @@ const mockRules = [
describe('humanizeActions', () => { describe('humanizeActions', () => {
it('returns an empty Array of actions as an empty Set', () => { it('returns an empty Array of actions as an empty Set', () => {
expect(humanizeActions([])).toStrictEqual(new Set()); expect(humanizeActions([])).toStrictEqual([]);
}); });
it('returns a single action as human-readable string', () => { it('returns a single action as human-readable string', () => {
expect(humanizeActions([mockActions[0]])).toStrictEqual(new Set(['Executes a Dast scan'])); expect(humanizeActions([mockActions[0]])).toStrictEqual(['Dast']);
}); });
it('returns multiple actions as human-readable strings', () => { it('returns multiple actions as human-readable strings', () => {
expect(humanizeActions(mockActions)).toStrictEqual( expect(humanizeActions(mockActions)).toStrictEqual([
new Set(['Executes a Dast scan', 'Executes a Secret Detection scan']), 'Dast',
); 'Secret Detection',
'Container Image Scanning',
]);
}); });
}); });
......
...@@ -59,6 +59,11 @@ const mockRules = [ ...@@ -59,6 +59,11 @@ const mockRules = [
}, },
]; ];
const mockRulesHumanized = [
'The Sast scanner finds a critical vulnerability in an open merge request targeting the main branch.',
'The Dast or Sast scanners find info or critical vulnerabilities in an open merge request targeting the master or main branches.',
];
const mockRulesEmptyBranch = { const mockRulesEmptyBranch = {
type: 'scan_finding', type: 'scan_finding',
branches: [], branches: [],
...@@ -74,21 +79,16 @@ describe('humanizeRules', () => { ...@@ -74,21 +79,16 @@ describe('humanizeRules', () => {
}); });
it('returns a single rule as a human-readable string for user approvers only', () => { it('returns a single rule as a human-readable string for user approvers only', () => {
expect(humanizeRules([mockRules[0]])).toStrictEqual([ expect(humanizeRules([mockRules[0]])).toStrictEqual([mockRulesHumanized[0]]);
'The sast scanner finds a critical vulnerability in an open merge request targeting the main branch.',
]);
}); });
it('returns multiple rules with different number of branches as human-readable strings', () => { it('returns multiple rules with different number of branches as human-readable strings', () => {
expect(humanizeRules(mockRules)).toStrictEqual([ expect(humanizeRules(mockRules)).toStrictEqual(mockRulesHumanized);
'The sast scanner finds a critical vulnerability in an open merge request targeting the main branch.',
'The dast or sast scanners find info or critical vulnerabilities in an open merge request targeting the master or main branches.',
]);
}); });
it('returns a single rule as a human-readable string for all branches', () => { it('returns a single rule as a human-readable string for all branches', () => {
expect(humanizeRules([mockRulesEmptyBranch])).toStrictEqual([ expect(humanizeRules([mockRulesEmptyBranch])).toStrictEqual([
'The sast scanner finds a critical vulnerability in an open merge request targeting all branches.', 'The Sast scanner finds a critical vulnerability in an open merge request targeting all branches.',
]); ]);
}); });
}); });
......
import { import {
assignSecurityPolicyProject, assignSecurityPolicyProject,
modifyPolicy, modifyPolicy,
convertScannersToTitleCase,
} from 'ee/threat_monitoring/components/policy_editor/utils'; } from 'ee/threat_monitoring/components/policy_editor/utils';
import { DEFAULT_ASSIGNED_POLICY_PROJECT } from 'ee/threat_monitoring/constants'; import { DEFAULT_ASSIGNED_POLICY_PROJECT } from 'ee/threat_monitoring/constants';
import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql'; import createPolicyProject from 'ee/threat_monitoring/graphql/mutations/create_policy_project.mutation.graphql';
...@@ -102,3 +103,14 @@ describe('modifyPolicy', () => { ...@@ -102,3 +103,14 @@ describe('modifyPolicy', () => {
await expect(modifyPolicy(createSavePolicyInput())).rejects.toThrowError(error); await expect(modifyPolicy(createSavePolicyInput())).rejects.toThrowError(error);
}); });
}); });
describe('convertScannersToTitleCase', () => {
it.each`
title | input | output
${'returns empty array if no imput is provided'} | ${undefined} | ${[]}
${'returns empty array for an empty array'} | ${[]} | ${[]}
${'returns converted array'} | ${['dast', 'container_scanning', 'secret_detection']} | ${['Dast', 'Container Scanning', 'Secret Detection']}
`('$title', ({ input, output }) => {
expect(convertScannersToTitleCase(input)).toStrictEqual(output);
});
});
...@@ -49,6 +49,17 @@ export const mockEnvironmentsResponse = { ...@@ -49,6 +49,17 @@ export const mockEnvironmentsResponse = {
stopped_count: 5, stopped_count: 5,
}; };
export const mockScanExecutionManifestNoActions = `type: scan_execution_policy
name: Test Dast
description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: false
rules:
- type: pipeline
branches:
- main
actions: []
`;
export const mockDastScanExecutionManifest = `type: scan_execution_policy export const mockDastScanExecutionManifest = `type: scan_execution_policy
name: Test Dast name: Test Dast
description: This policy enforces pipeline configuration to have a job with DAST scan description: This policy enforces pipeline configuration to have a job with DAST scan
...@@ -63,6 +74,20 @@ actions: ...@@ -63,6 +74,20 @@ actions:
scanner_profile: required_scanner_profile scanner_profile: required_scanner_profile
`; `;
export const mockScanExecutionManifestMultipleActions = `type: scan_execution_policy
name: Test Dast
description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: false
rules:
- type: pipeline
branches:
- main
actions:
- scan: container_scanning
- scan: secret_detection
- scan: sast
`;
export const mockDastScanExecutionObject = { export const mockDastScanExecutionObject = {
type: 'scan_execution_policy', type: 'scan_execution_policy',
name: 'Test Dast', name: 'Test Dast',
......
...@@ -32488,9 +32488,6 @@ msgstr "" ...@@ -32488,9 +32488,6 @@ msgstr ""
msgid "SecurityOrchestration|.yaml preview" msgid "SecurityOrchestration|.yaml preview"
msgstr "" msgstr ""
msgid "SecurityOrchestration|Action"
msgstr ""
msgid "SecurityOrchestration|Actions" msgid "SecurityOrchestration|Actions"
msgstr "" msgstr ""
...@@ -32533,9 +32530,6 @@ msgstr "" ...@@ -32533,9 +32530,6 @@ msgstr ""
msgid "SecurityOrchestration|Enforce security for this project. %{linkStart}More information.%{linkEnd}" msgid "SecurityOrchestration|Enforce security for this project. %{linkStart}More information.%{linkEnd}"
msgstr "" msgstr ""
msgid "SecurityOrchestration|Executes a %{scanType} scan"
msgstr ""
msgid "SecurityOrchestration|If you are using Auto DevOps, your %{monospacedStart}auto-deploy-values.yaml%{monospacedEnd} file will not be updated if you change a policy in this section. Auto DevOps users should make changes by following the %{linkStart}Container Network Policy documentation%{linkEnd}." msgid "SecurityOrchestration|If you are using Auto DevOps, your %{monospacedStart}auto-deploy-values.yaml%{monospacedEnd} file will not be updated if you change a policy in this section. Auto DevOps users should make changes by following the %{linkStart}Container Network Policy documentation%{linkEnd}."
msgstr "" msgstr ""
...@@ -32551,6 +32545,9 @@ msgstr "" ...@@ -32551,6 +32545,9 @@ msgstr ""
msgid "SecurityOrchestration|New policy" msgid "SecurityOrchestration|New policy"
msgstr "" msgstr ""
msgid "SecurityOrchestration|No actions defined - policy will not run."
msgstr ""
msgid "SecurityOrchestration|No description" msgid "SecurityOrchestration|No description"
msgstr "" msgstr ""
...@@ -32593,10 +32590,13 @@ msgstr "" ...@@ -32593,10 +32590,13 @@ msgstr ""
msgid "SecurityOrchestration|Require %{approvals} %{plural} from %{approvers} if any of the following occur:" msgid "SecurityOrchestration|Require %{approvals} %{plural} from %{approvers} if any of the following occur:"
msgstr "" msgstr ""
msgid "SecurityOrchestration|Rule" msgid "SecurityOrchestration|Rules"
msgstr "" msgstr ""
msgid "SecurityOrchestration|Rules" msgid "SecurityOrchestration|Runs %{actions} and %{lastAction} scans"
msgstr ""
msgid "SecurityOrchestration|Runs a %{action} scan"
msgstr "" msgstr ""
msgid "SecurityOrchestration|Scan Execution" msgid "SecurityOrchestration|Scan Execution"
......
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