Commit 50e9c769 authored by Mark Florian's avatar Mark Florian Committed by Vitaly Slobodin
parent ccb66e2f
......@@ -54,6 +54,7 @@ const Api = {
releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
pipelinesPath: '/api/:version/projects/:id/pipelines/',
createPipelinePath: '/api/:version/projects/:id/pipeline',
......@@ -599,6 +600,14 @@ const Api = {
return axios.get(url);
},
pipelineJobs(projectId, pipelineId) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectId))
.replace(':pipeline_id', encodeURIComponent(pipelineId));
return axios.get(url);
},
// Return all pipelines for a project or filter by query params
pipelines(id, options = {}) {
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
......
......@@ -86,6 +86,7 @@ export default {
TerraformPlan,
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
},
apollo: {
state: {
......@@ -179,6 +180,9 @@ export default {
this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
);
},
shouldRenderSecurityReport() {
return Boolean(window.gon?.features?.coreSecurityMrWidget && this.mr.pipeline.id);
},
mergeError() {
let { mergeError } = this.mr;
......@@ -456,6 +460,13 @@ export default {
:codequality-help-path="mr.codequalityHelpPath"
/>
<security-reports-app
v-if="shouldRenderSecurityReport"
:pipeline-id="mr.pipeline.id"
:project-id="mr.targetProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
/>
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"
......
......@@ -232,6 +232,7 @@ export default class MergeRequestStore {
this.userCalloutsPath = data.user_callouts_path;
this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id;
this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline;
this.securityReportsDocsPath = data.security_reports_docs_path;
// codeclimate
const blobPath = data.blob_path || {};
......
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import ReportSection from '~/reports/components/report_section.vue';
import { status } from '~/reports/constants';
import { s__ } from '~/locale';
import Flash from '~/flash';
import Api from '~/api';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
ReportSection,
},
props: {
pipelineId: {
type: Number,
required: true,
},
projectId: {
type: Number,
required: true,
},
securityReportsDocsPath: {
type: String,
required: true,
},
},
data() {
return {
hasSecurityReports: false,
// Error state is shown even when successfully loaded, since success
// state suggests that the security scans detected no security problems,
// which is not necessarily the case. A future iteration will actually
// check whether problems were found and display the appropriate status.
status: status.ERROR,
};
},
created() {
this.checkHasSecurityReports(this.$options.reportTypes)
.then(hasSecurityReports => {
this.hasSecurityReports = hasSecurityReports;
})
.catch(error => {
Flash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
});
},
methods: {
checkHasSecurityReports(reportTypes) {
return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
jobs.some(({ artifacts = [] }) =>
artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
),
);
},
activatePipelinesTab() {
if (window.mrTabs) {
window.mrTabs.tabShown('pipelines');
}
},
},
reportTypes: ['sast', 'secret_detection'],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
scansHaveRun: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
securityReportsHelp: s__('SecurityReports|Security reports help page link'),
},
};
</script>
<template>
<report-section
v-if="hasSecurityReports"
:status="status"
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
>
<template #error>
<gl-sprintf :message="$options.i18n.scansHaveRun">
<template #link="{ content }">
<gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
content
}}</gl-link>
</template>
</gl-sprintf>
<gl-link
target="_blank"
data-testid="help"
:href="securityReportsDocsPath"
:aria-label="$options.i18n.securityReportsHelp"
>
<gl-icon name="question" />
</gl-link>
</template>
</report-section>
</template>
......@@ -39,6 +39,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true)
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project)
end
before_action do
......
......@@ -124,6 +124,10 @@ class MergeRequestWidgetEntity < Grape::Entity
end
end
expose :security_reports_docs_path do |merge_request|
help_page_path('user/application_security/sast/index.md', anchor: 'reports-json-format')
end
private
delegate :current_user, to: :request
......
---
name: core_security_mr_widget
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44639
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/249543
type: development
group: group::static analysis
default_enabled: false
......@@ -23008,6 +23008,9 @@ msgstr ""
msgid "SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again."
msgstr ""
msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later."
msgstr ""
msgid "SecurityReports|False positive"
msgstr ""
......@@ -23071,6 +23074,12 @@ msgstr ""
msgid "SecurityReports|Security reports can only be accessed by authorized users."
msgstr ""
msgid "SecurityReports|Security reports help page link"
msgstr ""
msgid "SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
msgid "SecurityReports|Select a project to add by using the project search field above."
msgstr ""
......
......@@ -672,6 +672,27 @@ describe('Api', () => {
});
});
describe('pipelineJobs', () => {
it('fetches the jobs for a given pipeline', done => {
const projectId = 123;
const pipelineId = 456;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
const payload = [
{
name: 'test',
},
];
mock.onGet(expectedUrl).reply(httpStatus.OK, payload);
Api.pipelineJobs(projectId, pipelineId)
.then(({ data }) => {
expect(data).toEqual(payload);
})
.then(done)
.catch(done.fail);
});
});
describe('createBranch', () => {
it('creates new branch', done => {
const ref = 'master';
......
......@@ -262,6 +262,7 @@ export default {
merge_trains_enabled: true,
merge_trains_count: 3,
merge_train_index: 1,
security_reports_docs_path: 'security-reports-docs-path',
};
export const mockStore = {
......
......@@ -2,6 +2,7 @@ import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
import { withGonExperiment } from 'helpers/experimentation_helper';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
......@@ -51,13 +52,13 @@ describe('mrWidgetOptions', () => {
gon.features = {};
});
const createComponent = () => {
const createComponent = (mrData = mockData) => {
if (vm) {
vm.$destroy();
}
vm = mountComponent(MrWidgetOptions, {
mrData: { ...mockData },
mrData: { ...mrData },
});
return axios.waitForAll();
......@@ -65,6 +66,7 @@ describe('mrWidgetOptions', () => {
const findSuggestPipeline = () => vm.$el.querySelector('[data-testid="mr-suggest-pipeline"]');
const findSuggestPipelineButton = () => findSuggestPipeline().querySelector('button');
const findSecurityMrWidget = () => vm.$el.querySelector('[data-testid="security-mr-widget"]');
describe('default', () => {
beforeEach(() => {
......@@ -813,6 +815,41 @@ describe('mrWidgetOptions', () => {
});
});
describe('security widget', () => {
describe.each`
context | hasPipeline | reportType | isFlagEnabled | shouldRender
${'security report and flag enabled'} | ${true} | ${'sast'} | ${true} | ${true}
${'security report and flag disabled'} | ${true} | ${'sast'} | ${false} | ${false}
${'no security report and flag enabled'} | ${true} | ${'foo'} | ${true} | ${false}
${'no pipeline and flag enabled'} | ${false} | ${'sast'} | ${true} | ${false}
`('given $context', ({ hasPipeline, reportType, isFlagEnabled, shouldRender }) => {
beforeEach(() => {
gon.features.coreSecurityMrWidget = isFlagEnabled;
if (hasPipeline) {
jest.spyOn(Api, 'pipelineJobs').mockResolvedValue({
data: [{ artifacts: [{ file_type: reportType }] }],
});
}
return createComponent({
...mockData,
...(hasPipeline ? {} : { pipeline: undefined }),
});
});
if (shouldRender) {
it('renders', () => {
expect(findSecurityMrWidget()).toEqual(expect.any(HTMLElement));
});
} else {
it('does not render', () => {
expect(findSecurityMrWidget()).toBeNull();
});
}
});
});
describe('suggestPipeline Experiment', () => {
beforeEach(() => {
mock.onAny().reply(200);
......
......@@ -118,27 +118,33 @@ describe('MergeRequestStore', () => {
describe('setPaths', () => {
it('should set the add ci config path', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline');
});
it('should set humanAccess=Maintainer when user has that role', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.humanAccess).toBe('Maintainer');
});
it('should set pipelinesEmptySvgPath', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
});
it('should set newPipelinePath', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new');
});
it('should set securityReportsDocsPath', () => {
store.setPaths({ ...mockData });
expect(store.securityReportsDocsPath).toBe('security-reports-docs-path');
});
});
});
import { mount } from '@vue/test-utils';
import Api from '~/api';
import Flash from '~/flash';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
jest.mock('~/flash');
describe('Grouped security reports app', () => {
let wrapper;
let mrTabsMock;
const props = {
pipelineId: 123,
projectId: 456,
securityReportsDocsPath: '/docs',
};
const createComponent = () => {
wrapper = mount(SecurityReportsApp, {
propsData: { ...props },
});
};
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMrTabsMock = () => {
mrTabsMock = { tabShown: jest.fn() };
window.mrTabs = mrTabsMock;
};
const setupMockJobArtifact = reportType => {
jest
.spyOn(Api, 'pipelineJobs')
.mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] });
};
afterEach(() => {
wrapper.destroy();
delete window.mrTabs;
});
describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
beforeEach(() => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
describe('clicking the anchor to the pipelines tab', () => {
beforeEach(() => {
setupMrTabsMock();
findPipelinesTabAnchor().trigger('click');
});
it('calls the mrTabs.tabShown global', () => {
expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
});
});
it('renders a help link', () => {
expect(findHelpLink().attributes()).toMatchObject({
href: props.securityReportsDocsPath,
});
});
});
describe('given a report type "foo"', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
});
describe('given an error from the API', () => {
let error;
beforeEach(() => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
it('calls Flash correctly', () => {
expect(Flash.mock.calls).toEqual([
[
{
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error,
},
],
]);
});
});
});
......@@ -354,4 +354,8 @@ RSpec.describe MergeRequestWidgetEntity do
expect(entity[:rebase_path]).to be_nil
end
end
it 'has security_reports_docs_path' do
expect(subject[:security_reports_docs_path]).not_to be_nil
end
end
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