Commit a9fc41f9 authored by Fernando Arias's avatar Fernando Arias Committed by Vitaly Slobodin

First attempt at generic artifact download component

* Create generic component
* Add API Fuzzing constants

Working artifact dropdown for json assets

Add archive, metadata, trace download support

* Add support to download additional artifact types to
artifact download button

Add unit tests

- Unit tests for artifact download component

Update unit tests

* Update group security report specs
* Update mr widget specs

Apply code review feedback

* Refactor util function
* Refactor eslint rule exceptions

Update artifact download vue props

* Mark targetFullPath and mr id props as required

Apply review feedback

* Require props
* Update unit tests
* Prettier/Linter and Changelog
parent 88f90b0e
...@@ -4,6 +4,15 @@ export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; ...@@ -4,6 +4,15 @@ export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
/**
* Security artifact file types
*/
export const REPORT_FILE_TYPES = {
ARCHIVE: 'ARCHIVE',
TRACE: 'TRACE',
METADATA: 'METADATA',
};
/** /**
* Security scan report types, as provided by the backend. * Security scan report types, as provided by the backend.
*/ */
......
import { securityReportTypeEnumToReportType } from './constants'; import { capitalize } from 'lodash';
import {
securityReportTypeEnumToReportType,
REPORT_FILE_TYPES,
} from 'ee_else_ce/vue_shared/security_reports/constants';
const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPath) => {
if (reportTypes && reportTypes.includes(reportType)) {
acc.push({
reportType,
name: getName(reportType),
path: downloadPath,
});
}
};
export const extractSecurityReportArtifacts = (reportTypes, data) => { export const extractSecurityReportArtifacts = (reportTypes, data) => {
const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
...@@ -7,14 +21,21 @@ export const extractSecurityReportArtifacts = (reportTypes, data) => { ...@@ -7,14 +21,21 @@ export const extractSecurityReportArtifacts = (reportTypes, data) => {
const artifacts = job.artifacts?.nodes ?? []; const artifacts = job.artifacts?.nodes ?? [];
artifacts.forEach(({ downloadPath, fileType }) => { artifacts.forEach(({ downloadPath, fileType }) => {
const reportType = securityReportTypeEnumToReportType[fileType]; addReportTypeIfExists(
if (reportType && reportTypes.includes(reportType)) { acc,
acc.push({ reportTypes,
name: job.name, securityReportTypeEnumToReportType[fileType],
reportType, () => job.name,
path: downloadPath, downloadPath,
}); );
}
addReportTypeIfExists(
acc,
reportTypes,
REPORT_FILE_TYPES[fileType],
(reportType) => `${job.name} ${capitalize(reportType)}`,
downloadPath,
);
}); });
return acc; return acc;
......
...@@ -357,6 +357,8 @@ export default { ...@@ -357,6 +357,8 @@ export default {
:dependency-scanning-comparison-path="mr.dependencyScanningComparisonPath" :dependency-scanning-comparison-path="mr.dependencyScanningComparisonPath"
:sast-comparison-path="mr.sastComparisonPath" :sast-comparison-path="mr.sastComparisonPath"
:secret-scanning-comparison-path="mr.secretScanningComparisonPath" :secret-scanning-comparison-path="mr.secretScanningComparisonPath"
:target-project-full-path="mr.targetProjectFullPath"
:mr-iid="mr.iid"
class="js-security-widget" class="js-security-widget"
/> />
<mr-widget-licenses <mr-widget-licenses
......
<script>
import { reportTypeToSecurityReportTypeEnum } from 'ee/vue_shared/security_reports/constants';
import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export default {
components: {
SecurityReportDownloadDropdown,
},
props: {
reportTypes: {
type: Array,
required: true,
validator: (reportType) => {
return reportType.every((report) => reportTypeToSecurityReportTypeEnum[report]);
},
},
targetProjectFullPath: {
type: String,
required: true,
},
mrIid: {
type: Number,
required: true,
},
},
data() {
return {
reportArtifacts: [],
};
},
apollo: {
reportArtifacts: {
query: securityReportDownloadPathsQuery,
variables() {
return {
projectPath: this.targetProjectFullPath,
iid: String(this.mrIid),
reportTypes: this.reportTypes.map(
(reportType) => reportTypeToSecurityReportTypeEnum[reportType],
),
};
},
update(data) {
return extractSecurityReportArtifacts(this.reportTypes, data);
},
error(error) {
this.showError(error);
},
},
},
computed: {
isLoadingReportArtifacts() {
return this.$apollo.queries.reportArtifacts.loading;
},
},
methods: {
showError(error) {
createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
},
},
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
},
};
</script>
<template>
<security-report-download-dropdown
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
/* eslint-disable import/export */
import { invert } from 'lodash';
import { reportTypeToSecurityReportTypeEnum as reportTypeToSecurityReportTypeEnumCE } from '~/vue_shared/security_reports/constants';
export * from '~/vue_shared/security_reports/constants';
/**
* Security scan report types, as provided by the backend.
*/
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
/**
* SecurityReportTypeEnum values for use with GraphQL.
*
* These should correspond to the lowercase security scan report types.
*/
export const SECURITY_REPORT_TYPE_ENUM_API_FUZZING = 'API_FUZZING';
/* Override CE Definitions */
/**
* A mapping from security scan report types to SecurityReportTypeEnum values.
*/
export const reportTypeToSecurityReportTypeEnum = {
...reportTypeToSecurityReportTypeEnumCE,
[REPORT_TYPE_API_FUZZING]: SECURITY_REPORT_TYPE_ENUM_API_FUZZING,
};
/**
* A mapping from SecurityReportTypeEnum values to security scan report types.
*/
export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum);
...@@ -4,6 +4,9 @@ import { once } from 'lodash'; ...@@ -4,6 +4,9 @@ import { once } from 'lodash';
import { componentNames } from 'ee/reports/components/issue_body'; import { componentNames } from 'ee/reports/components/issue_body';
import { GlButton, GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui'; import { GlButton, GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
import FuzzingArtifactsDownload from 'ee/security_dashboard/components/fuzzing_artifacts_download.vue'; import FuzzingArtifactsDownload from 'ee/security_dashboard/components/fuzzing_artifacts_download.vue';
import ArtifactDownload from 'ee/vue_shared/security_reports/components/artifact_download.vue';
import { LOADING } from '~/reports/constants';
import { securityReportTypeEnumToReportType } from './constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReportSection from '~/reports/components/report_section.vue'; import ReportSection from '~/reports/components/report_section.vue';
import SummaryRow from '~/reports/components/summary_row.vue'; import SummaryRow from '~/reports/components/summary_row.vue';
...@@ -31,6 +34,7 @@ import { ...@@ -31,6 +34,7 @@ import {
export default { export default {
store: createStore(), store: createStore(),
components: { components: {
ArtifactDownload,
GroupedIssuesList, GroupedIssuesList,
ReportSection, ReportSection,
SummaryRow, SummaryRow,
...@@ -226,6 +230,14 @@ export default { ...@@ -226,6 +230,14 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
targetProjectFullPath: {
type: String,
required: true,
},
mrIid: {
type: Number,
required: true,
},
}, },
componentNames, componentNames,
computed: { computed: {
...@@ -330,6 +342,9 @@ export default { ...@@ -330,6 +342,9 @@ export default {
hasSecretDetectionIssues() { hasSecretDetectionIssues() {
return this.hasIssuesForReportType(MODULE_SECRET_DETECTION); return this.hasIssuesForReportType(MODULE_SECRET_DETECTION);
}, },
shouldShowDownloadGuidance() {
return this.targetProjectFullPath && this.mrIid && this.summaryStatus !== LOADING;
},
}, },
created() { created() {
...@@ -437,6 +452,7 @@ export default { ...@@ -437,6 +452,7 @@ export default {
}, },
}, },
summarySlots: ['success', 'error', 'loading'], summarySlots: ['success', 'error', 'loading'],
reportTypes: securityReportTypeEnumToReportType,
}; };
</script> </script>
<template> <template>
...@@ -654,6 +670,13 @@ export default { ...@@ -654,6 +670,13 @@ export default {
<template #summary> <template #summary>
<security-summary :message="groupedApiFuzzingText" /> <security-summary :message="groupedApiFuzzingText" />
</template> </template>
<artifact-download
v-if="shouldShowDownloadGuidance"
:report-types="[$options.reportTypes.API_FUZZING]"
:target-project-full-path="targetProjectFullPath"
:mr-iid="mrIid"
/>
</summary-row> </summary-row>
<grouped-issues-list <grouped-issues-list
......
---
title: Add API Fuzzing Artifact Download in MR widget
merge_request: 49780
author:
type: added
...@@ -928,6 +928,7 @@ describe('ee merge request widget options', () => { ...@@ -928,6 +928,7 @@ describe('ee merge request widget options', () => {
beforeEach(() => { beforeEach(() => {
gl.mrWidgetData = { gl.mrWidgetData = {
...mockData, ...mockData,
target_project_full_path: '',
enabled_reports: { enabled_reports: {
api_fuzzing: true, api_fuzzing: true,
}, },
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import {
expectedDownloadDropdownProps,
securityReportDownloadPathsQueryResponse,
} from 'jest/vue_shared/security_reports/mock_data';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from 'ee/vue_shared/security_reports/constants';
import Component from 'ee/vue_shared/security_reports/components/artifact_download.vue';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import createFlash from '~/flash';
import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
jest.mock('~/flash');
describe('Artifact Download', () => {
let wrapper;
const defaultProps = {
reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
targetProjectFullPath: '/path',
mrIid: 123,
};
const createWrapper = ({ propsData, options }) => {
wrapper = shallowMount(Component, {
stubs: {
SecurityReportDownloadDropdown,
},
propsData: {
...defaultProps,
...propsData,
},
...options,
});
};
const pendingHandler = () => new Promise(() => {});
const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse });
const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] });
const createMockApolloProvider = (handler) => {
Vue.use(VueApollo);
const requestHandlers = [[securityReportDownloadPathsQuery, handler]];
return createMockApollo(requestHandlers);
};
const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('given the query is loading', () => {
beforeEach(() => {
createWrapper({
options: {
apolloProvider: createMockApolloProvider(pendingHandler),
},
});
});
it('loading is true', () => {
expect(findDownloadDropdown().props('loading')).toBe(true);
});
});
describe('given the query loads successfully', () => {
beforeEach(() => {
createWrapper({
options: {
apolloProvider: createMockApolloProvider(successHandler),
},
});
});
it('renders the download dropdown', () => {
expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
});
describe('given the query fails', () => {
beforeEach(() => {
createWrapper({
options: {
apolloProvider: createMockApolloProvider(failureHandler),
},
});
});
it('calls createFlash correctly', () => {
expect(createFlash).toHaveBeenCalledWith({
message: Component.i18n.apiError,
captureError: true,
error: expect.any(Error),
});
});
it('renders nothing', () => {
expect(findDownloadDropdown().props('artifacts')).toEqual([]);
});
});
});
...@@ -56,7 +56,9 @@ describe('Grouped security reports app', () => { ...@@ -56,7 +56,9 @@ describe('Grouped security reports app', () => {
apiFuzzingHelpPath: 'path', apiFuzzingHelpPath: 'path',
pipelineId: 123, pipelineId: 123,
projectId: 321, projectId: 321,
mrIid: 123,
projectFullPath: 'path', projectFullPath: 'path',
targetProjectFullPath: 'path',
apiFuzzingComparisonPath: API_FUZZING_DIFF_ENDPOINT, apiFuzzingComparisonPath: API_FUZZING_DIFF_ENDPOINT,
containerScanningComparisonPath: CONTAINER_SCANNING_DIFF_ENDPOINT, containerScanningComparisonPath: CONTAINER_SCANNING_DIFF_ENDPOINT,
coverageFuzzingComparisonPath: COVERAGE_FUZZING_DIFF_ENDPOINT, coverageFuzzingComparisonPath: COVERAGE_FUZZING_DIFF_ENDPOINT,
...@@ -77,6 +79,15 @@ describe('Grouped security reports app', () => { ...@@ -77,6 +79,15 @@ describe('Grouped security reports app', () => {
const createWrapper = (propsData, options, provide) => { const createWrapper = (propsData, options, provide) => {
wrapper = mount(GroupedSecurityReportsApp, { wrapper = mount(GroupedSecurityReportsApp, {
propsData, propsData,
mocks: {
$apollo: {
queries: {
reportArtifacts: {
loading: false,
},
},
},
},
data() { data() {
return { return {
dastSummary: defaultDastSummary, dastSummary: defaultDastSummary,
...@@ -401,6 +412,8 @@ describe('Grouped security reports app', () => { ...@@ -401,6 +412,8 @@ describe('Grouped security reports app', () => {
headBlobPath: 'path', headBlobPath: 'path',
pipelinePath, pipelinePath,
projectFullPath: 'path', projectFullPath: 'path',
targetProjectFullPath: 'path',
mrIid: 123,
}); });
}); });
......
...@@ -392,6 +392,33 @@ export const securityReportDownloadPathsQueryResponse = { ...@@ -392,6 +392,33 @@ export const securityReportDownloadPathsQueryResponse = {
}, },
__typename: 'CiJob', __typename: 'CiJob',
}, },
{
name: 'all_artifacts',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=archive',
fileType: 'ARCHIVE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=metadata',
fileType: 'METADATA',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
], ],
__typename: 'CiJobConnection', __typename: 'CiJobConnection',
}, },
...@@ -435,3 +462,51 @@ export const expectedDownloadDropdownProps = { ...@@ -435,3 +462,51 @@ export const expectedDownloadDropdownProps = {
loading: false, loading: false,
artifacts: [...secretDetectionArtifacts, ...sastArtifacts], artifacts: [...secretDetectionArtifacts, ...sastArtifacts],
}; };
/**
* These correspond to any jobs with zip archives in the securityReportDownloadPathsQueryResponse above.
*/
export const archiveArtifacts = [
{
name: 'all_artifacts Archive',
path: '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=archive',
reportType: 'ARCHIVE',
},
];
/**
* These correspond to any jobs with trace data in the securityReportDownloadPathsQueryResponse above.
*/
export const traceArtifacts = [
{
name: 'secret_detection Trace',
path: '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace',
reportType: 'TRACE',
},
{
name: 'bandit-sast Trace',
path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace',
reportType: 'TRACE',
},
{
name: 'eslint-sast Trace',
path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace',
reportType: 'TRACE',
},
{
name: 'all_artifacts Trace',
path: '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=trace',
reportType: 'TRACE',
},
];
/**
* These correspond to any jobs with metadata data in the securityReportDownloadPathsQueryResponse above.
*/
export const metadataArtifacts = [
{
name: 'all_artifacts Metadata',
path: '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=metadata',
reportType: 'METADATA',
},
];
...@@ -2,11 +2,15 @@ import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/ut ...@@ -2,11 +2,15 @@ import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/ut
import { import {
REPORT_TYPE_SAST, REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_SECRET_DETECTION,
REPORT_FILE_TYPES,
} from '~/vue_shared/security_reports/constants'; } from '~/vue_shared/security_reports/constants';
import { import {
securityReportDownloadPathsQueryResponse, securityReportDownloadPathsQueryResponse,
sastArtifacts, sastArtifacts,
secretDetectionArtifacts, secretDetectionArtifacts,
archiveArtifacts,
traceArtifacts,
metadataArtifacts,
} from './mock_data'; } from './mock_data';
describe('extractSecurityReportArtifacts', () => { describe('extractSecurityReportArtifacts', () => {
...@@ -17,6 +21,9 @@ describe('extractSecurityReportArtifacts', () => { ...@@ -17,6 +21,9 @@ describe('extractSecurityReportArtifacts', () => {
${[REPORT_TYPE_SAST]} | ${sastArtifacts} ${[REPORT_TYPE_SAST]} | ${sastArtifacts}
${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts} ${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts}
${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]} ${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]}
${[REPORT_FILE_TYPES.ARCHIVE]} | ${archiveArtifacts}
${[REPORT_FILE_TYPES.TRACE]} | ${traceArtifacts}
${[REPORT_FILE_TYPES.METADATA]} | ${metadataArtifacts}
`( `(
'returns the expected artifacts given report types $reportTypes', 'returns the expected artifacts given report types $reportTypes',
({ reportTypes, expectedArtifacts }) => { ({ reportTypes, expectedArtifacts }) => {
......
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