Commit 66e2a2c1 authored by Fernando's avatar Fernando

Show artifact downloads for security reports

We now show artifact download dropdowns on the security tab
in the single pipeline view.

Changelog: added
parent 034d776d
...@@ -32,6 +32,11 @@ export default { ...@@ -32,6 +32,11 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
showDropdown() {
return this.loading || this.artifacts.length > 0;
},
},
methods: { methods: {
artifactText({ name }) { artifactText({ name }) {
return sprintf(s__('SecurityReports|Download %{artifactName}'), { return sprintf(s__('SecurityReports|Download %{artifactName}'), {
...@@ -44,6 +49,7 @@ export default { ...@@ -44,6 +49,7 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
v-if="showDropdown"
v-gl-tooltip v-gl-tooltip
:text="text" :text="text"
:title="title" :title="title"
......
...@@ -14,7 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa ...@@ -14,7 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa
} }
}; };
const extractSecurityReportArtifacts = (reportTypes, jobs) => { export const extractSecurityReportArtifacts = (reportTypes, jobs) => {
return jobs.reduce((acc, job) => { return jobs.reduce((acc, job) => {
const artifacts = job.artifacts?.nodes ?? []; const artifacts = job.artifacts?.nodes ?? [];
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import pipelineSecurityReportSummaryQuery from 'ee/security_dashboard/graphql/queries/pipeline_security_report_summary.query.graphql'; import pipelineSecurityReportSummaryQuery from 'ee/security_dashboard/graphql/queries/pipeline_security_report_summary.query.graphql';
import { reportTypeToSecurityReportTypeEnum } from 'ee/vue_shared/security_reports/constants';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -42,15 +43,25 @@ export default { ...@@ -42,15 +43,25 @@ export default {
return { return {
fullPath: this.projectFullPath, fullPath: this.projectFullPath,
pipelineIid: this.pipeline.iid, pipelineIid: this.pipeline.iid,
reportTypes: Object.values(reportTypeToSecurityReportTypeEnum),
}; };
}, },
update(data) { update(data) {
const summary = data?.project?.pipeline?.securityReportSummary; const summary = {
return summary && Object.keys(summary).length ? summary : null; reports: data?.project?.pipeline?.securityReportSummary,
jobs: data?.project?.pipeline?.jobs.nodes,
};
return summary?.reports && Object.keys(summary.reports).length ? summary : null;
}, },
}, },
}, },
computed: { computed: {
reportSummary() {
return this.securityReportSummary?.reports;
},
jobs() {
return this.securityReportSummary?.jobs;
},
shouldShowGraphqlVulnerabilityReport() { shouldShowGraphqlVulnerabilityReport() {
return this.glFeatures.pipelineSecurityDashboardGraphql; return this.glFeatures.pipelineSecurityDashboardGraphql;
}, },
...@@ -69,8 +80,8 @@ export default { ...@@ -69,8 +80,8 @@ export default {
const getScans = (reportSummary) => reportSummary?.scans?.nodes || []; const getScans = (reportSummary) => reportSummary?.scans?.nodes || [];
const hasErrors = (scan) => Boolean(scan.errors?.length); const hasErrors = (scan) => Boolean(scan.errors?.length);
return this.securityReportSummary return this.reportSummary
? Object.values(this.securityReportSummary) ? Object.values(this.reportSummary)
// generate flat array of all scans // generate flat array of all scans
.flatMap(getScans) .flatMap(getScans)
.filter(hasErrors) .filter(hasErrors)
...@@ -94,19 +105,17 @@ export default { ...@@ -94,19 +105,17 @@ export default {
<template> <template>
<div> <div>
<div v-if="securityReportSummary" class="gl-my-5"> <div v-if="reportSummary" class="gl-my-5">
<scan-errors-alert v-if="hasScansWithErrors" :scans="scansWithErrors" class="gl-mb-5" /> <scan-errors-alert v-if="hasScansWithErrors" :scans="scansWithErrors" class="gl-mb-5" />
<security-reports-summary :summary="securityReportSummary" /> <security-reports-summary :summary="reportSummary" :jobs="jobs" />
</div> </div>
<security-dashboard <security-dashboard
v-if="!shouldShowGraphqlVulnerabilityReport" v-if="!shouldShowGraphqlVulnerabilityReport"
:vulnerabilities-endpoint="vulnerabilitiesEndpoint" :vulnerabilities-endpoint="vulnerabilitiesEndpoint"
:lock-to-project="{ id: projectId }" :lock-to-project="{ id: projectId }"
:pipeline-id="pipeline.id" :pipeline-id="pipeline.id"
:pipeline-iid="pipeline.iid"
:project-full-path="projectFullPath"
:loading-error-illustrations="loadingErrorIllustrations" :loading-error-illustrations="loadingErrorIllustrations"
:security-report-summary="securityReportSummary" :security-report-summary="reportSummary"
> >
<template #empty-state> <template #empty-state>
<gl-empty-state v-bind="emptyStateProps" /> <gl-empty-state v-bind="emptyStateProps" />
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import PipelineArtifactDownload from 'ee/vue_shared/security_reports/components/artifact_downloads/pipeline_artifact_download.vue';
import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue'; import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue';
import { securityReportTypeEnumToReportType } from 'ee/vue_shared/security_reports/constants';
import { vulnerabilityModalMixin } from 'ee/vue_shared/security_reports/mixins/vulnerability_modal_mixin'; import { vulnerabilityModalMixin } from 'ee/vue_shared/security_reports/mixins/vulnerability_modal_mixin';
import VulnerabilityReportLayout from '../shared/vulnerability_report_layout.vue'; import VulnerabilityReportLayout from '../shared/vulnerability_report_layout.vue';
import Filters from './filters.vue'; import Filters from './filters.vue';
...@@ -16,7 +14,6 @@ export default { ...@@ -16,7 +14,6 @@ export default {
VulnerabilityReportLayout, VulnerabilityReportLayout,
SecurityDashboardTable, SecurityDashboardTable,
LoadingError, LoadingError,
PipelineArtifactDownload,
}, },
mixins: [vulnerabilityModalMixin('vulnerabilities')], mixins: [vulnerabilityModalMixin('vulnerabilities')],
props: { props: {
...@@ -24,20 +21,11 @@ export default { ...@@ -24,20 +21,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectFullPath: {
type: String,
required: true,
},
pipelineId: { pipelineId: {
type: Number, type: Number,
required: false, required: false,
default: null, default: null,
}, },
pipelineIid: {
type: Number,
required: false,
default: null,
},
securityReportSummary: { securityReportSummary: {
type: Object, type: Object,
required: false, required: false,
...@@ -61,9 +49,6 @@ export default { ...@@ -61,9 +49,6 @@ export default {
...mapState('pipelineJobs', ['projectId']), ...mapState('pipelineJobs', ['projectId']),
...mapState('filters', ['filters']), ...mapState('filters', ['filters']),
...mapGetters('vulnerabilities', ['loadingVulnerabilitiesFailedWithRecognizedErrorCode']), ...mapGetters('vulnerabilities', ['loadingVulnerabilitiesFailedWithRecognizedErrorCode']),
shouldShowDownloadGuidance() {
return this.projectFullPath && this.pipelineIid && this.securityReportSummary.coverageFuzzing;
},
canCreateIssue() { canCreateIssue() {
const gitLabIssuePath = this.vulnerability.create_vulnerability_feedback_issue_path; const gitLabIssuePath = this.vulnerability.create_vulnerability_feedback_issue_path;
const jiraIssueUrl = this.vulnerability.create_jira_issue_url; const jiraIssueUrl = this.vulnerability.create_jira_issue_url;
...@@ -102,9 +87,6 @@ export default { ...@@ -102,9 +87,6 @@ export default {
...mapActions('pipelineJobs', ['fetchPipelineJobs']), ...mapActions('pipelineJobs', ['fetchPipelineJobs']),
...mapActions('filters', ['lockFilter', 'setHideDismissedToggleInitialState']), ...mapActions('filters', ['lockFilter', 'setHideDismissedToggleInitialState']),
}, },
reportTypes: {
COVERAGE_FUZZING: [securityReportTypeEnumToReportType.COVERAGE_FUZZING],
},
}; };
</script> </script>
...@@ -118,20 +100,7 @@ export default { ...@@ -118,20 +100,7 @@ export default {
<template v-else> <template v-else>
<vulnerability-report-layout> <vulnerability-report-layout>
<template #header> <template #header>
<filters> <filters />
<template v-if="shouldShowDownloadGuidance" #buttons>
<pipeline-artifact-download
class="gl-display-flex gl-flex-direction-column gl-align-self-center"
:report-types="$options.reportTypes.COVERAGE_FUZZING"
:target-project-full-path="projectFullPath"
:pipeline-iid="pipelineIid"
>
<template #label>
<strong class="gl-mb-2">{{ s__('SecurityReports|Coverage fuzzing') }}</strong>
</template>
</pipeline-artifact-download>
</template>
</filters>
</template> </template>
<security-dashboard-table> <security-dashboard-table>
......
...@@ -12,7 +12,10 @@ import { COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY as LOCAL_STORAGE_KE ...@@ -12,7 +12,10 @@ import { COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY as LOCAL_STORAGE_KE
import { getFormattedSummary } from 'ee/security_dashboard/helpers'; import { getFormattedSummary } from 'ee/security_dashboard/helpers';
import Modal from 'ee/vue_shared/security_reports/components/dast_modal.vue'; import Modal from 'ee/vue_shared/security_reports/components/dast_modal.vue';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
export default { export default {
name: 'SecurityReportsSummary', name: 'SecurityReportsSummary',
...@@ -23,6 +26,7 @@ export default { ...@@ -23,6 +26,7 @@ export default {
GlSprintf, GlSprintf,
Modal, Modal,
GlLink, GlLink,
SecurityReportDownloadDropdown,
}, },
directives: { directives: {
collapseToggle: GlCollapseToggleDirective, collapseToggle: GlCollapseToggleDirective,
...@@ -33,6 +37,11 @@ export default { ...@@ -33,6 +37,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
jobs: {
type: Array,
required: false,
default: () => [],
},
}, },
data() { data() {
return { return {
...@@ -73,6 +82,10 @@ export default { ...@@ -73,6 +82,10 @@ export default {
downloadLink(scanSummary) { downloadLink(scanSummary) {
return scanSummary.scannedResourcesCsvPath || ''; return scanSummary.scannedResourcesCsvPath || '';
}, },
findArtifacts(scanType) {
const snakeCase = convertToSnakeCase(scanType.toLowerCase());
return extractSecurityReportArtifacts([snakeCase], this.jobs);
},
}, },
}; };
</script> </script>
...@@ -96,10 +109,10 @@ export default { ...@@ -96,10 +109,10 @@ export default {
</template> </template>
<gl-collapse id="security-reports-summary-details" v-model="isVisible" class="gl-pb-3"> <gl-collapse id="security-reports-summary-details" v-model="isVisible" class="gl-pb-3">
<div v-for="[scanType, scanSummary] in formattedSummary" :key="scanType" class="row gl-my-3"> <div v-for="[scanType, scanSummary] in formattedSummary" :key="scanType" class="row gl-my-3">
<div class="col-6 col-md-4 col-lg-2"> <div class="col-4">
{{ scanType }} {{ scanType }}
</div> </div>
<div class="col-6 col-md-8 col-lg-10"> <div class="col-4">
<gl-sprintf <gl-sprintf
:message=" :message="
n__('%d vulnerability', '%d vulnerabilities', scanSummary.vulnerabilitiesCount) n__('%d vulnerability', '%d vulnerabilities', scanSummary.vulnerabilitiesCount)
...@@ -145,6 +158,12 @@ export default { ...@@ -145,6 +158,12 @@ export default {
</gl-link> </gl-link>
</template> </template>
</div> </div>
<div class="col-4">
<security-report-download-dropdown
:text="s__('SecurityReports|Download results')"
:artifacts="findArtifacts(scanType)"
/>
</div>
</div> </div>
</gl-collapse> </gl-collapse>
</gl-card> </gl-card>
......
#import "../fragments/security_report_scans.fragment.graphql" #import "../fragments/security_report_scans.fragment.graphql"
#import "~/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql"
query pipelineSecuritySummary($fullPath: ID!, $pipelineIid: ID!) { query pipelineSecuritySummary(
$fullPath: ID!
$pipelineIid: ID!
$reportTypes: [SecurityReportTypeEnum!]
) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
pipeline(iid: $pipelineIid) { pipeline(iid: $pipelineIid) {
id
...JobArtifacts
securityReportSummary { securityReportSummary {
dast { dast {
vulnerabilitiesCount vulnerabilitiesCount
......
...@@ -150,7 +150,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -150,7 +150,7 @@ describe('Pipeline Security Dashboard component', () => {
describe('scans error alert', () => { describe('scans error alert', () => {
describe('with errors', () => { describe('with errors', () => {
const securityReportSummary = { const reportSummary = {
scanner_1: { scanner_1: {
// this scan contains errors // this scan contains errors
scans: { scans: {
...@@ -174,10 +174,14 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -174,10 +174,14 @@ describe('Pipeline Security Dashboard component', () => {
}, },
}; };
const scansWithErrors = [ const scansWithErrors = [
...securityReportSummary.scanner_1.scans.nodes, ...reportSummary.scanner_1.scans.nodes,
...securityReportSummary.scanner_3.scans.nodes, ...reportSummary.scanner_3.scans.nodes,
]; ];
const securityReportSummary = {
reports: reportSummary,
};
beforeEach(() => { beforeEach(() => {
factory({ factory({
data: { data: {
...@@ -192,7 +196,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -192,7 +196,7 @@ describe('Pipeline Security Dashboard component', () => {
}); });
describe('without errors', () => { describe('without errors', () => {
const securityReportSummary = { const reportSummary = {
dast: { dast: {
scans: [ scans: [
{ {
...@@ -203,6 +207,10 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -203,6 +207,10 @@ describe('Pipeline Security Dashboard component', () => {
}, },
}; };
const securityReportSummary = {
reports: reportSummary,
};
beforeEach(() => { beforeEach(() => {
factory({ factory({
data: { data: {
...@@ -218,12 +226,16 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -218,12 +226,16 @@ describe('Pipeline Security Dashboard component', () => {
}); });
describe('security reports summary', () => { describe('security reports summary', () => {
const securityReportSummary = { const reportSummary = {
dast: { dast: {
vulnerabilitiesCount: 123, vulnerabilitiesCount: 123,
}, },
}; };
const securityReportSummary = {
reports: reportSummary,
};
it('shows the summary if it is non-empty', () => { it('shows the summary if it is non-empty', () => {
factory({ factory({
data: { data: {
......
...@@ -8,7 +8,6 @@ import LoadingError from 'ee/security_dashboard/components/pipeline/loading_erro ...@@ -8,7 +8,6 @@ import LoadingError from 'ee/security_dashboard/components/pipeline/loading_erro
import SecurityDashboardTable from 'ee/security_dashboard/components/pipeline/security_dashboard_table.vue'; import SecurityDashboardTable from 'ee/security_dashboard/components/pipeline/security_dashboard_table.vue';
import SecurityDashboard from 'ee/security_dashboard/components/pipeline/security_dashboard_vuex.vue'; import SecurityDashboard from 'ee/security_dashboard/components/pipeline/security_dashboard_vuex.vue';
import { getStoreConfig } from 'ee/security_dashboard/store'; import { getStoreConfig } from 'ee/security_dashboard/store';
import PipelineArtifactDownload from 'ee/vue_shared/security_reports/components/artifact_downloads/pipeline_artifact_download.vue';
import { VULNERABILITY_MODAL_ID } from 'ee/vue_shared/security_reports/components/constants'; import { VULNERABILITY_MODAL_ID } from 'ee/vue_shared/security_reports/components/constants';
import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue'; import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -48,9 +47,6 @@ describe('Security Dashboard component', () => { ...@@ -48,9 +47,6 @@ describe('Security Dashboard component', () => {
wrapper = mount(SecurityDashboard, { wrapper = mount(SecurityDashboard, {
store, store,
stubs: {
PipelineArtifactDownload: true,
},
propsData: { propsData: {
dashboardDocumentation: '', dashboardDocumentation: '',
projectFullPath: '/path', projectFullPath: '/path',
...@@ -97,10 +93,6 @@ describe('Security Dashboard component', () => { ...@@ -97,10 +93,6 @@ describe('Security Dashboard component', () => {
expect(wrapper.find(IssueModal).exists()).toBe(true); expect(wrapper.find(IssueModal).exists()).toBe(true);
}); });
it('does not render coverage fuzzing artifact download', () => {
expect(wrapper.find(PipelineArtifactDownload).exists()).toBe(false);
});
it.each` it.each`
emittedModalEvent | eventPayload | expectedDispatchedAction | expectedActionPayload emittedModalEvent | eventPayload | expectedDispatchedAction | expectedActionPayload
${'addDismissalComment'} | ${'foo'} | ${'vulnerabilities/addDismissalComment'} | ${{ comment: 'foo', vulnerability: 'bar' }} ${'addDismissalComment'} | ${'foo'} | ${'vulnerabilities/addDismissalComment'} | ${{ comment: 'foo', vulnerability: 'bar' }}
...@@ -137,18 +129,6 @@ describe('Security Dashboard component', () => { ...@@ -137,18 +129,6 @@ describe('Security Dashboard component', () => {
}); });
}); });
describe('with coverage fuzzing', () => {
beforeEach(() => {
createComponent({
props: { securityReportSummary: { coverageFuzzing: { scannedResourcesCount: 1 } } },
});
});
it('renders coverage fuzzing artifact download', () => {
expect(wrapper.find(PipelineArtifactDownload).exists()).toBe(true);
});
});
describe('issue modal', () => { describe('issue modal', () => {
it.each` it.each`
givenState | expectedProps givenState | expectedProps
......
...@@ -2,9 +2,11 @@ import { GlSprintf } from '@gitlab/ui'; ...@@ -2,9 +2,11 @@ import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue'; import SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue';
import Modal from 'ee/vue_shared/security_reports/components/dast_modal.vue'; import Modal from 'ee/vue_shared/security_reports/components/dast_modal.vue';
import { mockPipelineJobs } from 'ee_jest/security_dashboard/mock_data/jobs';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
describe('Security reports summary component', () => { describe('Security reports summary component', () => {
useLocalStorageSpy(); useLocalStorageSpy();
...@@ -97,6 +99,24 @@ describe('Security reports summary component', () => { ...@@ -97,6 +99,24 @@ describe('Security reports summary component', () => {
expect(trimText(wrapper.text())).toContain(string); expect(trimText(wrapper.text())).toContain(string);
}); });
it.each`
summaryProp | jobsProp | hasDropdown
${{ coverageFuzzing: { vulnerabilitiesCount: 123 } }} | ${mockPipelineJobs} | ${true}
${{ coverageFuzzing: null }} | ${[]} | ${false}
`(
'artifact download dropdown is visible $hasDropdown',
({ summaryProp, jobsProp, hasDropdown }) => {
createWrapper({
propsData: {
summary: summaryProp,
jobs: jobsProp,
},
});
expect(wrapper.findComponent(SecurityReportDownloadDropdown).exists()).toBe(hasDropdown);
},
);
it.each` it.each`
summaryProp | report summaryProp | report
${{ dast: null }} | ${'DAST'} ${{ dast: null }} | ${'DAST'}
......
export const mockPipelineJobs = [
{
name: 'my_fuzz_target',
artifacts: {
nodes: [
{
downloadPath: '/debug-cov-fuzz-project/-/jobs/1133/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'debug-cov-fuzz-project/-/jobs/1133/artifacts/download?file_type=coverage_fuzzing',
fileType: 'COVERAGE_FUZZING',
__typename: 'CiJobArtifact',
},
{
downloadPath: '/debug-cov-fuzz-project/-/jobs/1133/artifacts/download?file_type=metadata',
fileType: 'METADATA',
__typename: 'CiJobArtifact',
},
{
downloadPath: '/debug-cov-fuzz-project/-/jobs/1133/artifacts/download?file_type=archive',
fileType: 'ARCHIVE',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
{
name: 'gosec-sast',
artifacts: {
nodes: [
{
downloadPath: '/debug-cov-fuzz-project/-/jobs/1131/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath: '/debug-cov-fuzz-project/-/jobs/1131/artifacts/download?file_type=sast',
fileType: 'SAST',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
];
...@@ -29576,9 +29576,6 @@ msgstr "" ...@@ -29576,9 +29576,6 @@ msgstr ""
msgid "SecurityReports|Configure security testing" msgid "SecurityReports|Configure security testing"
msgstr "" msgstr ""
msgid "SecurityReports|Coverage fuzzing"
msgstr ""
msgid "SecurityReports|Create Jira issue" msgid "SecurityReports|Create Jira issue"
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