Commit 51eb87ee authored by Dave Pisek's avatar Dave Pisek

Show Jira issues count on vulnerabilities report

If the Jira vulnerabilities integration is enabled this change
will make sure that the issues-count badge on the Vulnerabilities
Report will display data from Jira issues.
parent f34aebd7
......@@ -15,6 +15,11 @@ export default {
type: Array,
required: true,
},
isJira: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
numberOfIssues() {
......@@ -40,8 +45,10 @@ export default {
<template #title>
{{ popoverTitle }}
</template>
<div>
<div v-for="{ issue } in issues" :key="issue.iid">
<issue-link :issue="issue" />
<issue-link :issue="issue" :is-jira="isJira" />
</div>
</div>
</gl-popover>
</div>
......
......@@ -16,7 +16,7 @@ export default {
GlIntersectionObserver,
VulnerabilityList,
},
inject: ['projectFullPath'],
inject: ['projectFullPath', 'hasJiraVulnerabilitiesIntegrationEnabled'],
props: {
filters: {
type: Object,
......@@ -42,6 +42,7 @@ export default {
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
...this.filters,
};
},
......
......@@ -52,7 +52,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['hasVulnerabilities'],
inject: ['hasVulnerabilities', 'hasJiraVulnerabilitiesIntegrationEnabled'],
props: {
filters: {
type: Object,
......@@ -275,9 +275,20 @@ export default {
this.$set(this.selectedVulnerabilities, `${vulnerability.id}`, vulnerability);
}
},
issues(item) {
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');
},
......@@ -440,7 +451,11 @@ export default {
<template #cell(activity)="{ item }">
<div class="gl-display-flex gl-justify-content-end">
<auto-fix-help-text v-if="item.hasSolutions" :merge-request="item.mergeRequest" />
<issues-badge v-if="issues(item).length > 0" :issues="issues(item)" />
<issues-badge
v-if="badgeIssues(item).length > 0"
:issues="badgeIssues(item)"
:is-jira="hasJiraVulnerabilitiesIntegrationEnabled"
/>
<remediated-badge v-if="item.resolvedOnDefaultBranch" class="gl-ml-3" />
</div>
</template>
......
......@@ -37,6 +37,7 @@ export default (el, dashboardType) => {
pipelinePath,
pipelineSecurityBuildsFailedCount,
pipelineSecurityBuildsFailedPath,
hasJiraVulnerabilitiesIntegrationEnabled,
} = el.dataset;
if (isUnavailable) {
......@@ -61,6 +62,9 @@ export default (el, dashboardType) => {
noPipelineRunScannersHelpPath,
hasVulnerabilities: parseBoolean(hasVulnerabilities),
scanners: scanners ? JSON.parse(scanners) : [],
hasJiraVulnerabilitiesIntegrationEnabled: parseBoolean(
hasJiraVulnerabilitiesIntegrationEnabled,
),
};
const props = {
......
......@@ -12,6 +12,7 @@ query project(
$sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
$includeExternalIssueLinks: Boolean = false
) {
project(fullPath: $fullPath) {
vulnerabilities(
......@@ -27,6 +28,16 @@ query project(
) {
nodes {
...Vulnerability
externalIssueLinks @include(if: $includeExternalIssueLinks) {
nodes {
issue: externalIssue {
externalTracker
webUrl
title
iid: relativeReference
}
}
}
hasSolutions
mergeRequest {
webUrl
......
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { GlIcon, GlLink, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui';
import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
export default {
components: {
......@@ -8,13 +9,24 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
props: {
issue: {
type: Object,
required: true,
},
isJira: {
type: Boolean,
required: false,
},
},
computed: {
iconName() {
return this.issue.state === this.$options.STATE_OPENED ? 'issue-open-m' : 'issue-close';
},
},
jiraLogo,
STATE_OPENED: 'opened',
};
</script>
......@@ -22,14 +34,19 @@ export default {
<gl-link
v-gl-tooltip="issue.title"
:href="issue.webUrl"
:data-testid="`issue-link-${issue.iid}`"
class="d-inline-flex align-items-center gl-flex-shrink-0"
target="__blank"
class="gl-display-inline-flex gl-align-items-center gl-flex-shrink-0"
>
<gl-icon
class="mr-1"
:class="{ cgreen: issue.state === $options.STATE_OPENED }"
:name="issue.state === $options.STATE_OPENED ? 'issue-open-m' : 'issue-close'"
/>
<span class="gl-mr-3">
<span
v-if="isJira"
v-safe-html="$options.jiraLogo"
class="gl-min-h-6 gl-display-inline-flex gl-align-items-center"
data-testid="jira-logo"
></span>
<gl-icon v-else :class="{ cgreen: issue.state === $options.STATE_OPENED }" :name="iconName" />
</span>
#{{ issue.iid }}
<gl-icon v-if="isJira" :size="12" name="external-link" class="gl-ml-1" />
</gl-link>
</template>
......@@ -235,6 +235,7 @@ module EE
if project.vulnerabilities.none?
{
has_vulnerabilities: 'false',
has_jira_vulnerabilities_integration_enabled: project.configured_to_create_issues_from_vulnerabilities?.to_s,
empty_state_svg_path: image_path('illustrations/security-dashboard_empty.svg'),
security_dashboard_help_path: help_page_path('user/application_security/security_dashboard/index'),
no_vulnerabilities_svg_path: image_path('illustrations/issues.svg'),
......@@ -243,6 +244,7 @@ module EE
else
{
has_vulnerabilities: 'true',
has_jira_vulnerabilities_integration_enabled: project.configured_to_create_issues_from_vulnerabilities?.to_s,
project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_export_endpoint: api_v4_security_projects_vulnerability_exports_path(id: project.id),
......
......@@ -37,7 +37,7 @@ module VulnerabilitiesHelper
end
def create_jira_issue_url_for(vulnerability)
return unless vulnerability.project.jira_vulnerabilities_integration_enabled?
return unless vulnerability.project.configured_to_create_issues_from_vulnerabilities?
decorated_vulnerability = vulnerability.present
summary = _('Investigate vulnerability: %{title}') % { title: decorated_vulnerability.title }
......
......@@ -198,7 +198,7 @@ module EE
delegate :auto_rollback_enabled, :auto_rollback_enabled=, :auto_rollback_enabled?, to: :ci_cd_settings
delegate :closest_gitlab_subscription, to: :namespace
delegate :jira_vulnerabilities_integration_enabled?, to: :jira_service, allow_nil: true
delegate :jira_vulnerabilities_integration_enabled?, :configured_to_create_issues_from_vulnerabilities?, to: :jira_service, allow_nil: true
delegate :requirements_access_level, :security_and_compliance_access_level, to: :project_feature, allow_nil: true
delegate :pipeline_configuration_full_path, to: :compliance_management_framework, allow_nil: true
......
......@@ -24,7 +24,10 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
$apollo,
fetchNextPage: () => {},
},
provide: { hasVulnerabilities: true },
provide: {
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
......
......@@ -47,7 +47,10 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
fetchNextPage: () => {},
},
data,
provide: { hasVulnerabilities: true },
provide: {
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
......
......@@ -5,12 +5,13 @@ import IssueLink from 'ee/vulnerabilities/components/issue_link.vue';
describe('Remediated badge component', () => {
const issues = [{ issue: { iid: 41 } }, { issue: { iid: 591 } }];
let wrapper;
const findIcon = () => wrapper.find(GlIcon);
const findBadge = () => wrapper.find(GlBadge);
const findIssueLink = () => wrapper.findAll(IssueLink);
const findPopover = () => wrapper.find(GlPopover);
const findIcon = () => wrapper.findComponent(GlIcon);
const findBadge = () => wrapper.findComponent(GlBadge);
const findIssueLinks = () => wrapper.findAllComponents(IssueLink);
const findPopover = () => wrapper.findComponent(GlPopover);
const createWrapper = ({ propsData }) => {
return shallowMount(IssuesBadge, { propsData, stubs: { GlPopover, GlBadge } });
......@@ -23,7 +24,7 @@ describe('Remediated badge component', () => {
describe('when there are multiple issues', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issues } });
wrapper = createWrapper({ propsData: { issues, externalIssues: [] } });
});
it('displays the correct icon', () => {
......@@ -36,7 +37,7 @@ describe('Remediated badge component', () => {
});
it('displays the issues', () => {
expect(findIssueLink()).toHaveLength(issues.length);
expect(findIssueLinks()).toHaveLength(issues.length);
});
it('displays the correct number of issues in the badge', () => {
......@@ -50,7 +51,7 @@ describe('Remediated badge component', () => {
describe('when there are no issues', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issues: [] } });
wrapper = createWrapper({ propsData: { issues: [], externalIssues: [] } });
});
it('displays the correct number of issues in the badge', () => {
......@@ -61,4 +62,18 @@ describe('Remediated badge component', () => {
expect(findPopover().text()).toBe('0 Issues');
});
});
describe.each([true, false])('with "isJira" prop set to "%s"', (isJira) => {
beforeEach(() => {
wrapper = createWrapper({
propsData: { issues, isJira },
});
});
it('passes the correct prop to the issue link', () => {
findIssueLinks().wrappers.forEach((issueLink) => {
expect(issueLink.props('isJira')).toBe(isJira);
});
});
});
});
......@@ -37,6 +37,9 @@ export const generateVulnerabilities = () => [
issueLinks: {
nodes: [{ issue: { iid: 15 } }],
},
externalIssueLinks: {
nodes: [{ issue: { iid: 15, externalTracker: 'jira' } }],
},
},
{
id: 'id_1',
......
......@@ -14,6 +14,7 @@ describe('Vulnerabilities app component', () => {
wrapper = shallowMount(ProjectVulnerabilitiesApp, {
provide: {
projectFullPath: '#',
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
propsData: {
dashboardDocumentation: '#',
......
......@@ -11,6 +11,7 @@ import VulnerabilityList, {
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
} from 'ee/security_dashboard/components/vulnerability_list.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { trimText } from 'helpers/text_helper';
import { generateVulnerabilities, vulnerabilities } from './mock_data';
......@@ -20,8 +21,9 @@ describe('Vulnerability list component', () => {
let wrapper;
const createWrapper = ({ props = {}, listeners } = {}) => {
return mount(VulnerabilityList, {
const createWrapper = ({ props = {}, listeners, provide = {} } = {}) => {
return extendedWrapper(
mount(VulnerabilityList, {
propsData: {
vulnerabilities: [],
...props,
......@@ -37,29 +39,34 @@ describe('Vulnerability list component', () => {
notEnabledScannersHelpPath: '#',
noPipelineRunScannersHelpPath: '#',
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
...provide,
}),
});
}),
);
};
const findTable = () => wrapper.find(GlTable);
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 findRowById = (id) => wrapper.find(`tbody tr[data-pk="${id}"`);
const findAutoFixBulbInRow = (row) => row.find('[data-testid="vulnerability-solutions-bulb"]');
const findIssuesBadge = (index = 0) => wrapper.findAll(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.find(RemediatedBadge);
const findSecurityScannerAlert = () => wrapper.find(SecurityScannerAlert);
const findIssuesBadge = (index = 0) => wrapper.findAllComponents(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.findComponent(RemediatedBadge);
const findSecurityScannerAlert = () => wrapper.findComponent(SecurityScannerAlert);
const findDismissalButton = () => findSecurityScannerAlert().find('button[aria-label="Dismiss"]');
const findSelectionSummary = () => wrapper.find(SelectionSummary);
const findRowVulnerabilityCommentIcon = (row) => findRow(row).find(VulnerabilityCommentIcon);
const findDataCell = (label) => wrapper.find(`[data-testid="${label}"]`);
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 findLocationTextWrapper = (cell) => cell.find(GlTruncate);
const findFiltersProducedNoResults = () => wrapper.find(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () => wrapper.find(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.find(`[data-testid="vulnerability-vendor"]`);
const findFiltersProducedNoResults = () => wrapper.findComponent(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () =>
wrapper.findComponent(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.findByTestId('vulnerability-vendor');
afterEach(() => {
wrapper.destroy();
......@@ -94,14 +101,6 @@ describe('Vulnerability list component', () => {
expect(cell.text()).toBe(newVulnerabilities[0].title);
});
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 display the remediated badge', () => {
expect(findRemediatedBadge().exists()).toBe(true);
});
......@@ -182,6 +181,30 @@ describe('Vulnerability list component', () => {
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 vulnerability selection is disabled', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink } from '@gitlab/ui';
import IssueLink from 'ee/vulnerabilities/components/issue_link.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getBinding, createMockDirective } from 'helpers/vue_mock_directive';
describe('IssueLink component', () => {
......@@ -13,48 +15,71 @@ describe('IssueLink component', () => {
});
const createWrapper = ({ propsData }) => {
return shallowMount(IssueLink, {
return extendedWrapper(
shallowMount(IssueLink, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
});
}),
);
};
const findIssueLink = (id) => wrapper.find(`[data-testid="issue-link-${id}"]`);
const findIssueWithState = (state) =>
wrapper.find(state === 'opened' ? 'issue-open-m' : 'issue-close');
const findIssueLink = () => wrapper.findComponent(GlLink);
const findIcon = () => wrapper.findComponent(GlIcon);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each([true, false])('both internal and Jira issues', (isJira) => {
const issue = createIssue();
beforeEach(() => {
wrapper = createWrapper({ propsData: { issue, isJira } });
});
it('should contain a link to the issue', () => {
expect(findIssueLink().attributes('href')).toBe(issue.webUrl);
});
it('should contain the title', () => {
const tooltip = getBinding(findIssueLink().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(issue.title);
});
});
describe('with internal issues', () => {
describe.each`
state | icon
${'opened'} | ${'issue-open-m'}
${'closed'} | ${'issue-close'}
`('when issue link is mounted', ({ state }) => {
describe(`with state ${state}`, () => {
const issue = createIssue({ state });
`('with state "$state"', ({ state, icon }) => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issue } });
wrapper = createWrapper({ propsData: { issue: createIssue({ state }) } });
});
it('should contain the correct issue icon', () => {
expect(findIssueWithState(state)).toBeTruthy();
expect(findIcon().attributes('name')).toBe(icon);
});
});
});
it('should contain a link to the issue', () => {
expect(findIssueLink(issue.iid).attributes('href')).toBe(issue.webUrl);
describe('with Jira issues', () => {
beforeEach(() => {
wrapper = createWrapper({
propsData: { issue: createIssue(), isJira: true },
});
});
it('should contain the title', () => {
const tooltip = getBinding(findIssueLink(issue.iid).element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(issue.title);
it('should contain a Jira logo icon', () => {
expect(wrapper.findByTestId('jira-logo').exists()).toBe(true);
});
it('should contain an external-link icon', () => {
expect(findIcon().attributes('name')).toBe('external-link');
});
});
});
......@@ -119,11 +119,13 @@ RSpec.describe ProjectsHelper do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:jira_service) { create(:jira_service, project: project, vulnerabilities_enabled: true, project_key: 'GV', vulnerabilities_issuetype: '10000') }
subject { helper.project_security_dashboard_config(project) }
before do
group.add_owner(user)
stub_licensed_features(jira_vulnerabilities_integration: true)
allow(helper).to receive(:current_user).and_return(user)
end
......@@ -131,6 +133,7 @@ RSpec.describe ProjectsHelper do
let(:expected_value) do
{
has_vulnerabilities: 'false',
has_jira_vulnerabilities_integration_enabled: 'true',
empty_state_svg_path: start_with('/assets/illustrations/security-dashboard_empty'),
security_dashboard_help_path: '/help/user/application_security/security_dashboard/index',
project_full_path: project.full_path,
......@@ -145,6 +148,7 @@ RSpec.describe ProjectsHelper do
let(:base_values) do
{
has_vulnerabilities: 'true',
has_jira_vulnerabilities_integration_enabled: 'true',
project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_export_endpoint: "/api/v4/security/projects/#{project.id}/vulnerability_exports",
......
......@@ -176,6 +176,7 @@ RSpec.describe VulnerabilitiesHelper do
context 'with jira vulnerabilities integration enabled' do
before do
allow(project).to receive(:jira_vulnerabilities_integration_enabled?).and_return(true)
allow(project).to receive(:configured_to_create_issues_from_vulnerabilities?).and_return(true)
end
let(:expected_jira_issue_description) do
......@@ -237,6 +238,7 @@ RSpec.describe VulnerabilitiesHelper do
context 'with jira vulnerabilities integration disabled' do
before do
allow(project).to receive(:jira_vulnerabilities_integration_enabled?).and_return(false)
allow(project).to receive(:configured_to_create_issues_from_vulnerabilities?).and_return(false)
end
it { expect(subject[:create_jira_issue_url]).to be_nil }
......
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