Commit b4551ff8 authored by Daniel Tian's avatar Daniel Tian Committed by Savas Vedova

Use new vulnerability report for pipeline security tab

Changelog: changed
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81760
EE: true
parent d9ff351f
<script>
import { GlAlert, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import produce from 'immer';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import VulnerabilityList from '../shared/vulnerability_list.vue';
export default {
components: {
GlAlert,
GlLoadingIcon,
GlIntersectionObserver,
VulnerabilityList,
},
inject: {
groupFullPath: {},
canViewFalsePositive: {
default: false,
},
},
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
pageInfo: {},
vulnerabilities: [],
errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
};
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
variables() {
return {
fullPath: this.groupFullPath,
first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update: ({ group }) => group.vulnerabilities.nodes,
result({ data }) {
this.pageInfo = data?.group?.vulnerabilities?.pageInfo;
},
error() {
this.errorLoadingVulnerabilities = true;
},
skip() {
return !this.filters;
},
},
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.vulnerabilities.length === 0;
},
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
},
watch: {
filters() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
sort() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
},
methods: {
onErrorDismiss() {
this.errorLoadingVulnerabilities = false;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.group.vulnerabilities.nodes = [
...previousResult.group.vulnerabilities.nodes,
...draftData.group.vulnerabilities.nodes,
];
});
},
});
}
},
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
},
};
</script>
<template>
<div>
<gl-alert
v-if="errorLoadingVulnerabilities"
class="mb-4"
variant="danger"
@dismiss="onErrorDismiss"
>
{{
s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<vulnerability-list
v-else
:filters="filters"
:is-loading="isLoadingFirstResult"
:vulnerabilities="vulnerabilities"
should-show-project-namespace
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingQuery" size="md" />
</gl-intersection-observer>
</div>
</template>
<script>
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/instance_vulnerabilities.query.graphql';
import { preparePageInfo } from 'ee/security_dashboard/helpers';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import { fetchPolicies } from '~/lib/graphql';
import VulnerabilityList from '../shared/vulnerability_list.vue';
export default {
components: {
GlAlert,
GlIntersectionObserver,
GlLoadingIcon,
VulnerabilityList,
},
inject: {
canViewFalsePositive: {
default: false,
},
},
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
pageInfo: {},
vulnerabilities: [],
errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
};
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.vulnerabilities.length === 0;
},
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return {
first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update: ({ vulnerabilities }) => vulnerabilities.nodes,
result({ data }) {
this.pageInfo = preparePageInfo(data?.vulnerabilities?.pageInfo);
},
error() {
this.errorLoadingVulnerabilities = true;
},
skip() {
return !this.filters;
},
},
},
watch: {
filters() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.pageInfo = {};
},
sort() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
},
methods: {
onErrorDismiss() {
this.errorLoadingVulnerabilities = false;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.vulnerabilities.nodes = [
...previousResult.vulnerabilities.nodes,
...draftData.vulnerabilities.nodes,
];
});
},
});
}
},
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
},
};
</script>
<template>
<div>
<gl-alert
v-if="errorLoadingVulnerabilities"
class="mb-4"
variant="danger"
@dismiss="onErrorDismiss"
>
{{
s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<vulnerability-list
v-else
:filters="filters"
:is-loading="isLoadingFirstResult"
:vulnerabilities="vulnerabilities"
should-show-project-namespace
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingQuery" size="md" />
</gl-intersection-observer>
</div>
</template>
<script>
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { produce } from 'immer';
import findingsQuery from 'ee/security_dashboard/graphql/queries/pipeline_findings.query.graphql';
import { preparePageInfo } from 'ee/security_dashboard/helpers';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import VulnerabilityList from '../shared/vulnerability_list.vue';
import VulnerabilityFindingModal from './vulnerability_finding_modal.vue';
export default {
name: 'PipelineFindings',
components: {
VulnerabilityFindingModal,
GlAlert,
GlIntersectionObserver,
GlLoadingIcon,
VulnerabilityList,
},
inject: {
pipeline: {},
projectFullPath: {},
canViewFalsePositive: {
default: false,
},
},
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
pageInfo: {},
findings: [],
errorLoadingFindings: false,
sortBy: 'severity',
sortDirection: 'desc',
modalFinding: undefined,
};
},
computed: {
isLoadingQuery() {
return this.$apollo.queries.findings.loading;
},
isLoadingFirstResult() {
return this.isLoadingQuery && this.findings.length === 0;
},
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
},
apollo: {
findings: {
query: findingsQuery,
variables() {
return {
...this.filters,
pipelineId: this.pipeline.iid,
fullPath: this.projectFullPath,
vetEnabled: this.canViewFalsePositive,
first: VULNERABILITIES_PER_PAGE,
reportType: this.normalizeForGraphQLQuery('reportType'),
severity: this.normalizeForGraphQLQuery('severity'),
};
},
update: ({ project }) =>
project?.pipeline?.securityReportFindings?.nodes?.map((finding) => ({
...finding,
// vulnerabilties and findings are different but similar entities. Vulnerabilities have
// ids, findings have uuid. To make the selection work with the vulnerability list, we're
// going to massage the data and add an `id` field to the finding.
id: finding.uuid,
})),
result({ data }) {
if (!data) {
return;
}
this.pageInfo = preparePageInfo(data.project?.pipeline?.securityReportFindings?.pageInfo);
},
error() {
this.errorLoadingFindings = true;
},
skip() {
return !this.filters;
},
},
},
watch: {
filters() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.findings = [];
this.pageInfo = {};
},
sort() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.findings = [];
},
},
methods: {
// Two issues here:
// 1. Severity and reportType filters, unlike in vulnerabilities, need to be lower case.
// 2. Empty array returns an empty result, therefore we need to pass undefined in that case.
normalizeForGraphQLQuery(filterName) {
return this.filters?.[filterName]?.length
? this.filters[filterName].map((s) => s.toLowerCase())
: undefined;
},
dismissError() {
this.errorLoadingFindings = false;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.findings.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.project.pipeline.securityReportFindings.nodes = [
...previousResult.project.pipeline.securityReportFindings.nodes,
...draftData.project.pipeline.securityReportFindings.nodes,
];
});
},
});
}
},
updateSortSettings({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
showFindingModal(finding) {
this.modalFinding = finding;
},
hideFindingModal() {
this.modalFinding = undefined;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="errorLoadingFindings" class="gl-mb-6" variant="danger" @dismiss="dismissError">
{{
s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<vulnerability-list
v-else
:filters="filters"
:is-loading="isLoadingFirstResult"
:vulnerabilities="findings"
@sort-changed="updateSortSettings"
@vulnerability-clicked="showFindingModal"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="gl-text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingQuery" size="md" />
</gl-intersection-observer>
<vulnerability-finding-modal
v-if="modalFinding"
:finding="modalFinding"
@hide="hideFindingModal"
/>
</div>
</template>
...@@ -6,10 +6,10 @@ import { reportTypeToSecurityReportTypeEnum } from 'ee/vue_shared/security_repor ...@@ -6,10 +6,10 @@ import { reportTypeToSecurityReportTypeEnum } from 'ee/vue_shared/security_repor
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';
import VulnerabilityReport from '../shared/vulnerability_report.vue';
import ScanAlerts, { TYPE_ERRORS, TYPE_WARNINGS } from './scan_alerts.vue'; import ScanAlerts, { TYPE_ERRORS, TYPE_WARNINGS } from './scan_alerts.vue';
import SecurityDashboard from './security_dashboard_vuex.vue'; import SecurityDashboard from './security_dashboard_vuex.vue';
import SecurityReportsSummary from './security_reports_summary.vue'; import SecurityReportsSummary from './security_reports_summary.vue';
import PipelineVulnerabilityReport from './pipeline_vulnerability_report.vue';
export default { export default {
name: 'PipelineSecurityDashboard', name: 'PipelineSecurityDashboard',
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
ScanAlerts, ScanAlerts,
SecurityReportsSummary, SecurityReportsSummary,
SecurityDashboard, SecurityDashboard,
VulnerabilityReport, PipelineVulnerabilityReport,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
inject: [ inject: [
...@@ -159,6 +159,6 @@ export default { ...@@ -159,6 +159,6 @@ export default {
<gl-empty-state v-bind="emptyStateProps" /> <gl-empty-state v-bind="emptyStateProps" />
</template> </template>
</security-dashboard> </security-dashboard>
<vulnerability-report v-else /> <pipeline-vulnerability-report v-else />
</div> </div>
</template> </template>
<script>
import { PortalTarget } from 'portal-vue';
import findingsQuery from 'ee/security_dashboard/graphql/queries/pipeline_findings.query.graphql';
import VulnerabilityFilters from '../shared/vulnerability_report/vulnerability_filters.vue';
import VulnerabilityListGraphql from '../shared/vulnerability_report/vulnerability_list_graphql.vue';
import { FILTERS, FIELDS } from '../shared/vulnerability_report/constants';
import VulnerabilityFindingModal from './vulnerability_finding_modal.vue';
export default {
components: {
VulnerabilityFilters,
VulnerabilityListGraphql,
VulnerabilityFindingModal,
PortalTarget,
},
data() {
return {
graphqlFilters: undefined,
selectedFinding: undefined,
};
},
methods: {
// Two issues here for the pipeline GraphQL endpoint:
// 1. Severity and reportType filters, unlike for vulnerabilities, need to be lower case.
// 2. Empty array returns an empty result, so we need to pass undefined in that case.
normalizeForGraphQLQuery(filter) {
return filter?.length ? filter.map((s) => s.toLowerCase()) : undefined;
},
updateFilters(filters) {
this.graphqlFilters = {
...filters,
reportType: this.normalizeForGraphQLQuery(filters.reportType),
severity: this.normalizeForGraphQLQuery(filters.severity),
};
},
showModal(finding) {
this.selectedFinding = finding;
},
hideModal() {
this.selectedFinding = undefined;
},
},
filtersToShow: [FILTERS.STATUS, FILTERS.SEVERITY, FILTERS.TOOL_SIMPLE],
fieldsToShow: [
FIELDS.CHECKBOX,
FIELDS.STATUS,
FIELDS.SEVERITY,
FIELDS.DESCRIPTION,
FIELDS.IDENTIFIER,
FIELDS.TOOL,
],
portalName: 'pipeline-security-tab-sticky',
findingsQuery,
};
</script>
<template>
<div>
<div class="security-dashboard-filters gl-mt-7">
<vulnerability-filters :filters="$options.filtersToShow" @filters-changed="updateFilters" />
<portal-target :name="$options.portalName" />
</div>
<vulnerability-list-graphql
:query="$options.findingsQuery"
:fields="$options.fieldsToShow"
:filters="graphqlFilters"
:portal-name="$options.portalName"
@vulnerability-clicked="showModal"
/>
<vulnerability-finding-modal
v-if="selectedFinding"
:finding="selectedFinding"
@hide="hideModal"
/>
</div>
</template>
<script>
import { GlAlert, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import produce from 'immer';
import { difference } from 'lodash';
import { Portal } from 'portal-vue';
import securityScannersQuery from 'ee/security_dashboard/graphql/queries/project_security_scanners.query.graphql';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql';
import { preparePageInfo } from 'ee/security_dashboard/helpers';
import { VULNERABILITIES_PER_PAGE } from 'ee/security_dashboard/store/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { translateScannerNames } from '~/security_configuration/utils';
import VulnerabilityList from '../shared/vulnerability_list.vue';
import SecurityScannerAlert from './security_scanner_alert.vue';
export default {
name: 'ProjectVulnerabilities',
components: {
GlAlert,
GlLoadingIcon,
GlIntersectionObserver,
LocalStorageSync,
Portal,
SecurityScannerAlert,
VulnerabilityList,
},
inject: {
vulnerabilityReportAlertsPortal: {
default: '',
},
projectFullPath: {
default: '',
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
canViewFalsePositive: {
default: false,
},
},
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
pageInfo: {},
vulnerabilities: [],
scannerAlertDismissed: false,
securityScanners: {},
errorLoadingVulnerabilities: false,
sortBy: 'severity',
sortDirection: 'desc',
};
},
apollo: {
vulnerabilities: {
query: vulnerabilitiesQuery,
variables() {
return {
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
vetEnabled: this.canViewFalsePositive,
...this.filters,
};
},
update: ({ project }) => project?.vulnerabilities.nodes || [],
result({ data }) {
this.pageInfo = preparePageInfo(data?.project?.vulnerabilities?.pageInfo);
},
error() {
this.errorLoadingVulnerabilities = true;
},
skip() {
return !this.filters;
},
},
securityScanners: {
query: securityScannersQuery,
variables() {
return {
fullPath: this.projectFullPath,
};
},
error() {
this.securityScanners = {};
},
update({ project = {} }) {
const { available = [], enabled = [], pipelineRun = [] } = project?.securityScanners || {};
return {
available: translateScannerNames(available),
enabled: translateScannerNames(enabled),
pipelineRun: translateScannerNames(pipelineRun),
};
},
},
},
computed: {
isLoadingVulnerabilities() {
return this.$apollo.queries.vulnerabilities.loading;
},
isLoadingFirstVulnerabilities() {
return this.isLoadingVulnerabilities && this.vulnerabilities.length === 0;
},
sort() {
return `${this.sortBy}_${this.sortDirection}`;
},
notEnabledSecurityScanners() {
const { available = [], enabled = [] } = this.securityScanners;
return difference(available, enabled);
},
noPipelineRunSecurityScanners() {
const { enabled = [], pipelineRun = [] } = this.securityScanners;
return difference(enabled, pipelineRun);
},
shouldShowScannersAlert() {
return (
!this.scannerAlertDismissed &&
(this.notEnabledSecurityScanners.length > 0 ||
this.noPipelineRunSecurityScanners.length > 0)
);
},
},
watch: {
filters() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
sort() {
// Clear out the existing vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
},
},
methods: {
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => {
draftData.project.vulnerabilities.nodes = [
...previousResult.project.vulnerabilities.nodes,
...draftData.project.vulnerabilities.nodes,
];
});
},
});
}
},
handleSortChange({ sortBy, sortDesc }) {
this.sortDirection = sortDesc ? 'desc' : 'asc';
this.sortBy = sortBy;
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = parseBoolean(value);
},
},
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY: 'vulnerability_list_scanner_alert_dismissed',
};
</script>
<template>
<div>
<gl-alert v-if="errorLoadingVulnerabilities" :dismissible="false" variant="danger">
{{
s__(
'SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again.',
)
}}
</gl-alert>
<template v-else>
<local-storage-sync
:value="String(scannerAlertDismissed)"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<portal v-if="shouldShowScannersAlert" :to="vulnerabilityReportAlertsPortal">
<security-scanner-alert
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
/>
</portal>
<vulnerability-list
:is-loading="isLoadingFirstVulnerabilities"
:filters="filters"
:vulnerabilities="vulnerabilities"
@sort-changed="handleSortChange"
/>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingVulnerabilities" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
</template>
</div>
</template>
<script>
import { debounce, cloneDeep, isEqual } from 'lodash';
import {
stateFilter,
severityFilter,
vendorScannerFilter,
simpleScannerFilter,
activityFilter,
projectFilter,
} from 'ee/security_dashboard/helpers';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ActivityFilter from './activity_filter.vue';
import ProjectFilter from './project_filter.vue';
import ScannerFilter from './scanner_filter.vue';
import SimpleFilter from './simple_filter.vue';
export default {
components: {
SimpleFilter,
ScannerFilter,
ActivityFilter,
ProjectFilter,
},
mixins: [glFeatureFlagsMixin()],
inject: ['dashboardType'],
data() {
return {
filterQuery: {},
};
},
computed: {
isProjectDashboard() {
return this.dashboardType === DASHBOARD_TYPES.PROJECT;
},
isPipeline() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
isGroupDashboard() {
return this.dashboardType === DASHBOARD_TYPES.GROUP;
},
isInstanceDashboard() {
return this.dashboardType === DASHBOARD_TYPES.INSTANCE;
},
shouldShowProjectFilter() {
return this.isGroupDashboard || this.isInstanceDashboard;
},
},
methods: {
updateFilterQuery(query) {
const oldQuery = cloneDeep(this.filterQuery);
this.filterQuery = { ...this.filterQuery, ...query };
if (!isEqual(oldQuery, this.filterQuery)) {
this.emitFilterChange();
}
},
// When this component is first shown, every filter will emit its own @filter-changed event at
// the same time, which will trigger this method multiple times. We'll debounce it so that it
// only runs once.
emitFilterChange: debounce(function emit() {
this.$emit('filterChange', this.filterQuery);
}),
},
simpleFilters: [stateFilter, severityFilter],
vendorScannerFilter,
simpleScannerFilter,
activityFilter,
projectFilter,
};
</script>
<template>
<div
class="vulnerability-report-filters gl-p-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<simple-filter
v-for="filter in $options.simpleFilters"
:key="filter.id"
:filter="filter"
:data-testid="filter.id"
@filter-changed="updateFilterQuery"
/>
<scanner-filter
v-if="isProjectDashboard"
:filter="$options.vendorScannerFilter"
@filter-changed="updateFilterQuery"
/>
<simple-filter
v-else
:filter="$options.simpleScannerFilter"
:data-testid="$options.simpleScannerFilter.id"
@filter-changed="updateFilterQuery"
/>
<activity-filter
v-if="!isPipeline"
:filter="$options.activityFilter"
@filter-changed="updateFilterQuery"
/>
<project-filter
v-if="shouldShowProjectFilter"
:filter="$options.projectFilter"
@filter-changed="updateFilterQuery"
/>
</div>
</template>
<script>
import { GlCard } from '@gitlab/ui';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
export default {
components: {
GlCard,
SeverityBadge,
},
props: {
severity: {
type: String,
required: true,
},
count: {
type: Number,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<gl-card
class="gl-font-weight-bold"
header-class="gl-display-flex gl-justify-content-center gl-p-3"
body-class="gl-font-size-h2 gl-text-center"
>
<template #header>
<severity-badge :severity="severity" />
</template>
<template #default>
<span ref="body">
<span v-if="isLoading">&mdash;</span> <span v-else>{{ count }}</span>
</span>
</template>
</gl-card>
</template>
<script>
import vulnerabilitySeveritiesCountQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import eventHub from 'ee/security_dashboard/utils/event_hub';
import VulnerabilityCountListLayout from './vulnerability_count_list_layout.vue';
export default {
components: {
VulnerabilityCountListLayout,
},
inject: ['dashboardType', 'groupFullPath', 'projectFullPath'],
props: {
filters: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
queryError: false,
vulnerabilitiesCount: {},
};
},
computed: {
isLoading() {
return this.$apollo.queries.vulnerabilitiesCount.loading;
},
fullPath() {
return this.groupFullPath || this.projectFullPath;
},
},
created() {
eventHub.$on('vulnerabilities-updated', () =>
this.$apollo.queries.vulnerabilitiesCount.refetch(),
);
},
apollo: {
vulnerabilitiesCount: {
query: vulnerabilitySeveritiesCountQuery,
variables() {
const { dashboardType, fullPath } = this;
return {
fullPath,
isInstance: dashboardType === DASHBOARD_TYPES.INSTANCE,
isGroup: dashboardType === DASHBOARD_TYPES.GROUP,
isProject: dashboardType === DASHBOARD_TYPES.PROJECT,
...this.filters,
};
},
update(data) {
return data[this.dashboardType]?.vulnerabilitySeveritiesCount || {};
},
result() {
this.queryError = false;
},
error() {
this.queryError = true;
},
skip() {
return !this.filters;
},
},
},
};
</script>
<template>
<vulnerability-count-list-layout
:show-error="queryError"
:is-loading="isLoading"
:vulnerabilities-count="vulnerabilitiesCount"
/>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { SEVERITIES } from 'ee/security_dashboard/store/modules/vulnerabilities/constants';
import VulnerabilityCount from './vulnerability_count.vue';
export default {
components: {
VulnerabilityCount,
GlAlert,
},
props: {
showError: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
vulnerabilitiesCount: {
type: Object,
required: true,
},
},
data() {
return {
showAlert: this.showError,
};
},
computed: {
counts() {
return SEVERITIES.map((severity) => ({
severity,
count: this.vulnerabilitiesCount[severity] || 0,
}));
},
},
methods: {
onErrorDismiss() {
this.showAlert = false;
},
},
};
</script>
<template>
<div class="vulnerabilities-count-list">
<gl-alert v-if="showAlert" class="mb-4" variant="danger" @dismiss="onErrorDismiss">
{{
s__(
'SecurityReports|Error fetching the vulnerability counts. Please check your network connection and try again.',
)
}}
</gl-alert>
<div class="row">
<div v-for="count in counts" :key="count.severity" class="mb-5 col-md col-sm-6">
<vulnerability-count
:severity="count.severity"
:count="count.count"
:is-loading="isLoading"
/>
</div>
</div>
</div>
</template>
<script>
import { PortalTarget } from 'portal-vue';
import { GlLink, GlSprintf } from '@gitlab/ui';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupVulnerabilities from '../group/group_vulnerabilities.vue';
import InstanceVulnerabilities from '../instance/instance_vulnerabilities.vue';
import PipelineFindings from '../pipeline/pipeline_findings.vue';
import ProjectVulnerabilities from '../project/project_vulnerabilities.vue';
import AutoFixUserCallout from './auto_fix_user_callout.vue';
import CsvExportButton from './csv_export_button.vue';
import ReportNotConfiguredGroup from './empty_states/report_not_configured_group.vue';
import ReportNotConfiguredInstance from './empty_states/report_not_configured_instance.vue';
import ReportNotConfiguredOperational from './empty_states/report_not_configured_operational.vue';
import Filters from './filters/filters_layout.vue';
import ProjectPipelineStatus from './project_pipeline_status.vue';
import SurveyRequestBanner from './survey_request_banner.vue';
import VulnerabilitiesCountList from './vulnerability_count_list.vue';
import VulnerabilityReportLayout from './vulnerability_report_layout.vue';
export default {
components: {
AutoFixUserCallout,
VulnerabilityReportLayout,
GroupVulnerabilities,
InstanceVulnerabilities,
ProjectVulnerabilities,
PipelineFindings,
Filters,
CsvExportButton,
SurveyRequestBanner,
ReportNotConfiguredGroup,
ReportNotConfiguredInstance,
ReportNotConfiguredOperational,
PortalTarget,
ProjectPipelineStatus,
GlLink,
GlSprintf,
VulnerabilitiesCountList,
},
mixins: [glFeatureFlagsMixin()],
provide() {
return {
vulnerabilityReportAlertsPortal: this.$options.vulnerabilityReportAlertsPortal,
};
},
inject: {
dashboardType: {},
groupFullPath: { default: undefined },
autoFixDocumentation: { default: undefined },
dashboardDocumentation: { default: undefined },
pipeline: { default: undefined },
hasProjects: { default: undefined },
},
data() {
const shouldShowAutoFixUserCallout =
this.dashboardType === DASHBOARD_TYPES.PROJECT &&
this.glFeatures.securityAutoFix &&
!getCookie(this.$options.autoFixUserCalloutCookieName);
return {
filters: null,
shouldShowAutoFixUserCallout,
};
},
computed: {
isGroup() {
return this.dashboardType === DASHBOARD_TYPES.GROUP;
},
isInstance() {
return this.dashboardType === DASHBOARD_TYPES.INSTANCE;
},
isProject() {
return this.dashboardType === DASHBOARD_TYPES.PROJECT;
},
isPipeline() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
shouldShowSurvey() {
return !this.isPipeline;
},
isDashboardConfigured() {
// Projects can have manually created vulnerabilities, so we always show the report
if (this.isProject) {
return true;
}
if (this.isPipeline) {
return Boolean(this.pipeline?.id);
}
// Group and Instance Dashboards
return this.hasProjects;
},
shouldShowPipelineStatus() {
return this.isProject && Boolean(this.pipeline);
},
},
methods: {
handleFilterChange(filters) {
this.filters = filters;
},
handleAutoFixUserCalloutClose() {
setCookie(this.$options.autoFixUserCalloutCookieName, 'true');
this.shouldShowAutoFixUserCallout = false;
},
},
vulnerabilityReportAlertsPortal: 'vulnerability-report-alerts-portal',
autoFixUserCalloutCookieName: 'auto_fix_user_callout_dismissed',
i18n: {
title: s__('SecurityReports|Vulnerability Report'),
description: s__(
"SecurityReports|The Vulnerability Report shows the results of the latest successful pipeline on your project's default branch, as well as vulnerabilities from your latest container scan. %{linkStart}Learn more.%{linkEnd}",
),
},
};
</script>
<template>
<div>
<template v-if="!isDashboardConfigured">
<survey-request-banner v-if="shouldShowSurvey" class="gl-mt-5" />
<report-not-configured-group v-if="isGroup" />
<report-not-configured-instance v-else-if="isInstance" />
</template>
<template v-else>
<portal-target :name="$options.vulnerabilityReportAlertsPortal" multiple />
<auto-fix-user-callout
v-if="shouldShowAutoFixUserCallout"
:help-page-path="autoFixDocumentation"
@close="handleAutoFixUserCalloutClose"
/>
<vulnerability-report-layout>
<template v-if="!isPipeline" #header>
<survey-request-banner class="gl-mt-5" />
<header class="gl-mt-6 gl-mb-3 gl-display-flex gl-align-items-center">
<h2 class="gl-flex-grow-1 gl-my-0">
{{ $options.i18n.title }}
</h2>
<csv-export-button />
</header>
<gl-sprintf :message="$options.i18n.description">
<template #link="{ content }">
<gl-link :href="dashboardDocumentation" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</template>
<template #summary>
<project-pipeline-status
v-if="shouldShowPipelineStatus"
class="gl-mb-6"
:pipeline="pipeline"
/>
<vulnerabilities-count-list :filters="filters" />
</template>
<template #sticky>
<filters @filterChange="handleFilterChange" />
</template>
<group-vulnerabilities v-if="isGroup" :filters="filters" />
<instance-vulnerabilities v-else-if="isInstance" :filters="filters" />
<project-vulnerabilities v-else-if="isProject" :filters="filters" />
<pipeline-findings v-else-if="isPipeline" :filters="filters" />
<template #operational-empty-state>
<report-not-configured-operational />
</template>
</vulnerability-report-layout>
</template>
</div>
</template>
<script> <script>
import { GlLoadingIcon, GlIntersectionObserver, GlKeysetPagination } from '@gitlab/ui'; import { GlLoadingIcon, GlIntersectionObserver, GlKeysetPagination } from '@gitlab/ui';
import { produce } from 'immer'; import { produce } from 'immer';
import { get } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import VulnerabilityList from './vulnerability_list.vue'; import VulnerabilityList from './vulnerability_list.vue';
import { FIELDS } from './constants'; import { FIELDS } from './constants';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
// Deep searches an object for a key called 'vulnerabilities'. If it's not found, it will traverse const GRAPHQL_DATA_PATH = {
// down the object's first property until either it's found, or there's nothing left to search. Note [DASHBOARD_TYPES.PROJECT]: 'project.vulnerabilities',
// that this will only check the first property of any object, not all of them. [DASHBOARD_TYPES.GROUP]: 'group.vulnerabilities',
const deepFindVulnerabilities = (data) => { [DASHBOARD_TYPES.INSTANCE]: 'vulnerabilities',
let currentData = data; [DASHBOARD_TYPES.PIPELINE]: 'project.pipeline.securityReportFindings',
while (currentData !== undefined && currentData.vulnerabilities === undefined) {
[currentData] = Object.values(currentData);
}
return currentData?.vulnerabilities;
}; };
export default { export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList, GlKeysetPagination }, components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList, GlKeysetPagination },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'canViewFalsePositive', 'hasJiraVulnerabilitiesIntegrationEnabled'], inject: {
dashboardType: {
required: true,
},
fullPath: {
required: true,
},
canViewFalsePositive: {
default: false,
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
pipeline: {
default: {},
},
},
props: { props: {
query: { query: {
type: Object, type: Object,
...@@ -72,6 +84,7 @@ export default { ...@@ -72,6 +84,7 @@ export default {
sort: `${convertToSnakeCase(this.sort.sortBy)}_${this.sort.sortDesc ? 'desc' : 'asc'}`, sort: `${convertToSnakeCase(this.sort.sortBy)}_${this.sort.sortDesc ? 'desc' : 'asc'}`,
vetEnabled: this.canViewFalsePositive, vetEnabled: this.canViewFalsePositive,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled, includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
pipelineId: this.pipeline.iid,
// If we're using "after" we need to use "first", and if we're using "before" we need to // If we're using "after" we need to use "first", and if we're using "before" we need to
// use "last". See this comment for more info: // use "last". See this comment for more info:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79834#note_831878506 // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79834#note_831878506
...@@ -83,9 +96,11 @@ export default { ...@@ -83,9 +96,11 @@ export default {
}; };
}, },
update(data) { update(data) {
const vulnerabilities = deepFindVulnerabilities(data); const vulnerabilities = this.getVulnerabilitiesFromData(data);
this.pageInfo = vulnerabilities.pageInfo; this.pageInfo = vulnerabilities.pageInfo;
return vulnerabilities.nodes; // The id property is used for the bulk select feature. Vulnerabilities use 'id' and
// pipeline findings use 'uuid', so we'll normalize it to 'id'.
return vulnerabilities.nodes.map((v) => ({ ...v, id: v.id || v.uuid }));
}, },
error() { error() {
createFlash({ createFlash({
...@@ -148,7 +163,7 @@ export default { ...@@ -148,7 +163,7 @@ export default {
: this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0; : this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
}, },
shouldUsePagination() { shouldUsePagination() {
return this.glFeatures.vulnerabilityReportPagination; return Boolean(this.glFeatures.vulnerabilityReportPagination);
}, },
}, },
watch: { watch: {
...@@ -180,7 +195,8 @@ export default { ...@@ -180,7 +195,8 @@ export default {
variables: { after: this.pageInfo.endCursor }, variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, (draftData) => { return produce(fetchMoreResult, (draftData) => {
deepFindVulnerabilities(draftData).nodes.unshift(...this.vulnerabilities); const vulnerabilities = this.getVulnerabilitiesFromData(draftData);
vulnerabilities.nodes.unshift(...this.vulnerabilities);
}); });
}, },
}); });
...@@ -198,6 +214,9 @@ export default { ...@@ -198,6 +214,9 @@ export default {
pushQuerystring(data) { pushQuerystring(data) {
this.$router.push({ query: { ...this.$route.query, ...data } }); this.$router.push({ query: { ...this.$route.query, ...data } });
}, },
getVulnerabilitiesFromData(data) {
return get(data, GRAPHQL_DATA_PATH[this.dashboardType]);
},
}, },
}; };
</script> </script>
...@@ -211,6 +230,7 @@ export default { ...@@ -211,6 +230,7 @@ export default {
:sort.sync="sort" :sort.sync="sort"
:should-show-project-namespace="showProjectNamespace" :should-show-project-namespace="showProjectNamespace"
:portal-name="portalName" :portal-name="portalName"
@vulnerability-clicked="$emit('vulnerability-clicked', $event)"
/> />
<div v-if="shouldUsePagination" class="gl-text-center gl-mt-6"> <div v-if="shouldUsePagination" class="gl-text-center gl-mt-6">
......
#import "~/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/vulnerability_location.fragment.graphql" #import "../fragments/vulnerability_location.fragment.graphql"
query pipelineFindings( query pipelineFindings(
$fullPath: ID! $fullPath: ID!
$pipelineId: ID! $pipelineId: ID!
$first: Int $first: Int
$last: Int
$before: String
$after: String $after: String
$severity: [String!] $severity: [String!]
$reportType: [String!] $reportType: [String!]
...@@ -17,8 +19,10 @@ query pipelineFindings( ...@@ -17,8 +19,10 @@ query pipelineFindings(
pipeline(iid: $pipelineId) { pipeline(iid: $pipelineId) {
id id
securityReportFindings( securityReportFindings(
before: $before
after: $after after: $after
first: $first first: $first
last: $last
severity: $severity severity: $severity
reportType: $reportType reportType: $reportType
scanner: $scanner scanner: $scanner
......
...@@ -50,6 +50,9 @@ export default () => { ...@@ -50,6 +50,9 @@ export default () => {
projectId: parseInt(projectId, 10), projectId: parseInt(projectId, 10),
commitPathTemplate, commitPathTemplate,
projectFullPath, projectFullPath,
// fullPath is needed even though projectFullPath is already provided because
// vulnerability_list_graphql.vue expects the property name to be 'fullPath'
fullPath: projectFullPath,
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
canAdminVulnerability: parseBoolean(canAdminVulnerability), canAdminVulnerability: parseBoolean(canAdminVulnerability),
......
...@@ -12,6 +12,7 @@ module EE ...@@ -12,6 +12,7 @@ module EE
push_frontend_feature_flag(:pipeline_security_dashboard_graphql, project, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_security_dashboard_graphql, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_code_quality_full_report, project, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:graphql_code_quality_full_report, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml) push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
end end
feature_category :license_compliance, [:licenses] feature_category :license_compliance, [:licenses]
......
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import GroupVulnerabilities from 'ee/security_dashboard/components/group/group_vulnerabilities.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_list.vue';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { generateVulnerabilities } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Group Security Dashboard Vulnerabilities Component', () => {
let wrapper;
const apolloMock = {
queries: { vulnerabilities: { loading: true } },
};
const groupFullPath = 'group-full-path';
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findVulnerabilities = () => wrapper.findComponent(VulnerabilityList);
const findAlert = () => wrapper.findComponent(GlAlert);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const expectLoadingState = ({ initial = false, nextPage = false }) => {
expect(findVulnerabilities().props('isLoading')).toBe(initial);
expect(findLoadingIcon().exists()).toBe(nextPage);
};
const createWrapper = ({ $apollo = apolloMock } = {}) => {
return shallowMount(GroupVulnerabilities, {
mocks: {
$apollo,
fetchNextPage: () => {},
},
propsData: {
filters: {},
},
provide: {
groupFullPath,
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when the query is loading', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: true } },
},
});
});
it('shows the initial loading state', () => {
expectLoadingState({ initial: true });
});
});
describe('when the query returned an error status', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
});
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
errorLoadingVulnerabilities: true,
});
});
it('displays the alert', () => {
expect(findAlert().text()).toBe(
'Error fetching the vulnerability list. Please check your network connection and try again.',
);
});
it('should have an alert that is dismissable', async () => {
const alert = findAlert();
alert.vm.$emit('dismiss');
await nextTick();
expect(alert.exists()).toBe(false);
});
it('does not display the vulnerabilities', () => {
expect(findVulnerabilities().exists()).toBe(false);
});
});
describe('when the query is loaded and we have results', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
});
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
vulnerabilities,
});
});
it('passes down properties correctly', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: false,
shouldShowProjectNamespace: true,
vulnerabilities,
});
});
it('defaults to severity column for sorting', () => {
expect(wrapper.vm.sortBy).toBe('severity');
});
it('defaults to desc as sorting direction', () => {
expect(wrapper.vm.sortDirection).toBe('desc');
});
it('handles sorting', () => {
findVulnerabilities().vm.$listeners['sort-changed']({
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
it('does not show loading any state', () => {
expectLoadingState({ initial: false, nextPage: false });
});
});
describe('when there is more than a page of vulnerabilities', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: false } },
},
});
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
});
});
it('should render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
});
describe('when the query is loading the next page', () => {
beforeEach(() => {
wrapper = createWrapper({
$apollo: {
queries: { vulnerabilities: { loading: true } },
},
});
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
vulnerabilities: generateVulnerabilities(),
pageInfo: {
hasNextPage: true,
},
});
});
it('should render the loading spinner', () => {
expectLoadingState({ nextPage: true });
});
});
describe('when filter or sort is changed', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('should show the initial loading state when the filter is changed', () => {
wrapper.setProps({ filter: {} });
expectLoadingState({ initial: true });
});
it('should show the initial loading state when the sort is changed', () => {
findVulnerabilities().vm.$emit('sort-changed', {
sortBy: 'description',
sortDesc: false,
});
expectLoadingState({ initial: true });
});
});
describe('filters prop', () => {
const mockQuery = jest.fn().mockResolvedValue({
data: {
group: {
id: 'group-1',
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: '', hasNextPage: '' },
},
},
},
});
const createWrapperWithApollo = ({ query, filters }) => {
wrapper = shallowMount(GroupVulnerabilities, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, query]]),
propsData: { filters },
provide: { groupFullPath: 'path' },
});
};
it('does not run the query when filters is null', () => {
createWrapperWithApollo({ query: mockQuery, filters: null });
expect(mockQuery).not.toHaveBeenCalled();
});
it('runs query when filters is an object', () => {
createWrapperWithApollo({ query: mockQuery, filters: {} });
expect(mockQuery).toHaveBeenCalled();
});
});
});
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import InstanceVulnerabilities from 'ee/security_dashboard/components/instance/instance_vulnerabilities.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_list.vue';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/instance_vulnerabilities.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { generateVulnerabilities } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Instance Security Dashboard Vulnerabilities Component', () => {
let wrapper;
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findVulnerabilities = () => wrapper.findComponent(VulnerabilityList);
const findAlert = () => wrapper.findComponent(GlAlert);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const expectLoadingState = ({ initial = false, nextPage = false }) => {
expect(findVulnerabilities().props('isLoading')).toBe(initial);
expect(findLoadingIcon().exists()).toBe(nextPage);
};
const createWrapper = ({ loading = false, data } = {}) => {
return shallowMount(InstanceVulnerabilities, {
mocks: {
$apollo: {
queries: { vulnerabilities: { loading } },
},
fetchNextPage: () => {},
},
data,
propsData: {
filters: {},
},
provide: {
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when the query is loading', () => {
beforeEach(() => {
wrapper = createWrapper({ loading: true });
});
it('shows the initial loading state', () => {
expectLoadingState({ initial: true });
});
});
describe('when the query returned an error status', () => {
beforeEach(() => {
wrapper = createWrapper({
data: () => ({ errorLoadingVulnerabilities: true }),
});
});
it('displays the alert', () => {
expect(findAlert().text()).toBe(
'Error fetching the vulnerability list. Please check your network connection and try again.',
);
});
it('should have an alert that is dismissable', async () => {
const alert = findAlert();
alert.vm.$emit('dismiss');
await nextTick();
expect(alert.exists()).toBe(false);
});
it('does not display the vulnerabilities', () => {
expect(findVulnerabilities().exists()).toBe(false);
});
});
describe('when the query is loaded and we have results', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
data: () => ({ vulnerabilities }),
});
});
it('passes down properties correctly', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: false,
shouldShowProjectNamespace: true,
vulnerabilities,
});
});
it('defaults to severity column for sorting', () => {
expect(wrapper.vm.sortBy).toBe('severity');
});
it('defaults to desc as sorting direction', () => {
expect(wrapper.vm.sortDirection).toBe('desc');
});
it('handles sorting', () => {
findVulnerabilities().vm.$listeners['sort-changed']({
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
it('does not show loading any state', () => {
expectLoadingState({ initial: false, nextPage: false });
});
});
describe('when there is more than a page of vulnerabilities', () => {
const vulnerabilities = generateVulnerabilities();
beforeEach(() => {
wrapper = createWrapper({
data: () => ({
vulnerabilities,
pageInfo: {
hasNextPage: true,
},
}),
});
});
it('should render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
describe('when the filter is changed', () => {
it('it should not render the observer component', async () => {
await wrapper.setProps({ filters: {} });
expect(findIntersectionObserver().exists()).toBe(false);
});
});
});
describe('when the query is loading and there is another page', () => {
beforeEach(() => {
wrapper = createWrapper({
loading: true,
data: () => ({
vulnerabilities: generateVulnerabilities(),
pageInfo: {
hasNextPage: true,
},
}),
});
});
it('should render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
it('should render the next page loading spinner', () => {
expectLoadingState({ nextPage: true });
});
});
describe('when filter or sort is changed', () => {
beforeEach(() => {
wrapper = createWrapper({ loading: true });
});
it('should show the initial loading state when the filter is changed', async () => {
await wrapper.setProps({ filter: {} });
expectLoadingState({ initial: true });
});
it('should show the initial loading state when the sort is changed', () => {
findVulnerabilities().vm.$emit('sort-changed', {
sortBy: 'description',
sortDesc: false,
});
expectLoadingState({ initial: true });
});
});
describe('filters prop', () => {
const mockQuery = jest.fn().mockResolvedValue({
data: {
vulnerabilities: {
nodes: [],
pageInfo: { endCursor: '', hasNextPage: false },
},
},
});
const createWrapperWithApollo = ({ query, filters }) => {
wrapper = shallowMount(InstanceVulnerabilities, {
localVue,
apolloProvider: createMockApollo([[vulnerabilitiesQuery, query]]),
propsData: { filters },
});
};
it('does not run the query when filters is null', () => {
createWrapperWithApollo({ query: mockQuery, filters: null });
expect(mockQuery).not.toHaveBeenCalled();
});
it('runs query when filters is an object', () => {
createWrapperWithApollo({ query: mockQuery, filters: {} });
expect(mockQuery).toHaveBeenCalled();
});
});
});
import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import PipelineFindings from 'ee/security_dashboard/components/pipeline/pipeline_findings.vue';
import FindingModal from 'ee/security_dashboard/components/pipeline/vulnerability_finding_modal.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_list.vue';
import pipelineFindingsQuery from 'ee/security_dashboard/graphql/queries/pipeline_findings.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockPipelineFindingsResponse } from '../../mock_data';
describe('Pipeline findings', () => {
let wrapper;
const apolloMock = {
queries: { findings: { loading: true } },
};
const createWrapper = ({ props = {}, mocks, apolloProvider } = {}) => {
if (apolloProvider) {
Vue.use(VueApollo);
}
wrapper = shallowMount(PipelineFindings, {
apolloProvider,
provide: {
projectFullPath: 'gitlab/security-reports',
pipeline: {
id: 77,
iid: 8,
},
},
propsData: {
filters: {},
...props,
},
mocks,
});
};
const createWrapperWithApollo = (resolver, data) => {
return createWrapper({
...data,
apolloProvider: createMockApollo([[pipelineFindingsQuery, resolver]]),
});
};
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findAlert = () => wrapper.findComponent(GlAlert);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findModal = () => wrapper.findComponent(FindingModal);
afterEach(() => {
wrapper.destroy();
});
describe('when the findings are loading', () => {
beforeEach(() => {
createWrapper({ mocks: { $apollo: apolloMock } });
});
it('should show the initial loading state', () => {
expect(findVulnerabilityList().props('isLoading')).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('with findings', () => {
beforeEach(async () => {
createWrapperWithApollo(jest.fn().mockResolvedValue(mockPipelineFindingsResponse()));
await waitForPromises();
});
it('passes false as the loading state prop', () => {
expect(findVulnerabilityList().props('isLoading')).toBe(false);
});
it('passes down findings', () => {
expect(findVulnerabilityList().props('vulnerabilities')).toMatchObject([
{ confidence: 'unknown', id: '322ace94-2d2a-5efa-bd62-a04c927a4b9a', severity: 'HIGH' },
{ location: { file: 'package.json' }, id: '31ad79c6-b545-5408-89af-c4e90fc21eb4' },
]);
});
it('does not show the intersection loader when there is no next page', () => {
expect(findIntersectionObserver().exists()).toBe(false);
});
describe('vulnerability finding modal', () => {
it('is hidden per default', () => {
expect(findModal().exists()).toBe(false);
});
it('is visible when a vulnerability is clicked', async () => {
findVulnerabilityList().vm.$emit('vulnerability-clicked', {});
await nextTick();
expect(findModal().exists()).toBe(true);
});
it('gets passes the clicked finding as a prop', async () => {
const vulnerability = {};
findVulnerabilityList().vm.$emit('vulnerability-clicked', vulnerability);
await nextTick();
expect(findModal().props('finding')).toBe(vulnerability);
});
});
});
describe('with multiple page findings', () => {
beforeEach(async () => {
createWrapperWithApollo(
jest.fn().mockResolvedValue(mockPipelineFindingsResponse({ hasNextPage: true })),
);
await waitForPromises();
});
it('shows the intersection loader', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
});
describe('with failed query', () => {
beforeEach(async () => {
createWrapperWithApollo(jest.fn().mockRejectedValue(new Error('GraphQL error')));
await waitForPromises();
});
it('does not show the vulnerability list', () => {
expect(findVulnerabilityList().exists()).toBe(false);
});
it('shows the error', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('filtering', () => {
it.each(['reportType', 'severity'])(
`normalizes the GraphQL's query variable for the "%s" filter`,
(filterName) => {
const filterValues = ['FOO', 'BAR', 'FOO_BAR'];
const normalizedFilterValues = ['foo', 'bar', 'foo_bar'];
const queryMock = jest.fn().mockResolvedValue();
createWrapperWithApollo(queryMock, { props: { filters: { [filterName]: filterValues } } });
expect(queryMock.mock.calls[0][0]).toMatchObject({
[filterName]: normalizedFilterValues,
});
},
);
});
});
...@@ -13,7 +13,7 @@ import ScanAlerts, { ...@@ -13,7 +13,7 @@ import ScanAlerts, {
} from 'ee/security_dashboard/components/pipeline/scan_alerts.vue'; } from 'ee/security_dashboard/components/pipeline/scan_alerts.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 SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue'; import SecurityReportsSummary from 'ee/security_dashboard/components/pipeline/security_reports_summary.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report.vue'; import PipelineVulnerabilityReport from 'ee/security_dashboard/components/pipeline/pipeline_vulnerability_report.vue';
import { import {
pipelineSecurityReportSummary, pipelineSecurityReportSummary,
pipelineSecurityReportSummaryWithErrors, pipelineSecurityReportSummaryWithErrors,
...@@ -43,7 +43,7 @@ describe('Pipeline Security Dashboard component', () => { ...@@ -43,7 +43,7 @@ describe('Pipeline Security Dashboard component', () => {
let wrapper; let wrapper;
const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard); const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard);
const findVulnerabilityReport = () => wrapper.findComponent(VulnerabilityReport); const findVulnerabilityReport = () => wrapper.findComponent(PipelineVulnerabilityReport);
const findScanAlerts = () => wrapper.findComponent(ScanAlerts); const findScanAlerts = () => wrapper.findComponent(ScanAlerts);
const factory = ({ stubs, provide, apolloProvider } = {}) => { const factory = ({ stubs, provide, apolloProvider } = {}) => {
......
import { shallowMount } from '@vue/test-utils';
import { PortalTarget } from 'portal-vue';
import { nextTick } from 'vue';
import PipelineVulnerabilityReport from 'ee/security_dashboard/components/pipeline/pipeline_vulnerability_report.vue';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import FindingModal from 'ee/security_dashboard/components/pipeline/vulnerability_finding_modal.vue';
describe('Pipeline vulnerability report', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMount(PipelineVulnerabilityReport);
};
const findModal = () => wrapper.findComponent(FindingModal);
const findFilters = () => wrapper.findComponent(VulnerabilityFilters);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityListGraphql);
afterEach(() => {
wrapper.destroy();
});
it('shows the expected components', () => {
createWrapper();
expect(findFilters().exists()).toBe(true);
expect(findVulnerabilityList().exists()).toBe(true);
expect(wrapper.find(PortalTarget).exists()).toBe(true);
});
describe('filters', () => {
it.each`
property | value | expected
${'severity'} | ${['HIGH', 'LOW']} | ${['high', 'low']}
${'severity'} | ${[]} | ${undefined}
${'severity'} | ${undefined} | ${undefined}
${'reportType'} | ${['CONTAINER_SCANNING', 'SECRET_DETECTION']} | ${['container_scanning', 'secret_detection']}
${'reportType'} | ${[]} | ${undefined}
${'reportType'} | ${undefined} | ${undefined}
${'state'} | ${['DETECTED', 'CONFIRMED']} | ${['DETECTED', 'CONFIRMED']}
${'state'} | ${[]} | ${[]}
${'state'} | ${undefined} | ${undefined}
`(
'formats the filters correctly for the pipeline GraphQL endpoint when $property is $value',
async ({ property, value, expected }) => {
createWrapper();
findFilters().vm.$emit('filters-changed', { [property]: value });
await nextTick();
// severity and reportType should be lower-cased or undefined if empty/undefined, all other
// properties should be kept as-is
expect(findVulnerabilityList().props('filters')).toEqual({ [property]: expected });
},
);
});
describe('finding modal', () => {
it(`shows the modal when a vulnerability is clicked and hides it when it's supposed to be hidden`, async () => {
createWrapper();
expect(findModal().exists()).toBe(false);
const finding = {};
findVulnerabilityList().vm.$emit('vulnerability-clicked', finding);
await nextTick();
expect(findModal().props('finding')).toBe(finding);
findModal().vm.$emit('hide');
await nextTick();
expect(findModal().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ActivityFilter from 'ee/security_dashboard/components/shared/filters/activity_filter.vue';
import Filters from 'ee/security_dashboard/components/shared/filters/filters_layout.vue';
import ProjectFilter from 'ee/security_dashboard/components/shared/filters/project_filter.vue';
import ScannerFilter from 'ee/security_dashboard/components/shared/filters/scanner_filter.vue';
import SimpleFilter from 'ee/security_dashboard/components/shared/filters/simple_filter.vue';
import { simpleScannerFilter } from 'ee/security_dashboard/helpers';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('First class vulnerability filters component', () => {
let wrapper;
const findSimpleFilters = () => wrapper.findAllComponents(SimpleFilter);
const findSimpleScannerFilter = () => wrapper.findByTestId(simpleScannerFilter.id);
const findVendorScannerFilter = () => wrapper.findComponent(ScannerFilter);
const findActivityFilter = () => wrapper.findComponent(ActivityFilter);
const findProjectFilter = () => wrapper.findComponent(ProjectFilter);
const createComponent = ({ provide } = {}) => {
return extendedWrapper(
shallowMount(Filters, {
provide: {
dashboardType: DASHBOARD_TYPES.PROJECT,
...provide,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('on render without project filter', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('should render the default filters', () => {
expect(findSimpleFilters()).toHaveLength(2);
expect(findActivityFilter().exists()).toBe(true);
expect(findProjectFilter().exists()).toBe(false);
});
it('should emit filterChange when a filter is changed', () => {
const options = { foo: 'bar' };
findActivityFilter().vm.$emit('filter-changed', options);
expect(wrapper.emitted('filterChange')[0][0]).toEqual(options);
});
});
describe('project filter', () => {
it.each`
dashboardType | isShown
${DASHBOARD_TYPES.PROJECT} | ${false}
${DASHBOARD_TYPES.PIPELINE} | ${false}
${DASHBOARD_TYPES.GROUP} | ${true}
${DASHBOARD_TYPES.INSTANCE} | ${true}
`(
'on the $dashboardType report the project filter shown is $isShown',
({ dashboardType, isShown }) => {
wrapper = createComponent({ provide: { dashboardType } });
expect(findProjectFilter().exists()).toBe(isShown);
},
);
});
describe('activity filter', () => {
beforeEach(() => {
wrapper = createComponent({ provide: { dashboardType: DASHBOARD_TYPES.PIPELINE } });
});
it('does not display on the pipeline dashboard', () => {
expect(findActivityFilter().exists()).toBe(false);
});
});
describe('scanner filter', () => {
it.each`
type | dashboardType
${'vendor'} | ${DASHBOARD_TYPES.PROJECT}
${'simple'} | ${DASHBOARD_TYPES.GROUP}
${'simple'} | ${DASHBOARD_TYPES.INSTANCE}
${'simple'} | ${DASHBOARD_TYPES.PIPELINE}
`('shows the $type scanner filter on the $dashboardType report', ({ type, dashboardType }) => {
wrapper = createComponent({ provide: { dashboardType } });
expect(findSimpleScannerFilter().exists()).toBe(type === 'simple');
expect(findVendorScannerFilter().exists()).toBe(type === 'vendor');
});
});
});
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VulnerabilityCount from 'ee/security_dashboard/components/shared/vulnerability_count.vue';
import VulnerabilityCountListLayout from 'ee/security_dashboard/components/shared/vulnerability_count_list_layout.vue';
describe('Vulnerabilities count list component', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
const findVulnerability = () => wrapper.findAllComponents(VulnerabilityCount);
const createWrapper = ({ propsData } = {}) => {
return shallowMount(VulnerabilityCountListLayout, {
propsData,
stubs: {
GlAlert,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
it('passes the isLoading prop to the counts', () => {
wrapper = createWrapper({ propsData: { isLoading: true, vulnerabilitiesCount: {} } });
findVulnerability().wrappers.forEach((component) => {
expect(component.props('isLoading')).toBe(true);
});
});
});
describe('when there are no counts', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { vulnerabilitiesCount: {} } });
});
it.each`
index | name
${0} | ${'critical'}
${1} | ${'high'}
${2} | ${'medium'}
${3} | ${'low'}
${4} | ${'info'}
${5} | ${'unknown'}
`('shows 0 count for $name', ({ index, name }) => {
const vulnerability = findVulnerability().at(index);
expect(vulnerability.props('severity')).toBe(name);
expect(vulnerability.props('count')).toBe(0);
});
});
describe('when loaded and has a list of vulnerability counts', () => {
const vulnerabilitiesCount = { critical: 5, medium: 3, info: 1, unknown: 2, low: 3, high: 8 };
beforeEach(() => {
wrapper = createWrapper({ propsData: { vulnerabilitiesCount } });
});
it('sets the isLoading prop false and passes it down', () => {
findVulnerability().wrappers.forEach((component) => {
expect(component.props('isLoading')).toBe(false);
});
});
it.each`
index | count | name
${0} | ${vulnerabilitiesCount.critical} | ${'critical'}
${1} | ${vulnerabilitiesCount.high} | ${'high'}
${2} | ${vulnerabilitiesCount.medium} | ${'medium'}
${3} | ${vulnerabilitiesCount.low} | ${'low'}
${4} | ${vulnerabilitiesCount.info} | ${'info'}
${5} | ${vulnerabilitiesCount.unknown} | ${'unknown'}
`('shows count for $name correctly', ({ index, count, name }) => {
const vulnerability = findVulnerability().at(index);
expect(vulnerability.props('severity')).toBe(name);
expect(vulnerability.props('count')).toBe(count);
});
});
describe('when loaded and has an error', () => {
it('shows the error message', () => {
wrapper = createWrapper({ propsData: { showError: true, vulnerabilitiesCount: {} } });
expect(findAlert().text()).toBe(
'Error fetching the vulnerability counts. Please check your network connection and try again.',
);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import VulnerabilityCountList from 'ee/security_dashboard/components/shared/vulnerability_count_list.vue';
import VulnerabilityCountListLayout from 'ee/security_dashboard/components/shared/vulnerability_count_list_layout.vue';
import countQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import eventHub from 'ee/security_dashboard/utils/event_hub';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockVulnerabilitySeveritiesGraphQLResponse } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Vulnerabilities count list component', () => {
let wrapper;
let refetchSpy;
const findVulnerabilityLayout = () => wrapper.findComponent(VulnerabilityCountListLayout);
const createWrapper = ({ query = { isLoading: false }, provide, data = {} } = {}) => {
refetchSpy = jest.fn();
return shallowMount(VulnerabilityCountList, {
provide: {
dashboardType: DASHBOARD_TYPES.PROJECT,
projectFullPath: 'path-to-project',
groupFullPath: undefined,
...provide,
},
data: () => data,
mocks: {
$apollo: { queries: { vulnerabilitiesCount: { ...query, refetch: refetchSpy } } },
},
});
};
const createWrapperWithApollo = ({ query, provide, propsData, stubs }) => {
wrapper = shallowMount(VulnerabilityCountList, {
localVue,
apolloProvider: createMockApollo([[countQuery, query]]),
provide: { projectFullPath: undefined, groupFullPath: undefined, ...provide },
propsData,
stubs,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
it('passes down to the loading indicator', () => {
wrapper = createWrapper({ query: { loading: true } });
expect(findVulnerabilityLayout().props('isLoading')).toBe(true);
});
});
describe('when counts are loaded', () => {
beforeEach(() => {
wrapper = createWrapper({ query: { loading: false } });
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
vulnerabilitiesCount: {
critical: 5,
high: 3,
low: 19,
info: 4,
medium: 2,
unknown: 4,
},
});
});
it('sets the loading indicator false and passes it down', () => {
expect(findVulnerabilityLayout().props('isLoading')).toBe(false);
});
it('should load the vulnerabilities and pass them down to the layout', () => {
expect(findVulnerabilityLayout().props('vulnerabilitiesCount')).toEqual({
critical: 5,
high: 3,
low: 19,
info: 4,
medium: 2,
unknown: 4,
});
});
it('refetches the query when vulnerabilities-updated event is triggered', () => {
eventHub.$emit('vulnerabilities-updated', wrapper.vm);
expect(refetchSpy).toHaveBeenCalled();
});
});
describe.each`
dashboardType | provide | expectedContainedQueryVariables
${DASHBOARD_TYPES.INSTANCE} | ${undefined} | ${{ isInstance: true, isGroup: false, isProject: false }}
${DASHBOARD_TYPES.GROUP} | ${{ groupFullPath: 'group/path' }} | ${{ isInstance: false, isGroup: true, isProject: false }}
${DASHBOARD_TYPES.PROJECT} | ${{ projectFullPath: 'project/path' }} | ${{ isInstance: false, isGroup: false, isProject: true }}
`(
'when the dashboard type is $dashboardType',
({ dashboardType, provide, expectedContainedQueryVariables }) => {
beforeEach(async () => {
const mockResponse = jest
.fn()
.mockResolvedValue(mockVulnerabilitySeveritiesGraphQLResponse({ dashboardType }));
createWrapperWithApollo({
provide: { dashboardType, ...provide },
propsData: { filters: { someFilter: 1 } },
query: mockResponse,
stubs: { VulnerabilityCountListLayout },
});
await nextTick();
});
it('should pass the correct variables to the GraphQL query', () => {
expect(
wrapper.vm.$options.apollo.vulnerabilitiesCount.variables.call(wrapper.vm),
).toMatchObject(expectedContainedQueryVariables);
});
it('should set the data properly', () => {
expect(findVulnerabilityLayout().props('vulnerabilitiesCount')).toEqual({
critical: 0,
high: 0,
info: 0,
low: 0,
medium: 4,
unknown: 2,
});
});
},
);
describe('when there is an error', () => {
beforeEach(() => {
wrapper = createWrapper({ data: { queryError: true } });
});
it('should tell the layout to display an error', () => {
expect(findVulnerabilityLayout().props('showError')).toBe(true);
});
});
describe('filters prop', () => {
const mockQuery = jest.fn().mockResolvedValue(null);
it('does not run the query when filters is null', () => {
createWrapperWithApollo({
query: mockQuery,
propsData: { filters: null },
provide: { dashboardType: DASHBOARD_TYPES.PROJECT },
});
expect(mockQuery).not.toHaveBeenCalled();
});
it('runs query when filters is an object', () => {
createWrapperWithApollo({
query: mockQuery,
propsData: { filters: {} },
provide: { dashboardType: DASHBOARD_TYPES.PROJECT },
});
expect(mockQuery).toHaveBeenCalled();
});
});
});
import { GlCard } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VulnerabilityCount from 'ee/security_dashboard/components/shared/vulnerability_count.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
describe('Vulnerability Count', () => {
let wrapper;
const findCard = () => wrapper.findComponent(GlCard);
const findBadge = () => wrapper.findComponent(SeverityBadge);
const findBody = () => wrapper.findComponent({ ref: 'body' });
function mountComponent({ props } = {}) {
wrapper = shallowMount(VulnerabilityCount, {
propsData: {
severity: 'high',
count: 100,
...props,
},
});
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('should render correctly with a high severity vulnerability', () => {
const header = findBadge();
const body = findBody();
expect(header.props('severity')).toBe('high');
expect(body.text()).toBe('100');
});
it('should render a card layout with the correct header and body classes', () => {
const card = findCard();
expect(card.props('headerClass')).toBe('gl-display-flex gl-justify-content-center gl-p-3');
expect(card.props('bodyClass')).toBe('gl-font-size-h2 gl-text-center');
});
});
...@@ -10,6 +10,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -10,6 +10,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { FIELDS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants'; import { FIELDS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -60,6 +61,7 @@ describe('Vulnerability list GraphQL component', () => { ...@@ -60,6 +61,7 @@ describe('Vulnerability list GraphQL component', () => {
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]), apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: { provide: {
fullPath, fullPath,
dashboardType: DASHBOARD_TYPES.GROUP,
canViewFalsePositive, canViewFalsePositive,
hasJiraVulnerabilitiesIntegrationEnabled, hasJiraVulnerabilitiesIntegrationEnabled,
}, },
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { PortalTarget } from 'portal-vue';
import VulnerabilityListGraphql from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_report.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import projectVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import {
FIELD_PRESETS,
FILTER_PRESETS,
REPORT_TAB,
REPORT_TYPE_PRESETS,
} from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
describe('Vulnerability report component', () => {
let wrapper;
const createWrapper = ({
type = REPORT_TAB.DEVELOPMENT,
showProjectFilter = false,
canAdminVulnerability = false,
dashboardType = DASHBOARD_TYPES.GROUP,
} = {}) => {
wrapper = shallowMount(VulnerabilityReport, {
propsData: {
type,
query: projectVulnerabilitiesQuery,
showProjectFilter,
},
provide: {
dashboardType,
canAdminVulnerability,
},
});
};
const findVulnerabilityCounts = () => wrapper.findComponent(VulnerabilityCounts);
const findVulnerabilityFilters = () => wrapper.findComponent(VulnerabilityFilters);
const findVulnerabilityListGraphql = () => wrapper.findComponent(VulnerabilityListGraphql);
const findPortalTarget = () => wrapper.findComponent(PortalTarget);
afterEach(() => {
wrapper.destroy();
});
describe('vulnerability filters component', () => {
it('will pass data from filters-changed event to the counts and list components', async () => {
createWrapper();
const data = { a: 1 };
findVulnerabilityFilters().vm.$emit('filters-changed', data);
await nextTick();
expect(findVulnerabilityCounts().props('filters')).toBe(data);
expect(findVulnerabilityListGraphql().props('filters')).toBe(data);
});
it('will filter by everything except cluster image scanning results for the development report', async () => {
createWrapper({ type: REPORT_TAB.DEVELOPMENT });
findVulnerabilityFilters().vm.$emit('filters-changed', {});
await nextTick();
expect(findVulnerabilityListGraphql().props('filters').reportType).toBe(
REPORT_TYPE_PRESETS.DEVELOPMENT,
);
});
it('will filter by cluster image scanning results for the operational report', async () => {
createWrapper({ type: REPORT_TAB.OPERATIONAL });
findVulnerabilityFilters().vm.$emit('filters-changed', {});
await nextTick();
expect(findVulnerabilityListGraphql().props('filters').reportType).toBe(
REPORT_TYPE_PRESETS.OPERATIONAL,
);
});
it.each`
dashboardType | type | expectedFilters
${DASHBOARD_TYPES.GROUP} | ${REPORT_TAB.DEVELOPMENT} | ${FILTER_PRESETS.DEVELOPMENT}
${DASHBOARD_TYPES.INSTANCE} | ${REPORT_TAB.OPERATIONAL} | ${FILTER_PRESETS.OPERATIONAL}
${DASHBOARD_TYPES.PROJECT} | ${REPORT_TAB.DEVELOPMENT} | ${FILTER_PRESETS.DEVELOPMENT_PROJECT}
`(
'shows the expected filter for the $type $dashboardType report',
({ dashboardType, type, expectedFilters }) => {
createWrapper({ dashboardType, type });
expect(findVulnerabilityFilters().props('filters')).toEqual(expectedFilters);
},
);
});
describe('vulnerability list GraphQL component', () => {
it('gets passed the query prop', () => {
createWrapper();
expect(findVulnerabilityListGraphql().props('query')).toBe(projectVulnerabilitiesQuery);
});
it.each`
type | expectedFields
${REPORT_TAB.DEVELOPMENT} | ${FIELD_PRESETS.DEVELOPMENT}
${REPORT_TAB.OPERATIONAL} | ${FIELD_PRESETS.OPERATIONAL}
`('gets passed the expected fields prop for the $type report', ({ type, expectedFields }) => {
createWrapper({ type });
expect(findVulnerabilityListGraphql().props('fields')).toEqual(expectedFields);
});
it.each([true, false])(
'gets passed the expected value for the should show project namespace prop',
(showProjectFilter) => {
createWrapper({ showProjectFilter });
expect(findVulnerabilityListGraphql().props('showProjectNamespace')).toBe(
showProjectFilter,
);
},
);
});
describe('sticky portal', () => {
it.each([REPORT_TAB.DEVELOPMENT, REPORT_TAB.OPERATIONAL])(
'has the portal target with the expected name for the %s report',
(type) => {
createWrapper({ type });
expect(findPortalTarget().props('name')).toBe(`vulnerability-report-sticky-${type}`);
},
);
});
});
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import { PortalTarget } from 'portal-vue';
import { nextTick } from 'vue';
import GroupVulnerabilities from 'ee/security_dashboard/components/group/group_vulnerabilities.vue';
import InstanceVulnerabilities from 'ee/security_dashboard/components/instance/instance_vulnerabilities.vue';
import ProjectVulnerabilities from 'ee/security_dashboard/components/project/project_vulnerabilities.vue';
import AutoFixUserCallout from 'ee/security_dashboard/components/shared/auto_fix_user_callout.vue';
import CsvExportButton from 'ee/security_dashboard/components/shared/csv_export_button.vue';
import ReportNotConfiguredGroup from 'ee/security_dashboard/components/shared/empty_states/report_not_configured_group.vue';
import ReportNotConfiguredInstance from 'ee/security_dashboard/components/shared/empty_states/report_not_configured_instance.vue';
import Filters from 'ee/security_dashboard/components/shared/filters/filters_layout.vue';
import ProjectPipelineStatus from 'ee/security_dashboard/components/shared/project_pipeline_status.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/shared/survey_request_banner.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/shared/vulnerability_count_list.vue';
import VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report.vue';
import VulnerabilityReportLayout from 'ee/security_dashboard/components/shared/vulnerability_report_layout.vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
describe('Vulnerability Report', () => {
let wrapper;
const findAlertsPortalTarget = () => wrapper.findComponent(PortalTarget);
const findSurveyRequestBanner = () => wrapper.findComponent(SurveyRequestBanner);
const findInstanceVulnerabilities = () => wrapper.findComponent(InstanceVulnerabilities);
const findGroupVulnerabilities = () => wrapper.findComponent(GroupVulnerabilities);
const findProjectVulnerabilities = () => wrapper.findComponent(ProjectVulnerabilities);
const findCsvExportButton = () => wrapper.findComponent(CsvExportButton);
const findGroupEmptyState = () => wrapper.findComponent(ReportNotConfiguredGroup);
const findInstanceEmptyState = () => wrapper.findComponent(ReportNotConfiguredInstance);
const findFilters = () => wrapper.findComponent(Filters);
const findVulnerabilitiesCountList = () => wrapper.findComponent(VulnerabilitiesCountList);
const findProjectPipelineStatus = () => wrapper.findComponent(ProjectPipelineStatus);
const findAutoFixUserCallout = () => wrapper.findComponent(AutoFixUserCallout);
const findHeader = () => wrapper.find('h2');
const createWrapper = ({ data = {}, mocks, provide }) => {
return shallowMount(VulnerabilityReport, {
data: () => data,
mocks,
provide: {
hasProjects: true,
...provide,
},
stubs: { VulnerabilityReportLayout },
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when initialized - all levels', () => {
beforeEach(() => {
wrapper = createWrapper({
provide: {
dashboardType: DASHBOARD_TYPES.INSTANCE,
},
});
});
it('renders the alerts portal target', () => {
const portalTarget = findAlertsPortalTarget();
expect(portalTarget.exists()).toBe(true);
expect(portalTarget.props('name')).toBe(VulnerabilityReport.vulnerabilityReportAlertsPortal);
});
it('should show the header', () => {
expect(findHeader().exists()).toBe(true);
});
it('has filters', () => {
expect(findFilters().exists()).toBe(true);
});
it('responds to the filterChange event', async () => {
const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters);
await nextTick();
expect(findInstanceVulnerabilities().props('filters')).toBe(filters);
});
it('displays the csv export button', () => {
expect(findCsvExportButton().exists()).toBe(true);
});
it('should show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
});
describe('when initialized - instance level', () => {
const filters = {};
beforeEach(() => {
wrapper = createWrapper({
provide: {
dashboardType: DASHBOARD_TYPES.INSTANCE,
},
data: { filters },
});
});
it('should render the vulnerabilities', () => {
expect(findInstanceVulnerabilities().exists()).toBe(true);
});
it('shows the vulnerability count list and passes the filters prop', () => {
expect(findVulnerabilitiesCountList().props('filters')).toBe(filters);
});
it('does not show project pipeline status', () => {
expect(findProjectPipelineStatus().exists()).toBe(false);
});
});
describe('when initialized - group level', () => {
beforeEach(() => {
wrapper = createWrapper({
provide: {
groupFullPath: 'gitlab-org',
dashboardType: DASHBOARD_TYPES.GROUP,
},
});
});
it('should render the vulnerabilities', () => {
expect(findGroupVulnerabilities().exists()).toBe(true);
});
it('displays the vulnerability count list with the correct data', () => {
expect(findVulnerabilitiesCountList().props()).toEqual({
filters: wrapper.vm.filters,
});
});
});
describe('when uninitialized', () => {
beforeEach(() => {
wrapper = createWrapper({
provide: {
groupFullPath: 'gitlab-org',
dashboardType: DASHBOARD_TYPES.GROUP,
hasProjects: false,
},
});
});
it('only renders the empty state', () => {
expect(findAlertsPortalTarget().exists()).toBe(false);
expect(findGroupEmptyState().exists()).toBe(true);
expect(findInstanceEmptyState().exists()).toBe(false);
expect(findCsvExportButton().exists()).toBe(false);
expect(findFilters().exists()).toBe(false);
expect(findVulnerabilitiesCountList().exists()).toBe(false);
expect(findHeader().exists()).toBe(false);
});
it('should show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
});
describe('when initialized - project level', () => {
const createProjectWrapper = ({ securityAutoFix } = {}) =>
createWrapper({
provide: {
dashboardType: DASHBOARD_TYPES.PROJECT,
autoFixDocumentation: 'path/to/help-page',
pipeline: { id: '591' },
glFeatures: { securityAutoFix },
},
});
it('does not show user callout when feature flag is disabled', () => {
wrapper = createProjectWrapper({ securityAutoFix: false });
expect(findAutoFixUserCallout().exists()).toBe(false);
});
it('shows user callout when the cookie is not set and hides it when dismissed', async () => {
jest.spyOn(Cookies, 'set');
wrapper = createProjectWrapper({ securityAutoFix: true });
const autoFixUserCallOut = findAutoFixUserCallout();
expect(autoFixUserCallOut.exists()).toBe(true);
await autoFixUserCallOut.vm.$emit('close');
expect(autoFixUserCallOut.exists()).toBe(false);
expect(Cookies.set).toHaveBeenCalledWith(
wrapper.vm.$options.autoFixUserCalloutCookieName,
'true',
{
expires: 365,
secure: false,
},
);
});
it('does not show user callout when the cookie is set', () => {
jest.doMock('js-cookie', () => ({ get: jest.fn().mockReturnValue(true) }));
wrapper = createProjectWrapper({ securityAutoFix: true });
expect(findAutoFixUserCallout().exists()).toBe(false);
});
it('shows the project pipeline status', () => {
wrapper = createProjectWrapper();
expect(findProjectPipelineStatus().exists()).toBe(true);
});
it('renders the vulnerabilities', () => {
wrapper = createProjectWrapper();
expect(findProjectVulnerabilities().exists()).toBe(true);
});
});
describe('manually added vulnerabilities without a pipeline - project level', () => {
beforeEach(() => {
wrapper = createWrapper({
provide: {
dashboardType: DASHBOARD_TYPES.PROJECT,
pipeline: null,
},
});
});
it('renders the vulnerabilities project state', () => {
expect(findProjectVulnerabilities().exists()).toBe(true);
});
it('does not render the pipeline status', () => {
expect(findProjectPipelineStatus().exists()).toBe(false);
});
});
});
...@@ -6,11 +6,11 @@ module QA ...@@ -6,11 +6,11 @@ module QA
module Project module Project
module Secure module Secure
class SecurityDashboard < QA::Page::Base class SecurityDashboard < QA::Page::Base
view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_list.vue' do view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue' do
element :vulnerability element :vulnerability
end end
view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_list.vue' do view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue' do
element :false_positive_vulnerability element :false_positive_vulnerability
end end
......
...@@ -12,7 +12,7 @@ module QA ...@@ -12,7 +12,7 @@ module QA
element :security_report_content, required: true element :security_report_content, required: true
end end
view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_list.vue' do view 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue' do
element :false_positive_vulnerability element :false_positive_vulnerability
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