Commit 238373c5 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Mayra Cabrera

Show security reports summary in pipelines' security dashboard

- Created the :pipelines_security_report_summary feature flag
- Added GraphQL query for fetching security report summary
- Render filters with additional information
- Updated tests
parent 42fce179
......@@ -14,6 +14,7 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:junit_pipeline_view, project)
push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true)
push_frontend_feature_flag(:dag_pipeline_tab)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
end
before_action :ensure_pipeline, only: [:show]
......
......@@ -113,9 +113,10 @@ export default {
class="flex-shrink-0 js-check"
name="mobile-issue-close"
/>
<span :class="isSelected(option) ? 'prepend-left-4' : 'prepend-left-20'">{{
option.name
}}</span>
<span class="gl-white-space-nowrap gl-ml-2" :class="{ 'gl-pl-5': !isSelected(option) }">
{{ option.name }}
</span>
<slot v-bind="{ filter, option }"></slot>
</span>
</button>
</div>
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { n__ } from '~/locale';
import { camelCase } from 'lodash';
import DashboardFilter from './filter.vue';
import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
......@@ -8,11 +10,43 @@ export default {
DashboardFilter,
GlToggleVuex,
},
props: {
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapGetters('filters', ['visibleFilters']),
},
methods: {
...mapActions('filters', ['setFilter']),
/**
* This method lets us match some data coming from the API with values that are currently
* hardcoded in the frontend.
* We are considering moving the whole thing to the backend so that we can rely on a SSoT.
* https://gitlab.com/gitlab-org/gitlab/-/issues/217373
*/
getOptionEnrichedData(filter, option) {
if (filter.id === 'report_type') {
const { id: optionId } = option;
const optionData = this.securityReportSummary[camelCase(optionId)];
if (!optionData) {
return null;
}
const { vulnerabilitiesCount, scannedResourcesCount } = optionData;
const enrichedData = [];
if (vulnerabilitiesCount !== undefined) {
enrichedData.push(n__('%d vulnerability', '%d vulnerabilities', vulnerabilitiesCount));
}
if (scannedResourcesCount !== undefined) {
enrichedData.push(n__('%d url scanned', '%d urls scanned', scannedResourcesCount));
}
return enrichedData.join(', ');
}
return null;
},
},
};
</script>
......@@ -24,9 +58,19 @@ export default {
v-for="filter in visibleFilters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
:class="`js-filter-${filter.id}`"
:filter="filter"
@setFilter="setFilter"
/>
>
<template #default="{ option }">
<span
v-if="getOptionEnrichedData(filter, option)"
class="gl-text-gray-500 gl-white-space-nowrap"
>
&nbsp;({{ getOptionEnrichedData(filter, option) }})
</span>
</template>
</dashboard-filter>
<div class="ml-lg-auto p-2">
<strong>{{ s__('SecurityReports|Hide dismissed') }}</strong>
<gl-toggle-vuex
......
......@@ -2,6 +2,9 @@
import { mapActions } from 'vuex';
import { GlEmptyState } from '@gitlab/ui';
import SecurityDashboard from './security_dashboard_vuex.vue';
import { fetchPolicies } from '~/lib/graphql';
import pipelineSecurityReportSummaryQuery from '../graphql/pipeline_security_report_summary.query.graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'PipelineSecurityDashboard',
......@@ -9,6 +12,25 @@ export default {
GlEmptyState,
SecurityDashboard,
},
mixins: [glFeatureFlagsMixin()],
apollo: {
securityReportSummary: {
query: pipelineSecurityReportSummaryQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
fullPath: this.projectFullPath,
pipelineId: this.pipelineId,
};
},
update(data) {
return data?.project?.pipelines?.nodes?.[0]?.securityReportSummary;
},
skip() {
return !this.glFeatures.pipelinesSecurityReportSummary;
},
},
},
props: {
dashboardDocumentation: {
type: String,
......@@ -42,6 +64,11 @@ export default {
type: Object,
required: true,
},
projectFullPath: {
type: String,
required: false,
default: '',
},
},
created() {
this.setSourceBranch(this.sourceBranch);
......@@ -59,6 +86,7 @@ export default {
:lock-to-project="{ id: projectId }"
:pipeline-id="pipelineId"
:loading-error-illustrations="loadingErrorIllustrations"
:security-report-summary="securityReportSummary"
>
<template #emptyState>
<gl-empty-state
......
......@@ -61,6 +61,11 @@ export default {
required: false,
default: () => ({}),
},
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState('vulnerabilities', [
......@@ -163,7 +168,7 @@ export default {
<security-dashboard-layout>
<template #header>
<vulnerability-count-list v-if="shouldShowCountList" />
<filters />
<filters :security-report-summary="securityReportSummary" />
</template>
<security-dashboard-table>
......
query ($fullPath: ID!, $pipelineId: ID!) {
project(fullPath: $fullPath) {
pipelines(id:$pipelineId) {
nodes {
securityReportSummary {
dast {
vulnerabilitiesCount
scannedResourcesCount
}
sast {
scannedResourcesCount
}
containerScanning {
vulnerabilitiesCount
}
dependencyScanning {
vulnerabilitiesCount
}
}
}
}
}
}
\ No newline at end of file
......@@ -3,6 +3,7 @@ import createDashboardStore from './store';
import PipelineSecurityDashboard from './components/pipeline_security_dashboard.vue';
import { DASHBOARD_TYPES } from './store/constants';
import { LOADING_VULNERABILITIES_ERROR_CODES } from './store/modules/vulnerabilities/constants';
import apolloProvider from './graphql/provider';
export default () => {
const el = document.getElementById('js-security-report-app');
......@@ -21,6 +22,7 @@ export default () => {
vulnerabilityFeedbackHelpPath,
emptyStateUnauthorizedSvgPath,
emptyStateForbiddenSvgPath,
projectFullPath,
} = el.dataset;
const loadingErrorIllustrations = {
......@@ -30,6 +32,7 @@ export default () => {
return new Vue({
el,
apolloProvider,
store: createDashboardStore({
dashboardType: DASHBOARD_TYPES.PIPELINE,
}),
......@@ -44,6 +47,7 @@ export default () => {
dashboardDocumentation,
emptyStateSvgPath,
loadingErrorIllustrations,
projectFullPath,
},
});
},
......
......@@ -18,7 +18,8 @@
vulnerability_exports_endpoint: vulnerability_exports_endpoint_path,
vulnerability_feedback_help_path: help_page_path('user/application_security/index'),
empty_state_unauthorized_svg_path: image_path('illustrations/user-not-logged-in.svg'),
empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg') } }
empty_state_forbidden_svg_path: image_path('illustrations/lock_promotion.svg'),
project_full_path: project.path_with_namespace } }
- if pipeline.expose_license_scanning_data?
#js-tab-licenses.tab-pane
......
......@@ -8,15 +8,15 @@ describe('Filter component', () => {
const store = createStore();
const Component = Vue.extend(component);
afterEach(() => {
vm.$destroy();
});
describe('severity', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
vm.$destroy();
});
it('should display all filters', () => {
expect(vm.$el.querySelectorAll('.js-filter')).toHaveLength(3);
});
......@@ -25,4 +25,29 @@ describe('Filter component', () => {
expect(vm.$el.querySelectorAll('.js-toggle')).toHaveLength(1);
});
});
describe('Report type', () => {
const findReportTypeFilter = () => vm.$el.querySelector('.js-filter-report_type');
it.each`
dastProps | string
${{ vulnerabilitiesCount: 0, scannedResourcesCount: 123 }} | ${'(0 vulnerabilities, 123 urls scanned)'}
${{ vulnerabilitiesCount: 481, scannedResourcesCount: 0 }} | ${'(481 vulnerabilities, 0 urls scanned)'}
${{ vulnerabilitiesCount: 1, scannedResourcesCount: 1 }} | ${'(1 vulnerability, 1 url scanned)'}
${{ vulnerabilitiesCount: 321 }} | ${'(321 vulnerabilities)'}
${{ scannedResourcesCount: 890 }} | ${'(890 urls scanned)'}
${{ vulnerabilitiesCount: 0 }} | ${'(0 vulnerabilities)'}
${{ scannedResourcesCount: 0 }} | ${'(0 urls scanned)'}
`('shows security report summary $string', ({ dastProps, string }) => {
vm = mountComponentWithStore(Component, {
store,
props: {
securityReportSummary: {
dast: dastProps,
},
},
});
expect(findReportTypeFilter().textContent).toContain(string);
});
});
});
......@@ -39,6 +39,9 @@ describe('Pipeline Security Dashboard component', () => {
wrapper = shallowMount(PipelineSecurityDashboard, {
localVue,
store,
data() {
return { securityReportSummary: {} };
},
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
......
......@@ -219,6 +219,16 @@ msgid_plural "%d unresolved threads"
msgstr[0] ""
msgstr[1] ""
msgid "%d url scanned"
msgid_plural "%d urls scanned"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability"
msgid_plural "%d vulnerabilities"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability dismissed"
msgid_plural "%d vulnerabilities dismissed"
msgstr[0] ""
......
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