Commit a6b0dffc authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '213623-back-project-status-with-sav-data' into 'master'

Use GraphlQl for project severity status

See merge request gitlab-org/gitlab!35031
parents 85461b92 2d24c1d4
......@@ -4,7 +4,7 @@ import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
......@@ -19,7 +19,7 @@ export default {
CsvExportButton,
SecurityDashboardLayout,
InstanceSecurityVulnerabilities,
VulnerabilitySeverity,
VulnerabilitySeverities,
VulnerabilityChart,
Filters,
GlLoadingIcon,
......@@ -35,10 +35,6 @@ export default {
type: String,
required: true,
},
vulnerableProjectsEndpoint: {
type: String,
required: true,
},
vulnerabilitiesExportEndpoint: {
type: String,
required: true,
......@@ -149,7 +145,7 @@ export default {
:query="vulnerabilityHistoryQuery"
class="mb-4"
/>
<vulnerability-severity v-if="shouldShowDashboard" :endpoint="vulnerableProjectsEndpoint" />
<vulnerability-severities v-if="shouldShowDashboard" :projects="projects" />
</template>
</security-dashboard-layout>
</template>
......@@ -72,12 +72,12 @@ export default {
mutation: addProjectToSecurityDashboard,
variables: { id: project.id },
update(store, { data: results }) {
const data = store.readQuery({
query: projectsQuery,
const data = store.readQuery({ query: projectsQuery });
const newProject = results.addProjectToSecurityDashboard.project;
data.instanceSecurityDashboard.projects.nodes.push({
...newProject,
vulnerabilitySeveritiesCount: newProject.vulnerabilitySeveritiesCount || null, // This is required to surpress missing field warning in GraphQL.
});
data.instanceSecurityDashboard.projects.nodes.push(
results.addProjectToSecurityDashboard.project,
);
store.writeQuery({ query: projectsQuery, data });
},
})
......
<script>
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { sum } from '~/lib/utils/number_utils';
import { isNumber } from 'lodash';
import { Accordion, AccordionItem } from 'ee/vue_shared/components/accordion';
import {
severityGroupTypes,
severityLevels,
severityLevelsTranslations,
SEVERITY_LEVELS_ORDERED_BY_SEVERITY,
SEVERITY_GROUPS,
} from 'ee/security_dashboard/store/modules/vulnerable_projects/constants';
export default {
css: {
severityGroups: {
[severityGroupTypes.F]: 'gl-text-red-900',
[severityGroupTypes.D]: 'gl-text-red-700',
[severityGroupTypes.C]: 'gl-text-orange-600',
[severityGroupTypes.B]: 'gl-text-orange-400',
[severityGroupTypes.A]: 'gl-text-green-500',
},
severityLevels: {
[severityLevels.CRITICAL]: 'gl-text-red-900',
[severityLevels.HIGH]: 'gl-text-red-700',
[severityLevels.UNKNOWN]: 'gl-text-gray-500',
[severityLevels.MEDIUM]: 'gl-text-orange-600',
[severityLevels.LOW]: 'gl-text-orange-500',
[severityLevels.NONE]: 'gl-text-green-500',
},
},
accordionItemsContentMaxHeight: '445px',
components: { Accordion, AccordionItem, GlLink, GlIcon },
directives: {
'gl-tooltip': GlTooltipDirective,
},
props: {
helpPagePath: {
type: String,
required: false,
default: '',
},
projects: {
type: Array,
required: true,
},
},
computed: {
severityGroups() {
return SEVERITY_GROUPS.map(group => ({
...group,
projects: this.findProjectsForGroup(group),
}));
},
},
methods: {
findProjectsForGroup(group) {
if (group.type === severityGroupTypes.A) {
return this.projects.filter(
project =>
Object.values(project.vulnerabilitySeveritiesCount || {})
.filter(i => isNumber(i))
.reduce(sum, 0) === 0,
);
}
return this.projects
.filter(project =>
group.severityLevels.some(level => project.vulnerabilitySeveritiesCount?.[level] > 0),
)
.map(project => ({
...project,
mostSevereVulnerability: this.findMostSevereVulnerabilityForGroup(project, group),
}));
},
findMostSevereVulnerabilityForGroup(project, group) {
const mostSevereVulnerability = {};
SEVERITY_LEVELS_ORDERED_BY_SEVERITY.some(level => {
if (!group.severityLevels.includes(level)) {
return false;
}
const hasVulnerabilityForThisLevel = project.vulnerabilitySeveritiesCount?.[level] > 0;
if (hasVulnerabilityForThisLevel) {
mostSevereVulnerability.level = level;
mostSevereVulnerability.count = project.vulnerabilitySeveritiesCount[level];
}
return hasVulnerabilityForThisLevel;
});
return mostSevereVulnerability;
},
shouldAccordionItemBeDisabled({ projects }) {
return projects?.length < 1;
},
cssForSeverityGroup({ type }) {
return this.$options.css.severityGroups[type];
},
cssForMostSevereVulnerability({ level }) {
return this.$options.css.severityLevels[level] || [];
},
severityText(severityLevel) {
return severityLevelsTranslations[severityLevel];
},
},
};
</script>
<template>
<section class="gl-border-solid gl-border-1 gl-border-gray-200 gl-rounded-base">
<header class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-200 gl-p-5">
<h4 class="gl-my-0">
{{ __('Project security status') }}
<gl-link
v-if="helpPagePath"
:href="helpPagePath"
:aria-label="__('Project security status help page')"
target="_blank"
><gl-icon name="question"
/></gl-link>
</h4>
<p class="gl-text-gray-700 gl-m-0">
{{ __('Projects are graded based on the highest severity vulnerability present') }}
</p>
</header>
<accordion class="gl-px-5">
<template #default="{ accordionId }">
<accordion-item
v-for="severityGroup in severityGroups"
:ref="`accordionItem${severityGroup.type}`"
:key="severityGroup.type"
:accordion-id="accordionId"
:disabled="shouldAccordionItemBeDisabled(severityGroup)"
:max-height="$options.accordionItemsContentMaxHeight"
>
<template #title="{ isExpanded, isDisabled }"
><h5 class="gl-display-flex gl-align-items-center gl-font-weight-normal gl-p-0 gl-m-0">
<span
v-gl-tooltip
:title="severityGroup.description"
class="gl-font-weight-bold gl-mr-5 gl-font-lg"
:class="cssForSeverityGroup(severityGroup)"
>
{{ severityGroup.type }}
</span>
<span :class="{ 'gl-font-weight-bold': isExpanded, 'gl-text-gray-700': isDisabled }">
{{ n__('%d project', '%d projects', severityGroup.projects.length) }}
</span>
</h5>
</template>
<template #subTitle>
<p class="gl-m-0 ml-5 gl-pb-2 gl-text-gray-700">{{ severityGroup.warning }}</p>
</template>
<div class="gl-ml-7 gl-pb-3">
<ul class="list-unstyled gl-py-2">
<li v-for="project in severityGroup.projects" :key="project.id" class="gl-py-3">
<gl-link target="_blank" :href="`/${project.fullPath}/-/security/dashboard`">{{
project.nameWithNamespace
}}</gl-link>
<span
v-if="project.mostSevereVulnerability"
ref="mostSevereCount"
class="d-block text-lowercase"
:class="cssForMostSevereVulnerability(project.mostSevereVulnerability)"
>{{ project.mostSevereVulnerability.count }}
{{ severityText(project.mostSevereVulnerability.level) }}
</span>
</li>
</ul>
</div>
</accordion-item>
</template>
</accordion>
</section>
</template>
......@@ -57,7 +57,6 @@ export default (
props.vulnerableProjectsEndpoint = el.dataset.vulnerableProjectsEndpoint;
} else if (dashboardType === DASHBOARD_TYPES.INSTANCE) {
component = FirstClassInstanceSecurityDashboard;
props.vulnerableProjectsEndpoint = el.dataset.vulnerableProjectsEndpoint;
}
const router = createRouter();
......
......@@ -5,9 +5,6 @@ mutation addProjectToSecurityDashboard($id: ID!) {
errors
project {
...Project
avatarUrl
nameWithNamespace
path
}
}
}
#import "ee/security_dashboard/graphql/project.fragment.graphql"
#import "./vulnerablity_severities_count.fragment.graphql"
query projectsQuery {
instanceSecurityDashboard {
projects {
nodes {
...Project
avatarUrl
nameWithNamespace
path
...VulnerabilitySeveritiesCount
}
}
}
......
......@@ -5,9 +5,6 @@ query getProjects($search: String!, $after: String = "", $first: Int!) {
projects(search: $search, after: $after, first: $first, membership: true) {
nodes {
...Project
avatarUrl
nameWithNamespace
path
}
pageInfo {
...PageInfo
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "ee/vulnerabilities/graphql/vulnerability.fragment.graphql"
#import "./project.fragment.graphql"
query instance(
$after: String
......@@ -10,13 +9,6 @@ query instance(
$reportType: [VulnerabilityReportType!]
$state: [VulnerabilityState!]
) {
instanceSecurityDashboard {
projects{
nodes{
...Project
}
}
}
vulnerabilities(
after: $after
first: $first
......
fragment Project on Project {
id
name
nameWithNamespace
fullPath
avatarUrl
path
}
fragment VulnerabilitySeveritiesCount on Project{
vulnerabilitySeveritiesCount{
critical
high
info
low
medium
unknown
}
}
---
title: Back Project Severity Status component with GraphQL data
merge_request: 35031
author:
type: changed
......@@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_class_instance_security_dashboard.vue';
import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
......
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import { n__ } from '~/locale';
import { trimText } from 'helpers/text_helper';
import { severityGroupTypes } from 'ee/security_dashboard/store/modules/vulnerable_projects/constants';
import { Accordion, AccordionItem } from 'ee/vue_shared/components/accordion';
import VulnerabilitySeverity from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
describe('Vulnerability Severity component', () => {
let wrapper;
const helpPagePath = 'http://localhost/help-me';
const projects = [
{
id: 'gid://gitlab/Project/11',
name: 'Security Reports Internal',
nameWithNamespace: 'Administrator / Security Reports Internal',
fullPath: 'root/security-reports-internal',
vulnerabilitySeveritiesCount: {
critical: 2,
high: 1,
info: 0,
low: 2,
medium: 5,
unknown: 3,
},
},
{
id: 'gid://gitlab/Project/1',
name: 'Gitlab Test',
nameWithNamespace: 'Gitlab Org / Gitlab Test',
fullPath: 'gitlab-org/gitlab-test',
vulnerabilitySeveritiesCount: {
critical: 0,
high: 0,
info: 1,
low: 0,
medium: 4,
unknown: 0,
},
},
{
id: 'gid://gitlab/Project/2',
name: 'Gitlab Test',
nameWithNamespace: 'Gitlab Org / Gitlab Test - 2',
fullPath: 'gitlab-org/gitlab-test',
vulnerabilitySeveritiesCount: {},
},
{
id: 'gid://gitlab/Project/3',
name: 'Gitlab Test',
nameWithNamespace: 'Gitlab Org / Gitlab Test - 3',
fullPath: 'gitlab-org/gitlab-test',
vulnerabilitySeveritiesCount: {
critical: 0,
},
},
];
const createWrapper = ({ propsData }) => {
return shallowMount(VulnerabilitySeverity, {
propsData: {
helpPagePath,
...propsData,
},
stubs: {
Accordion,
AccordionItem,
},
});
};
const findHelpLink = () => wrapper.find(GlLink);
const findHeader = () => wrapper.find('h4');
const findDescription = () => wrapper.find('p');
const findAccordionItemByGrade = grade => wrapper.find({ ref: `accordionItem${grade}` });
const findProjectName = accordion => accordion.findAll(GlLink);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
beforeEach(() => {
wrapper = createWrapper({ propsData: { projects } });
});
describe('for all cases', () => {
it('has the link to the help page', () => {
expect(findHelpLink().attributes('href')).toBe(helpPagePath);
});
it('has a correct header', () => {
expect(findHeader().text()).toBe('Project security status');
});
it('has a correct description', () => {
expect(findDescription().text()).toBe(
'Projects are graded based on the highest severity vulnerability present',
);
});
});
describe.each`
grade | relatedProjects | correspondingMostSevereVulnerability | levels
${severityGroupTypes.F} | ${[projects[0]]} | ${['2 Critical']} | ${'Critical'}
${severityGroupTypes.D} | ${[projects[0]]} | ${['1 High']} | ${'High or unknown'}
${severityGroupTypes.C} | ${[projects[0], projects[1]]} | ${['5 Medium', '4 Medium']} | ${'Medium'}
${severityGroupTypes.B} | ${[projects[0]]} | ${['2 Low']} | ${'Low'}
${severityGroupTypes.A} | ${[projects[2], projects[3]]} | ${['No vulnerabilities present', 'No vulnerabilities present']} | ${'No'}
`(
'for grade $grade',
({ grade, relatedProjects, correspondingMostSevereVulnerability, levels }) => {
let accordion;
let text;
beforeEach(() => {
accordion = findAccordionItemByGrade(grade);
text = trimText(accordion.text());
});
it('has a corresponding accordion item', () => {
expect(accordion.exists()).toBe(true);
});
it('has the projects listed in the accordion item', () => {
relatedProjects.forEach((project, i) => {
const projectLink = findProjectName(accordion).at(i);
expect(projectLink.text()).toBe(project.nameWithNamespace);
expect(projectLink.attributes('href')).toBe(`/${project.fullPath}/-/security/dashboard`);
});
});
it('states how many projects are there in the group', () => {
expect(text).toContain(n__('%d project', '%d projects', relatedProjects.length));
});
it('states which levels belong to the group', () => {
expect(text).toContain(`${levels} vulnerabilities present`);
});
it('states the most severe vulnerability', () => {
relatedProjects.forEach((_, i) => {
expect(text).toContain(correspondingMostSevereVulnerability[i]);
});
});
},
);
});
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