Commit 5aebfbe1 authored by Savas Vedova's avatar Savas Vedova

Merge branch '347155-use-new-vuln-report-for-pipeline' into 'master'

Use new vulnerability report for pipeline security tab

See merge request gitlab-org/gitlab!81760
parents bb065c57 b4551ff8
<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
import { fetchPolicies } from '~/lib/graphql';
import { s__ } from '~/locale';
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 SecurityDashboard from './security_dashboard_vuex.vue';
import SecurityReportsSummary from './security_reports_summary.vue';
import PipelineVulnerabilityReport from './pipeline_vulnerability_report.vue';
export default {
name: 'PipelineSecurityDashboard',
......@@ -20,7 +20,7 @@ export default {
ScanAlerts,
SecurityReportsSummary,
SecurityDashboard,
VulnerabilityReport,
PipelineVulnerabilityReport,
},
mixins: [glFeatureFlagMixin()],
inject: [
......@@ -159,6 +159,6 @@ export default {
<gl-empty-state v-bind="emptyStateProps" />
</template>
</security-dashboard>
<vulnerability-report v-else />
<pipeline-vulnerability-report v-else />
</div>
</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 {
GlFormCheckbox,
GlLink,
GlSprintf,
GlTruncate,
GlSkeletonLoading,
GlTooltipDirective,
GlTable,
} from '@gitlab/ui';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/shared/empty_states/dashboard_has_no_vulnerabilities.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/shared/empty_states/filters_produced_no_results.vue';
import { VULNERABILITIES_PER_PAGE, DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import convertReportType from 'ee/vue_shared/security_reports/store/utils/convert_report_type';
import getPrimaryIdentifier from 'ee/vue_shared/security_reports/store/utils/get_primary_identifier';
import FalsePositiveBadge from 'ee/vulnerabilities/components/false_positive_badge.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import AutoFixHelpText from './auto_fix_help_text.vue';
import IssuesBadge from './issues_badge.vue';
import SelectionSummary from './selection_summary.vue';
import VulnerabilityCommentIcon from './vulnerability_comment_icon.vue';
export default {
components: {
GlFormCheckbox,
GlLink,
GlSkeletonLoading,
GlSprintf,
GlTable,
GlTruncate,
IssuesBadge,
AutoFixHelpText,
RemediatedBadge,
FalsePositiveBadge,
SelectionSummary,
SeverityBadge,
VulnerabilityCommentIcon,
FiltersProducedNoResults,
DashboardHasNoVulnerabilities,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
hasVulnerabilities: {
default: false,
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
canAdminVulnerability: {
default: false,
},
dashboardType: {},
},
props: {
filters: {
type: Object,
required: false,
default: () => ({}),
},
vulnerabilities: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
shouldShowProjectNamespace: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
selectedVulnerabilities: {},
sortBy: 'severity',
sortDesc: true,
};
},
computed: {
isSortable() {
return Boolean(this.$listeners['sort-changed']);
},
isPipelineDashboard() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
hasAnyScannersOtherThanGitLab() {
return this.vulnerabilities.some(
(v) => v.scanner?.vendor !== 'GitLab' && v.scanner?.vendor !== '',
);
},
hasSelectedSomeVulnerabilities() {
// Whether the user has selected at least 1, but not all vulnerabilities.
return this.numOfSelectedVulnerabilities > 0 && !this.hasSelectedAllVulnerabilities;
},
hasSelectedAllVulnerabilities() {
if (!this.vulnerabilities.length) {
return false;
}
return this.numOfSelectedVulnerabilities === this.vulnerabilities.length;
},
numOfSelectedVulnerabilities() {
return Object.keys(this.selectedVulnerabilities).length;
},
shouldShowSelectionSummary() {
return this.canAdminVulnerability && this.numOfSelectedVulnerabilities > 0;
},
theadClass() {
return this.shouldShowSelectionSummary ? 'below-selection-summary' : '';
},
fields() {
const baseFields = [
{
key: 'checkbox',
class: 'checkbox',
skip: !this.canAdminVulnerability,
},
{
key: 'detected',
label: s__('Vulnerability|Detected'),
class: 'detected',
sortable: this.isSortable,
skip: this.isPipelineDashboard,
},
{
key: 'state',
label: s__('Vulnerability|Status'),
class: 'status',
sortable: this.isSortable,
},
{
key: 'severity',
label: s__('Vulnerability|Severity'),
class: 'severity',
sortable: this.isSortable,
},
{
key: 'title',
label: __('Description'),
class: 'description gl-word-break-all',
sortable: this.isSortable,
},
{
key: 'identifier',
label: s__('Vulnerability|Identifier'),
class: 'identifier gl-word-break-all',
},
{
key: 'reportType',
label: s__('Reports|Tool'),
class: 'scanner',
sortable: this.isSortable,
},
{
key: 'activity',
label: s__('Vulnerability|Activity'),
thClass: 'gl-text-right',
class: 'activity',
skip: this.isPipelineDashboard,
},
].filter((f) => !f.skip);
// Apply gl-bg-white! to every header.
baseFields.forEach((field) => {
field.thClass = [field.thClass, 'gl-bg-white!']; // eslint-disable-line no-param-reassign
});
return baseFields;
},
},
watch: {
filters() {
this.selectedVulnerabilities = {};
},
vulnerabilities() {
const ids = new Set(this.vulnerabilities.map((v) => v.id));
Object.keys(this.selectedVulnerabilities).forEach((vulnerabilityId) => {
if (!ids.has(vulnerabilityId)) {
this.$delete(this.selectedVulnerabilities, vulnerabilityId);
}
});
},
},
methods: {
createLocationString(location) {
const { image, file, startLine, path } = location;
if (image) {
return image;
}
if (file && startLine) {
return `${file}:${startLine}`;
}
if (path) {
return path;
}
return file;
},
deselectVulnerability(vulnerabilityId) {
this.$delete(this.selectedVulnerabilities, vulnerabilityId);
},
deselectAllVulnerabilities() {
this.selectedVulnerabilities = {};
},
extraIdentifierCount(identifiers) {
return identifiers?.length - 1;
},
fileUrl(vulnerability) {
const { startLine: start, endLine: end, blobPath } = vulnerability.location;
const lineNumber = end > start ? `${start}-${end}` : start;
if (!blobPath) {
return '';
}
return `${blobPath}${lineNumber ? `#L${lineNumber}` : ''}`;
},
primaryIdentifier(identifiers) {
return getPrimaryIdentifier(identifiers, 'externalType');
},
isSelected(vulnerability = {}) {
return Boolean(this.selectedVulnerabilities[vulnerability.id]);
},
selectAllVulnerabilities() {
this.selectedVulnerabilities = this.vulnerabilities.reduce((acc, curr) => {
acc[curr.id] = curr;
return acc;
}, {});
},
shouldShowExtraIdentifierCount(identifiers) {
return identifiers?.length > 1;
},
shouldShowVulnerabilityPath(item) {
return Boolean(item.location.image || item.location.file || item.location.path);
},
toggleAllVulnerabilities() {
if (this.hasSelectedAllVulnerabilities) {
this.deselectAllVulnerabilities();
} else {
this.selectAllVulnerabilities();
}
},
toggleVulnerability(vulnerability) {
if (this.selectedVulnerabilities[vulnerability.id]) {
this.$delete(this.selectedVulnerabilities, `${vulnerability.id}`);
} else {
this.$set(this.selectedVulnerabilities, `${vulnerability.id}`, vulnerability);
}
},
gitlabIssues(item) {
return item.issueLinks?.nodes || [];
},
externalIssues(item) {
return item.externalIssueLinks?.nodes || [];
},
jiraIssues(item) {
return this.externalIssues(item).filter(({ issue }) => issue?.externalTracker === 'jira');
},
badgeIssues(item) {
return this.hasJiraVulnerabilitiesIntegrationEnabled
? this.jiraIssues(item)
: this.gitlabIssues(item);
},
formatDate(item) {
return formatDate(item.detectedAt, 'yyyy-mm-dd');
},
formatDateTooltip(item) {
return formatDate(item.detectedAt);
},
hasComments(item) {
return item.userNotesCount > 0;
},
useConvertReportType(reportType) {
return convertReportType(reportType);
},
handleSortChange(args) {
if (args.sortBy) {
this.$emit('sort-changed', { ...args, sortBy: convertToSnakeCase(args.sortBy) });
}
},
getVulnerabilityState(state = '') {
const stateName = state.toLowerCase();
// Use the raw state name if we don't have a localization for it.
return VULNERABILITY_STATES[stateName] || stateName;
},
},
VULNERABILITIES_PER_PAGE,
};
</script>
<template>
<div class="vulnerability-list">
<selection-summary
:selected-vulnerabilities="Object.values(selectedVulnerabilities)"
:visible="shouldShowSelectionSummary"
@cancel-selection="deselectAllVulnerabilities"
@vulnerability-updated="deselectVulnerability"
/>
<gl-table
v-if="filters"
:busy="isLoading"
:fields="fields"
:items="vulnerabilities"
:thead-class="theadClass"
:sort-desc="sortDesc"
:sort-by="sortBy"
sort-icon-left
no-local-sorting
stacked="sm"
class="vulnerability-list"
show-empty
responsive
hover
primary-key="id"
:tbody-tr-class="{ 'gl-cursor-pointer': vulnerabilities.length }"
@sort-changed="handleSortChange"
@row-clicked="toggleVulnerability"
>
<template #head(checkbox)>
<gl-form-checkbox
class="gl-m-0"
data-testid="vulnerability-checkbox-all"
:checked="hasSelectedAllVulnerabilities"
:indeterminate="hasSelectedSomeVulnerabilities"
@change="toggleAllVulnerabilities"
/>
</template>
<template #cell(checkbox)="{ item }">
<gl-form-checkbox
class="gl-display-inline-block! gl-m-0 gl-pointer-events-none"
data-testid="vulnerability-checkbox"
:checked="isSelected(item)"
@change="toggleVulnerability(item)"
/>
</template>
<template #cell(detected)="{ item }">
<time v-gl-tooltip :data-testid="`detected-${item.id}`" :title="formatDateTooltip(item)">
{{ formatDate(item) }}
</time>
</template>
<template #cell(state)="{ item }">
<span class="text-capitalize js-status">{{ getVulnerabilityState(item.state) }}</span>
</template>
<template #cell(severity)="{ item }">
<severity-badge class="js-severity" :severity="item.severity" />
</template>
<template #cell(title)="{ item }">
<div
class="gl-display-flex gl-flex-direction-column flex-sm-row gl-align-items-end align-items-sm-center"
:data-testid="`title-${item.id}`"
>
<gl-link
class="gl-text-body vulnerability-title js-description"
:href="item.vulnerabilityPath"
:data-qa-vulnerability-description="item.title || item.name"
data-qa-selector="vulnerability"
@click="$emit('vulnerability-clicked', item)"
>
{{ item.title || item.name }}
</gl-link>
<vulnerability-comment-icon v-if="hasComments(item)" :vulnerability="item" />
</div>
<div
v-if="item.location"
:data-testid="`location-${item.id}`"
class="gl-text-color-secondary gl-font-sm"
>
<div v-if="shouldShowProjectNamespace">
{{ item.project.nameWithNamespace }}
</div>
<div v-if="shouldShowVulnerabilityPath(item)">
<gl-link v-if="item.location.blobPath" :href="fileUrl(item)">
<gl-truncate :text="createLocationString(item.location)" position="middle" />
</gl-link>
<gl-truncate v-else :text="createLocationString(item.location)" position="middle" />
</div>
</div>
</template>
<template #cell(identifier)="{ item }">
<div data-testid="vulnerability-identifier">
{{ primaryIdentifier(item.identifiers) }}
</div>
<div
v-if="shouldShowExtraIdentifierCount(item.identifiers)"
data-testid="vulnerability-more-identifiers"
class="gl-text-gray-300"
>
<gl-sprintf :message="__('+ %{count} more')">
<template #count>
{{ extraIdentifierCount(item.identifiers) }}
</template>
</gl-sprintf>
</div>
</template>
<template #cell(reportType)="{ item }">
<div data-testid="vulnerability-report-type" class="text-capitalize">
{{ useConvertReportType(item.reportType) }}
</div>
<div
v-if="hasAnyScannersOtherThanGitLab && item.scanner"
data-testid="vulnerability-vendor"
class="gl-text-gray-300"
>
{{ item.scanner.vendor }}
</div>
</template>
<template #cell(activity)="{ item }">
<div class="gl-display-flex gl-justify-content-end">
<auto-fix-help-text v-if="item.mergeRequest" :merge-request="item.mergeRequest" />
<issues-badge
v-if="badgeIssues(item).length > 0"
:issues="badgeIssues(item)"
:is-jira="hasJiraVulnerabilitiesIntegrationEnabled"
/>
<false-positive-badge
v-if="item.falsePositive"
data-qa-selector="false_positive_vulnerability"
class="gl-ml-3"
/>
<remediated-badge v-if="item.resolvedOnDefaultBranch" class="gl-ml-3" />
</div>
</template>
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.VULNERABILITIES_PER_PAGE"
:key="n"
class="gl-m-3 js-skeleton-loader"
:lines="2"
/>
</template>
<template #empty>
<filters-produced-no-results v-if="hasVulnerabilities && !isLoading" />
<dashboard-has-no-vulnerabilities v-else-if="!isLoading" />
</template>
</gl-table>
</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>
import { GlLoadingIcon, GlIntersectionObserver, GlKeysetPagination } from '@gitlab/ui';
import { produce } from 'immer';
import { get } from 'lodash';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import VulnerabilityList from './vulnerability_list.vue';
import { FIELDS } from './constants';
const PAGE_SIZE = 20;
// Deep searches an object for a key called 'vulnerabilities'. If it's not found, it will traverse
// down the object's first property until either it's found, or there's nothing left to search. Note
// that this will only check the first property of any object, not all of them.
const deepFindVulnerabilities = (data) => {
let currentData = data;
while (currentData !== undefined && currentData.vulnerabilities === undefined) {
[currentData] = Object.values(currentData);
}
return currentData?.vulnerabilities;
const GRAPHQL_DATA_PATH = {
[DASHBOARD_TYPES.PROJECT]: 'project.vulnerabilities',
[DASHBOARD_TYPES.GROUP]: 'group.vulnerabilities',
[DASHBOARD_TYPES.INSTANCE]: 'vulnerabilities',
[DASHBOARD_TYPES.PIPELINE]: 'project.pipeline.securityReportFindings',
};
export default {
components: { GlLoadingIcon, GlIntersectionObserver, VulnerabilityList, GlKeysetPagination },
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath', 'canViewFalsePositive', 'hasJiraVulnerabilitiesIntegrationEnabled'],
inject: {
dashboardType: {
required: true,
},
fullPath: {
required: true,
},
canViewFalsePositive: {
default: false,
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
pipeline: {
default: {},
},
},
props: {
query: {
type: Object,
......@@ -72,6 +84,7 @@ export default {
sort: `${convertToSnakeCase(this.sort.sortBy)}_${this.sort.sortDesc ? 'desc' : 'asc'}`,
vetEnabled: this.canViewFalsePositive,
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
// use "last". See this comment for more info:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79834#note_831878506
......@@ -83,9 +96,11 @@ export default {
};
},
update(data) {
const vulnerabilities = deepFindVulnerabilities(data);
const vulnerabilities = this.getVulnerabilitiesFromData(data);
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() {
createFlash({
......@@ -148,7 +163,7 @@ export default {
: this.isLoadingVulnerabilities && this.vulnerabilities.length <= 0;
},
shouldUsePagination() {
return this.glFeatures.vulnerabilityReportPagination;
return Boolean(this.glFeatures.vulnerabilityReportPagination);
},
},
watch: {
......@@ -180,7 +195,8 @@ export default {
variables: { after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
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 {
pushQuerystring(data) {
this.$router.push({ query: { ...this.$route.query, ...data } });
},
getVulnerabilitiesFromData(data) {
return get(data, GRAPHQL_DATA_PATH[this.dashboardType]);
},
},
};
</script>
......@@ -211,6 +230,7 @@ export default {
:sort.sync="sort"
:should-show-project-namespace="showProjectNamespace"
:portal-name="portalName"
@vulnerability-clicked="$emit('vulnerability-clicked', $event)"
/>
<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"
query pipelineFindings(
$fullPath: ID!
$pipelineId: ID!
$first: Int
$last: Int
$before: String
$after: String
$severity: [String!]
$reportType: [String!]
......@@ -17,8 +19,10 @@ query pipelineFindings(
pipeline(iid: $pipelineId) {
id
securityReportFindings(
before: $before
after: $after
first: $first
last: $last
severity: $severity
reportType: $reportType
scanner: $scanner
......
......@@ -50,6 +50,9 @@ export default () => {
projectId: parseInt(projectId, 10),
commitPathTemplate,
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,
emptyStateSvgPath,
canAdminVulnerability: parseBoolean(canAdminVulnerability),
......
......@@ -12,6 +12,7 @@ module EE
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(:secure_vulnerability_training, project, default_enabled: :yaml)
push_frontend_feature_flag(:vulnerability_report_pagination, current_user, default_enabled: :yaml)
end
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, {
} from 'ee/security_dashboard/components/pipeline/scan_alerts.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 VulnerabilityReport from 'ee/security_dashboard/components/shared/vulnerability_report.vue';
import PipelineVulnerabilityReport from 'ee/security_dashboard/components/pipeline/pipeline_vulnerability_report.vue';
import {
pipelineSecurityReportSummary,
pipelineSecurityReportSummaryWithErrors,
......@@ -43,7 +43,7 @@ describe('Pipeline Security Dashboard component', () => {
let wrapper;
const findSecurityDashboard = () => wrapper.findComponent(SecurityDashboard);
const findVulnerabilityReport = () => wrapper.findComponent(VulnerabilityReport);
const findVulnerabilityReport = () => wrapper.findComponent(PipelineVulnerabilityReport);
const findScanAlerts = () => wrapper.findComponent(ScanAlerts);
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 { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { Portal } from 'portal-vue';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import ProjectVulnerabilities from 'ee/security_dashboard/components/project/project_vulnerabilities.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/project/security_scanner_alert.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_list.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 { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
import { generateVulnerabilities } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Vulnerabilities app component', () => {
useLocalStorageSpy();
let wrapper;
const apolloMock = {
queries: { vulnerabilities: { loading: true } },
};
const createWrapper = ({ props = {}, $apollo = apolloMock } = {}, options = {}) => {
wrapper = shallowMount(ProjectVulnerabilities, {
provide: {
projectFullPath: '#',
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
propsData: {
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
...props,
},
mocks: {
$apollo,
fetchNextPage: () => {},
},
...options,
});
};
const securityScannersHandler = async ({
available = [],
enabled = [],
pipelineRun = [],
} = {}) => ({
data: {
project: {
id: 'project-1',
securityScanners: { available, enabled, pipelineRun },
},
},
});
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findAlert = () => wrapper.findComponent(GlAlert);
const findSecurityScannerAlert = (root = wrapper) => root.findComponent(SecurityScannerAlert);
const findVulnerabilityList = () => wrapper.findComponent(VulnerabilityList);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const expectLoadingState = ({ initial = false, nextPage = false }) => {
expect(findVulnerabilityList().props('isLoading')).toBe(initial);
expect(findLoadingIcon().exists()).toBe(nextPage);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when the vulnerabilities are loading', () => {
beforeEach(() => {
createWrapper();
});
it('should show the initial loading state', () => {
expectLoadingState({ initial: true });
});
});
describe('with some vulnerabilities', () => {
let vulnerabilities;
beforeEach(() => {
createWrapper();
vulnerabilities = generateVulnerabilities();
// 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('should not show any loading state', () => {
expectLoadingState({ initial: false, nextPage: false });
});
it('should pass the vulnerabilities to the vulnerabilities list', () => {
expect(findVulnerabilityList().props().vulnerabilities).toEqual(vulnerabilities);
});
it('should not render the observer component', () => {
expect(findIntersectionObserver().exists()).toBe(false);
});
it('should not render the alert', () => {
expect(findAlert().exists()).toBe(false);
});
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', () => {
findVulnerabilityList().vm.$emit('sort-changed', {
sortBy: 'description',
sortDesc: false,
});
expect(wrapper.vm.sortBy).toBe('description');
expect(wrapper.vm.sortDirection).toBe('asc');
});
});
describe('with more than a page of vulnerabilities', () => {
let vulnerabilities;
beforeEach(() => {
createWrapper();
vulnerabilities = generateVulnerabilities();
// 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);
});
it('should render the next page loading spinner', () => {
expectLoadingState({ nextPage: true });
});
});
describe(`when there's an error loading vulnerabilities`, () => {
beforeEach(() => {
createWrapper();
// 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('should render the alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('when filter or sort is changed', () => {
beforeEach(() => {
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', () => {
findVulnerabilityList().vm.$emit('sort-changed', {
sortBy: 'description',
sortDesc: false,
});
expectLoadingState({ initial: true });
});
});
describe('filters prop', () => {
const vulnerabilitiesHandler = jest.fn().mockResolvedValue({
data: {
project: {
vulnerabilities: {
nodes: [],
pageInfo: { startCursor: '', endCursor: '' },
},
},
},
});
const createWrapperWithApollo = ({ filters }) => {
wrapper = shallowMount(ProjectVulnerabilities, {
localVue,
apolloProvider: createMockApollo([
[vulnerabilitiesQuery, vulnerabilitiesHandler],
[securityScannersQuery, securityScannersHandler],
]),
propsData: { filters },
provide: { groupFullPath: 'path' },
});
};
it('does not run the query when filters is null', () => {
createWrapperWithApollo({ filters: null });
expect(vulnerabilitiesHandler).not.toHaveBeenCalled();
});
it('runs query when filters is an object', () => {
createWrapperWithApollo({ filters: {} });
expect(vulnerabilitiesHandler).toHaveBeenCalled();
});
});
describe('security scanner alerts', () => {
const vulnerabilityReportAlertsPortal = 'test-alerts-portal';
const createWrapperForScannerAlerts = async ({ securityScanners }) => {
wrapper = shallowMount(ProjectVulnerabilities, {
localVue,
apolloProvider: createMockApollo([
[securityScannersQuery, () => securityScannersHandler(securityScanners)],
]),
provide: {
vulnerabilityReportAlertsPortal,
projectFullPath: 'path',
},
stubs: {
LocalStorageSync,
},
});
await waitForPromises();
};
describe.each`
available | enabled | pipelineRun | expectAlertShown
${['DAST']} | ${[]} | ${[]} | ${true}
${['DAST']} | ${['DAST']} | ${[]} | ${true}
${['DAST']} | ${[]} | ${['DAST']} | ${true}
${['DAST']} | ${['DAST']} | ${['DAST']} | ${false}
${[]} | ${[]} | ${[]} | ${false}
`('visibility', ({ available, enabled, pipelineRun, expectAlertShown }) => {
beforeEach(() => {});
it(`should${expectAlertShown ? '' : ' not'} show the alert`, async () => {
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
expect(findSecurityScannerAlert().exists()).toBe(expectAlertShown);
});
if (expectAlertShown) {
it('should portal the alert to the provided vulnerabilityReportAlertsPortal', async () => {
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
const portal = wrapper.findComponent(Portal);
expect(portal.props('to')).toBe(vulnerabilityReportAlertsPortal);
expect(findSecurityScannerAlert(portal).exists()).toBe(true);
});
}
it('should never show the alert once it has been dismissed', async () => {
window.localStorage.setItem(
ProjectVulnerabilities.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
);
await createWrapperForScannerAlerts({
securityScanners: { available, enabled, pipelineRun },
});
expect(findSecurityScannerAlert().exists()).toBe(false);
});
});
describe.each(Object.keys(SCANNER_NAMES_MAP))(
'When %s is available but not enabled',
(scanner) => {
const translatedScannerName = SCANNER_NAMES_MAP[scanner];
beforeEach(() => {
createWrapperForScannerAlerts({
securityScanners: { available: [scanner], enabled: [], pipelineRun: [] },
});
});
it(`passes the translated scanner's name to the alert (${translatedScannerName})`, () => {
expect(findSecurityScannerAlert().props('notEnabledScanners')[0]).toBe(
translatedScannerName,
);
});
},
);
describe('dismissal', () => {
beforeEach(() => {
return createWrapperForScannerAlerts({
securityScanners: { available: ['DAST'], enabled: [], pipelineRun: [] },
});
});
it('should hide the alert when it is dismissed', async () => {
const scannerAlert = findSecurityScannerAlert();
expect(scannerAlert.exists()).toBe(true);
scannerAlert.vm.$emit('dismiss');
await nextTick();
expect(scannerAlert.exists()).toBe(false);
});
it('should remember the dismissal state', async () => {
findSecurityScannerAlert().vm.$emit('dismiss');
await nextTick();
expect(window.localStorage.setItem.mock.calls).toContainEqual([
ProjectVulnerabilities.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
]);
});
});
});
});
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');
});
});
import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlFormCheckbox,
GlTable,
GlTruncate,
} from '@gitlab/ui';
import { capitalize } from 'lodash';
import { nextTick } from 'vue';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/shared/empty_states/dashboard_has_no_vulnerabilities.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/shared/empty_states/filters_produced_no_results.vue';
import IssuesBadge from 'ee/security_dashboard/components/shared/issues_badge.vue';
import SelectionSummary from 'ee/security_dashboard/components/shared/selection_summary.vue';
import VulnerabilityCommentIcon from 'ee/security_dashboard/components/shared/vulnerability_comment_icon.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_list.vue';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import FalsePositiveBadge from 'ee/vulnerabilities/components/false_positive_badge.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { trimText } from 'helpers/text_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import { generateVulnerabilities, vulnerabilities } from '../mock_data';
describe('Vulnerability list component', () => {
let wrapper;
const createWrapper = ({ props = {}, listeners, provide = {}, stubs } = {}) => {
return mountExtended(VulnerabilityList, {
propsData: {
vulnerabilities: [],
...props,
},
stubs: {
GlPopover: true,
...stubs,
},
listeners,
provide: () => ({
dashboardType: DASHBOARD_TYPES.PROJECT,
noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
notEnabledScannersHelpPath: '#',
noPipelineRunScannersHelpPath: '#',
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
canAdminVulnerability: true,
...provide,
}),
});
};
const locationText = ({ file, startLine }) => `${file}:${startLine}`;
const findTable = () => wrapper.findComponent(GlTable);
const findSortableColumn = () => wrapper.find('[aria-sort="descending"]');
const findCell = (label) => wrapper.find(`.js-${label}`);
const findRows = () => wrapper.findAll('tbody tr');
const findRow = (index = 0) => findRows().at(index);
const findColumn = (className) => wrapper.find(`[role="columnheader"].${className}`);
const findRowById = (id) => wrapper.find(`tbody tr[data-pk="${id}"`);
const findAutoFixBulbInRow = (row) => row.find('[data-testid="vulnerability-solutions-bulb"]');
const findIssuesBadge = (index = 0) => wrapper.findAllComponents(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.findComponent(RemediatedBadge);
const findSelectionSummary = () => wrapper.findComponent(SelectionSummary);
const findRowVulnerabilityCommentIcon = (row) =>
findRow(row).findComponent(VulnerabilityCommentIcon);
const findDataCell = (label) => wrapper.findByTestId(label);
const findDataCells = (label) => wrapper.findAll(`[data-testid="${label}"]`);
const findLocationCell = (id) => wrapper.findByTestId(`location-${id}`);
const findTitleCell = (id) => wrapper.findByTestId(`title-${id}`);
const findLocationTextWrapper = (cell) => cell.findComponent(GlTruncate);
const findFiltersProducedNoResults = () => wrapper.findComponent(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () =>
wrapper.findComponent(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.findByTestId('vulnerability-vendor');
const findCheckAllCheckbox = () => wrapper.findByTestId('vulnerability-checkbox-all');
const findAllRowCheckboxes = () => wrapper.findAllByTestId('vulnerability-checkbox');
afterEach(() => {
wrapper.destroy();
});
describe('with vulnerabilities', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
it('should render a list of vulnerabilities', () => {
expect(wrapper.findAll('.js-status')).toHaveLength(newVulnerabilities.length);
});
it('should correctly render the status', () => {
const cell = findCell('status');
expect(cell.text()).toBe(capitalize(newVulnerabilities[0].state));
});
it('should correctly render the severity', () => {
const cell = findCell('severity');
expect(cell.text().toLowerCase()).toBe(newVulnerabilities[0].severity);
});
it('should correctly render the description', () => {
const cell = findCell('description');
expect(cell.text()).toBe(newVulnerabilities[0].title);
});
it('should display the remediated badge', () => {
expect(findRemediatedBadge().exists()).toBe(true);
});
it('should display autoFixIcon for first Item', () => {
expect(findAutoFixBulbInRow(findRow(0)).exists()).toBe(true);
});
it('should not display autoFixIcon for second Item', () => {
expect(findAutoFixBulbInRow(findRow(1)).exists()).toBe(false);
});
it('should correctly render the identifier cell', () => {
const identifiers = findDataCells('vulnerability-identifier');
const extraIdentifierCounts = findDataCells('vulnerability-more-identifiers');
const firstIdentifiers = newVulnerabilities[0].identifiers;
expect(identifiers.at(0).text()).toBe(firstIdentifiers[0].name);
expect(trimText(extraIdentifierCounts.at(0).text())).toContain(
`${firstIdentifiers.length - 1} more`,
);
expect(identifiers.at(1).text()).toBe(newVulnerabilities[1].identifiers[0].name);
expect(extraIdentifierCounts).toHaveLength(1);
});
it('should correctly render the report type cell', () => {
const cells = findDataCells('vulnerability-report-type');
expect(cells.at(0).text()).toBe('SAST');
expect(cells.at(1).text()).toBe('Dependency Scanning');
expect(cells.at(2).text()).toBe('Custom scanner without translation');
expect(cells.at(3).text()).toBe('');
});
it('should correctly render the vulnerability vendor if the vulnerability vendor does exist', () => {
const cells = findDataCells('vulnerability-vendor');
expect(cells.at(0).text()).toBe('GitLab');
});
it('should correctly render an empty string if the vulnerability vendor does not exist', () => {
const cells = findDataCells('vulnerability-vendor');
expect(cells.at(3).text()).toBe('');
});
it('should not show the selection summary if no vulnerabilities are selected', () => {
expect(findSelectionSummary().props('visible')).toBe(false);
});
it('should show the selection summary when a checkbox is selected', async () => {
findDataCell('vulnerability-checkbox').setChecked(true);
await nextTick();
expect(findSelectionSummary().props('visible')).toBe(true);
});
it('should sync selected vulnerabilities when the vulnerability list is updated', async () => {
findDataCell('vulnerability-checkbox').setChecked(true);
await nextTick();
expect(findSelectionSummary().props('selectedVulnerabilities')).toHaveLength(1);
wrapper.setProps({ vulnerabilities: [] });
await nextTick();
expect(findSelectionSummary().props('visible')).toBe(false);
});
it('should uncheck a selected vulnerability after the vulnerability is updated', async () => {
const checkbox = () => findDataCell('vulnerability-checkbox');
checkbox().setChecked(true);
expect(checkbox().element.checked).toBe(true);
await nextTick();
findSelectionSummary().vm.$emit('vulnerability-updated', newVulnerabilities[0].id);
await nextTick();
expect(checkbox().element.checked).toBe(false);
});
describe.each([true, false])(
'issues badge when "hasJiraVulnerabilitiesIntegrationEnabled" is set to "%s"',
(hasJiraVulnerabilitiesIntegrationEnabled) => {
beforeEach(() => {
wrapper = createWrapper({
props: { vulnerabilities: generateVulnerabilities() },
provide: { hasJiraVulnerabilitiesIntegrationEnabled },
});
});
it('should display the issues badge for the first item', () => {
expect(findIssuesBadge(0).exists()).toBe(true);
});
it('should not display the issues badge for the second item', () => {
expect(() => findIssuesBadge(1)).toThrow();
});
it('should render the badge as Jira issues', () => {
expect(findIssuesBadge(0).props('isJira')).toBe(hasJiraVulnerabilitiesIntegrationEnabled);
});
},
);
});
describe('when user has no permission to admin vulnerabilities', () => {
beforeEach(() => {
wrapper = createWrapper({
props: { vulnerabilities },
provide: {
canAdminVulnerability: false,
},
});
});
it('should not show the checkboxes', () => {
expect(findDataCell('vulnerability-checkbox-all').exists()).toBe(false);
expect(findDataCell('vulnerability-checkbox').exists()).toBe(false);
});
});
describe('when displayed on instance or group level dashboard', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
wrapper = createWrapper({
props: { vulnerabilities: newVulnerabilities, shouldShowProjectNamespace: true },
});
});
it('should display the vulnerability locations for images', () => {
const { id, project, location } = newVulnerabilities[0];
const cell = findLocationCell(id);
expect(cell.text()).toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: location.image,
position: 'middle',
}),
);
});
it('should display the vulnerability locations for code', () => {
const { id, project, location } = newVulnerabilities[1];
const cell = findLocationCell(id);
expect(cell.text()).toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: locationText(location),
position: 'middle',
}),
);
});
it('should display the vulnerability locations for code with no line data', () => {
const { id, project, location } = newVulnerabilities[2];
const cell = findLocationCell(id);
expect(cell.text()).toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: location.file,
position: 'middle',
}),
);
});
it('should not display the vulnerability locations for vulnerabilities without a location', () => {
const { id, project } = newVulnerabilities[4];
const cellText = findLocationCell(id).text();
expect(cellText).toEqual(project.nameWithNamespace);
expect(cellText).not.toContain(':');
});
it('should display the vulnerability locations for path', () => {
const { id, project, location } = newVulnerabilities[5];
const cell = findLocationCell(id);
expect(cell.text()).toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: location.path,
position: 'middle',
}),
);
});
});
describe('when displayed on a project level dashboard', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
wrapper = createWrapper({
props: {
vulnerabilities: newVulnerabilities,
shouldShowIdentifier: true,
shouldShowReportType: true,
},
});
});
it('should not display the vulnerability group/project locations for images', () => {
const { id, project, location } = newVulnerabilities[0];
const cell = findLocationCell(id);
expect(cell.text()).not.toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: location.image,
position: 'middle',
}),
);
});
it('should display the detected time', () => {
const { id } = newVulnerabilities[1];
const cell = findDataCell(`detected-${id}`);
expect(cell.text()).toEqual(`2020-07-22`);
expect(cell.attributes('title')).toEqual('Jul 22, 2020 7:31pm UTC');
});
it('should display the vulnerability locations for code', () => {
const { id, project, location } = newVulnerabilities[1];
const cell = findLocationCell(id);
expect(cell.text()).not.toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: locationText(location),
position: 'middle',
}),
);
});
it('should make the file path linkable', () => {
const { id, location } = newVulnerabilities[1];
const cell = findLocationCell(id);
expect(cell.find('a').attributes('href')).toBe(`${location.blobPath}#L${location.startLine}`);
});
it('should not make the file path linkable if blobPath is missing', () => {
const { id } = newVulnerabilities[0];
const cell = findLocationCell(id);
expect(cell.find('a').exists()).toBe(false);
});
it('should not display the vulnerability group/project locations for code with no line data', () => {
const { id, project, location } = newVulnerabilities[2];
const cell = findLocationCell(id);
expect(cell.text()).not.toContain(project.nameWithNamespace);
expect(findLocationTextWrapper(cell).props()).toEqual(
expect.objectContaining({
text: location.file,
position: 'middle',
}),
);
});
});
describe('when has an issue associated', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].issueLinks = {
nodes: [
{
issue: {
title: 'my-title',
iid: 114,
state: 'opened',
webUrl: 'http://localhost/issues/~/114',
},
},
],
};
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
it('should emit "vulnerability-clicked" with the vulnerability as a payload when a vulnerability-link is clicked', async () => {
const clickedEventName = 'vulnerability-clicked';
const vulnerability = newVulnerabilities[1];
const link = findTitleCell(vulnerability.id).find('a');
expect(wrapper.emitted(clickedEventName)).toBe(undefined);
await link.trigger('click');
const emittedEvents = wrapper.emitted(clickedEventName);
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0][0]).toBe(vulnerability);
});
});
describe('when has comments', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].userNotesCount = 1;
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
it('should render the comments badge on the first vulnerability', () => {
expect(findRowVulnerabilityCommentIcon(0).exists()).toBe(true);
});
it('should not render the comments badge on the second vulnerability', () => {
expect(findRowVulnerabilityCommentIcon(1).exists()).toBe(false);
});
});
describe('when GitLab is the only scanner in the reports', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities = newVulnerabilities.map((v) => ({
...v,
scanner: { vendor: 'GitLab' },
}));
wrapper = createWrapper({
props: {
vulnerabilities: newVulnerabilities,
shouldShowReportType: true,
},
});
});
it('should not render the vendor name', () => {
expect(findVendorNames().exists()).toBe(false);
});
});
describe('when vendor name is not provided in the reports', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities = newVulnerabilities.map((v) => ({ ...v, scanner: { vendor: '' } }));
wrapper = createWrapper({
props: {
vulnerabilities: newVulnerabilities,
shouldShowReportType: true,
},
});
});
it('should not render the vendor name', () => {
expect(findVendorNames().exists()).toBe(false);
});
});
describe('when there are other scanners in the report', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].scanner = { vendor: 'GitLab' };
newVulnerabilities[1].scanner = { vendor: 'Third Party Scanner' };
wrapper = createWrapper({
props: {
vulnerabilities: newVulnerabilities,
shouldShowReportType: true,
},
});
});
it('should not render the vendor name', () => {
expect(findVendorNames().exists()).toBe(true);
});
});
describe('when a vulnerability has a false positive', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].falsePositive = true;
wrapper = createWrapper({
props: { vulnerabilities: newVulnerabilities },
provide: {
falsePositiveDocUrl: '/docs',
canViewFalsePositive: true,
},
});
});
it('should render the false positive info badge on the first vulnerability', () => {
const row = findRow(0);
const badge = row.findComponent(FalsePositiveBadge);
expect(badge.exists()).toEqual(true);
});
it('should not render the false positive info badge on the second vulnerability', () => {
const row = findRow(1);
const badge = row.findComponent(FalsePositiveBadge);
expect(badge.exists()).toEqual(false);
});
});
describe('when a vulnerability is resolved on the default branch', () => {
let newVulnerabilities;
beforeEach(() => {
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].resolvedOnDefaultBranch = true;
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
it('should render the remediated info badge on the first vulnerability', () => {
const row = findRow(0);
const badge = row.findComponent(RemediatedBadge);
expect(badge.exists()).toEqual(true);
});
it('should not render the remediated info badge on the second vulnerability', () => {
const row = findRow(1);
const badge = row.findComponent(RemediatedBadge);
expect(badge.exists()).toEqual(false);
});
});
describe('when loading', () => {
beforeEach(() => {
wrapper = createWrapper({ props: { isLoading: true } });
});
it('should show the loading state', () => {
expect(findCell('status').exists()).toEqual(false);
expect(wrapper.findComponent(GlSkeletonLoading).exists()).toEqual(true);
});
});
describe('with no vulnerabilities', () => {
beforeEach(() => {
wrapper = createWrapper({ props: { filters: { someFilter: 'true' } } });
});
it('should show the empty state', () => {
expect(findCell('status').exists()).toEqual(false);
expect(findFiltersProducedNoResults().exists()).toEqual(true);
expect(findDashboardHasNoVulnerabilities().exists()).toEqual(false);
});
});
describe('with vulnerabilities when there are filters', () => {
it.each`
state
${['DETECTED']}
${['DISMISSED']}
${[]}
${['DETECTED', 'DISMISSED']}
`('should only show vulnerabilities that match filter $state', (state) => {
wrapper = createWrapper({ props: { vulnerabilities, filters: { state } } });
const filteredVulnerabilities = vulnerabilities.filter((x) =>
state.length ? state.includes(x.state) : true,
);
expect(findRows().length).toBe(filteredVulnerabilities.length);
filteredVulnerabilities.forEach((vulnerability) => {
expect(findRowById(vulnerability.id).exists()).toBe(true);
});
});
});
describe('when has a sort-changed listener defined', () => {
let spy;
beforeEach(() => {
spy = jest.fn();
wrapper = createWrapper({
listeners: { 'sort-changed': spy },
});
});
it('is sortable', () => {
expect(findSortableColumn().attributes('class')).toContain('severity');
});
it('triggers the listener when sortBy is not an empty value', () => {
const args = { sortBy: 'severity', sortDesc: false };
findTable().vm.$emit('sort-changed', args);
expect(spy).toHaveBeenCalledWith(args);
});
it('triggers the listener when sortBy is camelCased and transforms it to snake_case', () => {
const args = { sortBy: 'reportType', sortDesc: false };
findTable().vm.$emit('sort-changed', args);
expect(spy).toHaveBeenCalledWith({ ...args, sortBy: 'report_type' });
});
it('does not trigger the listener when sortBy is an empty value', () => {
findTable().vm.$emit('sort-changed', {});
expect(spy).not.toHaveBeenCalled();
});
});
describe('when does not have a sort-changed listener defined', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('is not sortable', () => {
expect(findSortableColumn().exists()).toBe(false);
});
});
describe('row click', () => {
const findRowCheckbox = (index) =>
findRow(index).find('[data-testid="vulnerability-checkbox"]');
beforeEach(() => {
wrapper = createWrapper({ props: { vulnerabilities } });
});
it('will select and deselect vulnerabilities', async () => {
const rowCount = vulnerabilities.length;
const rowsToClick = [0, 1, 2];
const clickRows = () => rowsToClick.forEach((row) => findRow(row).trigger('click'));
const expectRowCheckboxesToBe = (condition) => {
for (let i = 0; i < rowCount; i += 1)
expect(findRowCheckbox(i).element.checked).toBe(condition(i));
};
clickRows();
await nextTick();
expectRowCheckboxesToBe((i) => rowsToClick.includes(i));
clickRows();
await nextTick();
expectRowCheckboxesToBe(() => false);
});
});
describe('select all checkbox', () => {
it('will toggle between selecting all and deselecting all vulnerabilities', async () => {
const getChecked = () => findAllRowCheckboxes().filter((x) => x.element.checked === true);
wrapper = createWrapper({ props: { vulnerabilities } });
// Sanity check to ensure that everything starts off unchecked.
expect(getChecked()).toHaveLength(0);
await findCheckAllCheckbox().trigger('click');
// First click should select all rows.
expect(getChecked()).toHaveLength(vulnerabilities.length);
await findCheckAllCheckbox().trigger('click');
// Second click should un-select all rows.
expect(getChecked()).toHaveLength(0);
});
it('will toggle the indeterminate state when some but not all vulnerabilities are selected', async () => {
const expectIndeterminateState = (state) =>
expect(findCheckAllCheckbox().props('indeterminate')).toBe(state);
wrapper = createWrapper({
props: { vulnerabilities },
stubs: { GlFormCheckbox: stubComponent(GlFormCheckbox, { props: ['indeterminate'] }) },
});
// We start off with no items selected, so no indeterminate state.
expectIndeterminateState(false);
await findRow(1).trigger('click');
// When we go from 0 to 1 item selected, indeterminate state should be true.
expectIndeterminateState(true);
await findRow(1).trigger('click');
// When we go from 1 to 0 items selected, indeterminate state should be false.
expectIndeterminateState(false);
// Check all items.
findCheckAllCheckbox().trigger('click');
// When all the items are selected, indeterminate state should be false.
expectIndeterminateState(false);
await findRow(1).trigger('click');
// When we uncheck an item when all items are selected, indeterminate state should be true.
expectIndeterminateState(true);
});
});
describe('when it is the pipeline dashboard', () => {
beforeEach(() => {
wrapper = createWrapper({
props: { vulnerabilities },
provide: { dashboardType: DASHBOARD_TYPES.PIPELINE },
stubs: {
GlTable,
},
});
});
it.each([['detected'], ['activity']])('does not render %s column', (className) => {
expect(findColumn(className).exists()).toBe(false);
});
it.each([['status'], ['severity'], ['description'], ['identifier'], ['scanner']])(
'renders %s column',
(className) => {
expect(findColumn(className).exists()).toBe(true);
},
);
});
});
......@@ -10,6 +10,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { FIELDS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
jest.mock('~/flash');
......@@ -60,6 +61,7 @@ describe('Vulnerability list GraphQL component', () => {
apolloProvider: createMockApollo([[vulnerabilitiesQuery, vulnerabilitiesHandler]]),
provide: {
fullPath,
dashboardType: DASHBOARD_TYPES.GROUP,
canViewFalsePositive,
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
module Project
module Secure
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
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
end
......
......@@ -12,7 +12,7 @@ module QA
element :security_report_content, required: true
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
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