Commit 254a5576 authored by Mark Florian's avatar Mark Florian Committed by David O'Regan

Add artifacts dropdown to security MR widget

This implements the report artifacts download dropdown on the Core
security MR widget, for
https://gitlab.com/gitlab-org/gitlab/-/issues/249544.

This is implemented behind the `core_security_mr_widget_downloads`
feature flag, whose rollout is tracked by
https://gitlab.com/gitlab-org/gitlab/-/issues/273418.
parent 903d91af
......@@ -469,6 +469,8 @@ export default {
:pipeline-id="mr.pipeline.id"
:project-id="mr.sourceProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
:target-project-full-path="mr.targetProjectFullPath"
:mr-iid="mr.iid"
/>
<grouped-test-reports-app
......
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
name: 'SecurityReportDownloadDropdown',
components: {
GlDropdown,
GlDropdownItem,
},
props: {
artifacts: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
artifactText({ name }) {
return sprintf(s__('SecurityReports|Download %{artifactName}'), {
artifactName: name,
});
},
},
};
</script>
<template>
<gl-dropdown
:text="s__('SecurityReports|Download results')"
:loading="loading"
icon="download"
right
>
<gl-dropdown-item
v-for="artifact in artifacts"
:key="artifact.path"
:href="artifact.path"
download
>
{{ artifactText(artifact) }}
</gl-dropdown-item>
</gl-dropdown>
</template>
import { invert } from 'lodash';
export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
......@@ -7,3 +9,24 @@ export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
*/
export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
/**
* SecurityReportTypeEnum values for use with GraphQL.
*
* These should correspond to the lowercase security scan report types.
*/
export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST';
export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION';
/**
* A mapping from security scan report types to SecurityReportTypeEnum values.
*/
export const reportTypeToSecurityReportTypeEnum = {
[REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST,
[REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION,
};
/**
* A mapping from SecurityReportTypeEnum values to security scan report types.
*/
export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum);
query securityReportDownloadPaths(
$projectPath: ID!
$iid: String!
$reportTypes: [SecurityReportTypeEnum!]
) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
headPipeline {
jobs(securityReportTypes: $reportTypes) {
nodes {
name
artifacts {
nodes {
downloadPath
fileType
}
}
}
}
}
}
}
}
......@@ -8,10 +8,17 @@ import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import Api from '~/api';
import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue';
import SecuritySummary from './components/security_summary.vue';
import store from './store';
import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
reportTypeToSecurityReportTypeEnum,
} from './constants';
import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql';
import { extractSecurityReportArtifacts } from './utils';
export default {
store,
......@@ -20,6 +27,7 @@ export default {
GlLink,
GlSprintf,
ReportSection,
SecurityReportDownloadDropdown,
SecuritySummary,
},
mixins: [glFeatureFlagsMixin()],
......@@ -46,6 +54,16 @@ export default {
required: false,
default: '',
},
targetProjectFullPath: {
type: String,
required: false,
default: '',
},
mrIid: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
......@@ -60,8 +78,44 @@ export default {
status: ERROR,
};
},
apollo: {
reportArtifacts: {
query: securityReportDownloadPathsQuery,
variables() {
return {
projectPath: this.targetProjectFullPath,
iid: String(this.mrIid),
reportTypes: this.$options.reportTypes.map(
reportType => reportTypeToSecurityReportTypeEnum[reportType],
),
};
},
skip() {
return !this.canShowDownloads;
},
update(data) {
return extractSecurityReportArtifacts(this.$options.reportTypes, data);
},
error(error) {
this.showError(error);
},
result({ loading }) {
if (loading) {
return;
}
// Query has completed, so populate the availableSecurityReports.
this.onCheckingAvailableSecurityReports(
this.reportArtifacts.map(({ reportType }) => reportType),
);
},
},
},
computed: {
...mapGetters(['groupedSummaryText', 'summaryStatus']),
canShowDownloads() {
return this.glFeatures.coreSecurityMrWidgetDownloads;
},
hasSecurityReports() {
return this.availableSecurityReports.length > 0;
},
......@@ -71,23 +125,26 @@ export default {
hasSecretDetectionReports() {
return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION);
},
isLoaded() {
return this.summaryStatus !== LOADING;
isLoadingReportArtifacts() {
return this.$apollo.queries.reportArtifacts.loading;
},
shouldShowDownloadGuidance() {
return !this.canShowDownloads && this.summaryStatus !== LOADING;
},
scansHaveRunMessage() {
return this.canShowDownloads
? this.$options.i18n.scansHaveRun
: this.$options.i18n.scansHaveRunWithDownloadGuidance;
},
},
created() {
this.checkAvailableSecurityReports(this.$options.reportTypes)
.then(availableSecurityReports => {
this.availableSecurityReports = Array.from(availableSecurityReports);
this.fetchCounts();
})
.catch(error => {
createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
});
if (!this.canShowDownloads) {
this.checkAvailableSecurityReports(this.$options.reportTypes)
.then(availableSecurityReports => {
this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports));
})
.catch(this.showError);
}
},
methods: {
...mapActions(MODULE_SAST, {
......@@ -150,13 +207,25 @@ export default {
window.mrTabs.tabShown('pipelines');
}
},
onCheckingAvailableSecurityReports(availableSecurityReports) {
this.availableSecurityReports = availableSecurityReports;
this.fetchCounts();
},
showError(error) {
createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
},
},
reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
scansHaveRun: s__(
scansHaveRun: s__('SecurityReports|Security scans have run'),
scansHaveRunWithDownloadGuidance: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
downloadFromPipelineTab: s__(
......@@ -190,7 +259,7 @@ export default {
</span>
</template>
<template v-if="isLoaded" #sub-heading>
<template v-if="shouldShowDownloadGuidance" #sub-heading>
<span class="gl-font-sm">
<gl-sprintf :message="$options.i18n.downloadFromPipelineTab">
<template #link="{ content }">
......@@ -204,6 +273,13 @@ export default {
</gl-sprintf>
</span>
</template>
<template v-if="canShowDownloads" #action-buttons>
<security-report-download-dropdown
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
</report-section>
<!-- TODO: Remove this section when removing core_security_mr_widget_counts
......@@ -216,7 +292,7 @@ export default {
data-testid="security-mr-widget"
>
<template #error>
<gl-sprintf :message="$options.i18n.scansHaveRun">
<gl-sprintf :message="scansHaveRunMessage">
<template #link="{ content }">
<gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
content
......@@ -233,5 +309,12 @@ export default {
<gl-icon name="question" />
</gl-link>
</template>
<template v-if="canShowDownloads" #action-buttons>
<security-report-download-dropdown
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
</report-section>
</template>
import { securityReportTypeEnumToReportType } from './constants';
export const extractSecurityReportArtifacts = (reportTypes, data) => {
const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
return jobs.reduce((acc, job) => {
const artifacts = job.artifacts?.nodes ?? [];
artifacts.forEach(({ downloadPath, fileType }) => {
const reportType = securityReportTypeEnumToReportType[fileType];
if (reportType && reportTypes.includes(reportType)) {
acc.push({
name: job.name,
reportType,
path: downloadPath,
});
}
});
return acc;
}, []);
};
......@@ -40,6 +40,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
push_frontend_feature_flag(:core_security_mr_widget_downloads, @project)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:test_failure_history, @project)
push_frontend_feature_flag(:diffs_gradual_load, @project)
......
---
name: core_security_mr_widget_downloads
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48769
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/273418
milestone: '13.7'
type: development
group: group::static analysis
default_enabled: false
......@@ -315,6 +315,8 @@ export default {
:security-reports-docs-path="mr.securityReportsDocsPath"
:sast-comparison-path="mr.sastComparisonPath"
:secret-scanning-comparison-path="mr.secretScanningComparisonPath"
:target-project-full-path="mr.targetProjectFullPath"
:mr-iid="mr.iid"
/>
<grouped-security-reports-app
v-else-if="shouldRenderExtendedSecurityReport"
......
......@@ -24386,9 +24386,15 @@ msgstr ""
msgid "SecurityReports|Dismissed '%{vulnerabilityName}'. Turn off the hide dismissed toggle to view."
msgstr ""
msgid "SecurityReports|Download %{artifactName}"
msgstr ""
msgid "SecurityReports|Download Report"
msgstr ""
msgid "SecurityReports|Download results"
msgstr ""
msgid "SecurityReports|Either you don't have permission to view this dashboard or the dashboard has not been setup. Please check your permission settings with your administrator or check your dashboard configurations to proceed."
msgstr ""
......@@ -24473,6 +24479,9 @@ msgstr ""
msgid "SecurityReports|Security reports help page link"
msgstr ""
msgid "SecurityReports|Security scans have run"
msgstr ""
msgid "SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
......
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
describe('SecurityReportDownloadDropdown component', () => {
let wrapper;
let artifacts;
const createComponent = props => {
wrapper = shallowMount(SecurityReportDownloadDropdown, {
propsData: { ...props },
});
};
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('given report artifacts', () => {
beforeEach(() => {
artifacts = [
{
name: 'foo',
path: '/foo.json',
},
{
name: 'bar',
path: '/bar.json',
},
];
createComponent({ artifacts });
});
it('renders a dropdown', () => {
expect(findDropdown().props('loading')).toBe(false);
});
it('renders a dropdown items for each artifact', () => {
artifacts.forEach((artifact, i) => {
const item = findDropdownItems().at(i);
expect(item.text()).toContain(artifact.name);
expect(item.attributes()).toMatchObject({
href: artifact.path,
download: expect.any(String),
});
});
});
});
describe('given it is loading', () => {
beforeEach(() => {
createComponent({ artifacts: [], loading: true });
});
it('renders a loading dropdown', () => {
expect(findDropdown().props('loading')).toBe(true);
});
});
});
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
export const mockFindings = [
{
id: null,
......@@ -316,3 +321,117 @@ export const secretScanningDiffSuccessMock = {
base_report_out_of_date: false,
head_report_created_at: '2020-01-10T10:00:00.000Z',
};
export const securityReportDownloadPathsQueryResponse = {
project: {
mergeRequest: {
headPipeline: {
id: 'gid://gitlab/Ci::Pipeline/176',
jobs: {
nodes: [
{
name: 'secret_detection',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
fileType: 'SECRET_DETECTION',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
{
name: 'bandit-sast',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
fileType: 'SAST',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
{
name: 'eslint-sast',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
fileType: 'SAST',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
],
__typename: 'CiJobConnection',
},
__typename: 'Pipeline',
},
__typename: 'MergeRequest',
},
__typename: 'Project',
},
};
/**
* These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above.
*/
export const sastArtifacts = [
{
name: 'bandit-sast',
reportType: REPORT_TYPE_SAST,
path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
},
{
name: 'eslint-sast',
reportType: REPORT_TYPE_SAST,
path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
},
];
/**
* These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above.
*/
export const secretDetectionArtifacts = [
{
name: 'secret_detection',
reportType: REPORT_TYPE_SECRET_DETECTION,
path:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
},
];
export const expectedDownloadDropdownProps = {
loading: false,
artifacts: [...secretDetectionArtifacts, ...sastArtifacts],
};
import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
expectedDownloadDropdownProps,
securityReportDownloadPathsQueryResponse,
sastDiffSuccessMock,
secretScanningDiffSuccessMock,
} from 'jest/vue_shared/security_reports/mock_data';
......@@ -15,7 +19,9 @@ import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
jest.mock('~/flash');
......@@ -47,8 +53,20 @@ describe('Security reports app', () => {
);
};
const pendingHandler = () => new Promise(() => {});
const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse });
const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] });
const createMockApolloProvider = handler => {
localVue.use(VueApollo);
const requestHandlers = [[securityReportDownloadPathsQuery, handler]];
return createMockApollo(requestHandlers);
};
const anyParams = expect.any(Object);
const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMockJobArtifact = reportType => {
......@@ -103,7 +121,9 @@ describe('Security reports app', () => {
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
expect(wrapper.text()).toMatchInterpolatedText(
SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
);
});
describe('clicking the anchor to the pipelines tab', () => {
......@@ -172,7 +192,9 @@ describe('Security reports app', () => {
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
expect(wrapper.text()).toMatchInterpolatedText(
SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
);
});
});
......@@ -320,4 +342,118 @@ describe('Security reports app', () => {
},
);
});
describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => {
const createComponentWithFlagEnabled = options =>
createComponent(
merge(options, {
provide: {
glFeatures: {
coreSecurityMrWidgetDownloads: true,
},
},
}),
);
describe('given the query is loading', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(pendingHandler),
});
});
// TODO: Remove this assertion as part of
// https://gitlab.com/gitlab-org/gitlab/-/issues/273431
it('initially renders nothing', () => {
expect(wrapper.isEmpty()).toBe(true);
});
});
describe('given the query loads successfully', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(successHandler),
});
});
it('renders the download dropdown', () => {
expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
it('renders the expected message', () => {
const text = wrapper.text();
expect(text).not.toContain(SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance);
expect(text).toContain(SecurityReportsApp.i18n.scansHaveRun);
});
it('should not render the pipeline tab anchor', () => {
expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
describe('given the query fails', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(failureHandler),
});
});
it('calls createFlash correctly', () => {
expect(createFlash).toHaveBeenCalledWith({
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error: expect.any(Error),
});
});
// TODO: Remove this assertion as part of
// https://gitlab.com/gitlab-org/gitlab/-/issues/273431
it('renders nothing', () => {
expect(wrapper.isEmpty()).toBe(true);
});
});
});
describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(SAST_COMPARISON_PATH).replyOnce(200, sastDiffSuccessMock);
mock.onGet(SECRET_SCANNING_COMPARISON_PATH).replyOnce(200, secretScanningDiffSuccessMock);
createComponent({
propsData: {
sastComparisonPath: SAST_COMPARISON_PATH,
secretScanningComparisonPath: SECRET_SCANNING_COMPARISON_PATH,
},
provide: {
glFeatures: {
coreSecurityMrWidgetCounts: true,
coreSecurityMrWidgetDownloads: true,
},
},
apolloProvider: createMockApolloProvider(successHandler),
});
return waitForPromises();
});
afterEach(() => {
mock.restore();
});
it('renders the download dropdown', () => {
expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
it('renders the expected counts message', () => {
expect(trimText(wrapper.text())).toContain(
'Security scanning detected 3 potential vulnerabilities 2 Critical 1 High and 0 Others',
);
});
it('should not render the pipeline tab anchor', () => {
expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
});
import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
import {
securityReportDownloadPathsQueryResponse,
sastArtifacts,
secretDetectionArtifacts,
} from './mock_data';
describe('extractSecurityReportArtifacts', () => {
it.each`
reportTypes | expectedArtifacts
${[]} | ${[]}
${['foo']} | ${[]}
${[REPORT_TYPE_SAST]} | ${sastArtifacts}
${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts}
${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]}
`(
'returns the expected artifacts given report types $reportTypes',
({ reportTypes, expectedArtifacts }) => {
expect(
extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse),
).toEqual(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