Commit a27dd323 authored by Savas Vedova's avatar Savas Vedova

Merge branch '338790-clone-vulnerability-list' into 'master'

Clone vulnerability list and move vulnerability counts component

See merge request gitlab-org/gitlab!72917
parents bfb16f42 97ae4a0d
......@@ -2,7 +2,7 @@
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityCounts from '../shared/vulnerability_counts.vue';
import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue';
export default {
components: { VulnerabilityCounts },
......
<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 !== '',
);
},
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"
@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" 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>
......@@ -2,7 +2,7 @@ import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_counts.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
......
import { GlCard, GlSkeletonLoading } from '@gitlab/ui';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_counts.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import {
CRITICAL,
......
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlTable, GlTruncate } from '@gitlab/ui';
import { capitalize } from 'lodash';
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 { 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.find(GlTruncate);
const findFiltersProducedNoResults = () => wrapper.findComponent(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () =>
wrapper.findComponent(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.findByTestId('vulnerability-vendor');
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 wrapper.vm.$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 wrapper.vm.$nextTick();
expect(findSelectionSummary().props('selectedVulnerabilities')).toHaveLength(1);
wrapper.setProps({ vulnerabilities: [] });
await wrapper.vm.$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 wrapper.vm.$nextTick();
findSelectionSummary().vm.$emit('vulnerability-updated', newVulnerabilities[0].id);
await wrapper.vm.$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({
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({
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({
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({
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({
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({
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({
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.find(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.find(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.find(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 wrapper.vm.$nextTick();
expectRowCheckboxesToBe((i) => rowsToClick.includes(i));
clickRows();
await wrapper.vm.$nextTick();
expectRowCheckboxesToBe(() => false);
});
});
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);
},
);
});
});
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