Commit 8ab82896 authored by samdbeckham's avatar samdbeckham

Adds the counts to the security dashboard

- Improves the vulnerabilities store to accomodate the counts
- Adds Components for the vulnerability counts
- Adds extra actions, getters, and methods for the counts
- Adds skeletal loading to the vulnerabilities tablet
- Adds integration tests for the components that use stores
parent 22192158
...@@ -7,7 +7,7 @@ import { BYTES_IN_KIB } from './constants'; ...@@ -7,7 +7,7 @@ import { BYTES_IN_KIB } from './constants';
* * * Show 3 digits to the right * * * Show 3 digits to the right
* * For 2 digits to the left of the decimal point and X digits to the right of it * * For 2 digits to the left of the decimal point and X digits to the right of it
* * * Show 2 digits to the right * * * Show 2 digits to the right
*/ */
export function formatRelevantDigits(number) { export function formatRelevantDigits(number) {
let digitsLeft = ''; let digitsLeft = '';
let relevantDigits = 0; let relevantDigits = 0;
...@@ -80,3 +80,22 @@ export function numberToHumanSize(size) { ...@@ -80,3 +80,22 @@ export function numberToHumanSize(size) {
} }
return `${bytesToGiB(size).toFixed(2)} GiB`; return `${bytesToGiB(size).toFixed(2)} GiB`;
} }
/**
* A simple method that returns the value of a + b
* It seems unessesary, but when combined with a reducer it
* adds up all the values in an array.
*
* e.g. `[1, 2, 3, 4, 5].reduce(sum) // => 15`
*
* @param {Float} a
* @param {Float} b
* @example
* // return 15
* [1, 2, 3, 4, 5].reduce(sum);
*
* // returns 6
* Object.values([{a: 1, b: 2, c: 3].reduce(sum);
* @returns {Float} The summed value
*/
export const sum = (a = 0, b = 0) => a + b;
<script> <script>
import { mapActions, mapGetters } from 'vuex';
import Tabs from '~/vue_shared/components/tabs/tabs'; import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue'; import Tab from '~/vue_shared/components/tabs/tab.vue';
import SecurityDashboardTable from './security_dashboard_table.vue'; import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityCountList from './vulnerability_count_list.vue';
export default { export default {
name: 'SecurityDashboardApp', name: 'SecurityDashboardApp',
...@@ -9,31 +11,34 @@ export default { ...@@ -9,31 +11,34 @@ export default {
Tabs, Tabs,
Tab, Tab,
SecurityDashboardTable, SecurityDashboardTable,
VulnerabilityCountList,
}, },
computed: { computed: {
count() { ...mapGetters('vulnerabilities', ['vulnerabilitiesCountByReportType']),
// TODO: Get the count from the overview API sastCount() {
return { return this.vulnerabilitiesCountByReportType('sast');
sast: null,
};
}, },
showSastCount() {
return this.count && this.count.sast;
}, },
created() {
this.fetchVulnerabilitiesCount();
},
methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilitiesCount']),
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<vulnerability-count-list />
<tabs stop-propagation> <tabs stop-propagation>
<tab active> <tab active>
<template slot="title"> <template slot="title">
{{ __('SAST') }} {{ __('SAST') }}
<span <span
v-if="showSastCount" v-if="sastCount"
class="badge badge-pill"> class="badge badge-pill">
{{ count.sast }} {{ sastCount }}
</span> </span>
</template> </template>
...@@ -42,4 +47,3 @@ export default { ...@@ -42,4 +47,3 @@ export default {
</tabs> </tabs>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapActions, mapState } from 'vuex';
import Pagination from '~/vue_shared/components/pagination_links.vue'; import Pagination from '~/vue_shared/components/pagination_links.vue';
import SecurityDashboardTableRow from './security_dashboard_table_row.vue'; import SecurityDashboardTableRow from './security_dashboard_table_row.vue';
export default { export default {
name: 'SecurityDashboardTable', name: 'SecurityDashboardTable',
components: { components: {
SecurityDashboardTableRow,
Pagination, Pagination,
SecurityDashboardTableRow,
}, },
computed: { computed: {
...mapGetters(['vulnerabilities', 'pageInfo', 'isLoading']), ...mapState('vulnerabilities', ['vulnerabilities', 'pageInfo', 'isLoadingVulnerabilities']),
showPagination() { showPagination() {
return this.pageInfo && this.pageInfo.total; return this.pageInfo && this.pageInfo.total;
}, },
...@@ -19,7 +19,7 @@ export default { ...@@ -19,7 +19,7 @@ export default {
this.fetchVulnerabilities(); this.fetchVulnerabilities();
}, },
methods: { methods: {
...mapActions(['fetchVulnerabilities']), ...mapActions('vulnerabilities', ['fetchVulnerabilities']),
}, },
}; };
</script> </script>
...@@ -27,7 +27,7 @@ export default { ...@@ -27,7 +27,7 @@ export default {
<template> <template>
<div class="ci-table"> <div class="ci-table">
<div <div
class="gl-responsive-table-row table-row-header" class="gl-responsive-table-row table-row-header vulnerabilities-row-header"
role="row" role="row"
> >
<div <div
...@@ -50,10 +50,13 @@ export default { ...@@ -50,10 +50,13 @@ export default {
</div> </div>
</div> </div>
<gl-loading-icon <div v-if="isLoadingVulnerabilities">
v-if="isLoading" <security-dashboard-table-row
:size="2" v-for="n in 10"
:key="n"
:is-loading="true"
/> />
</div>
<div v-else> <div v-else>
<security-dashboard-table-row <security-dashboard-table-row
...@@ -72,3 +75,10 @@ export default { ...@@ -72,3 +75,10 @@ export default {
</div> </div>
</template> </template>
<style>
.vulnerabilities-row-header {
color: #707070;
padding-left: 0.4em;
padding-right: 0.4em;
}
</style>
<script> <script>
import { SkeletonLoading } from '@gitlab-org/gitlab-ui';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import SecurityDashboardActionButtons from './security_dashboard_action_buttons.vue'; import SecurityDashboardActionButtons from './security_dashboard_action_buttons.vue';
...@@ -7,11 +8,18 @@ export default { ...@@ -7,11 +8,18 @@ export default {
components: { components: {
SeverityBadge, SeverityBadge,
SecurityDashboardActionButtons, SecurityDashboardActionButtons,
SkeletonLoading,
}, },
props: { props: {
vulnerability: { vulnerability: {
type: Object, type: Object,
required: true, required: false,
default: () => ({}),
},
isLoading: {
type: Boolean,
required: false,
default: false,
}, },
}, },
computed: { computed: {
...@@ -19,10 +27,7 @@ export default { ...@@ -19,10 +27,7 @@ export default {
return this.vulnerability.confidence || ''; return this.vulnerability.confidence || '';
}, },
severity() { severity() {
return this.vulnerability.severity || ''; return this.vulnerability.severity || ' ';
},
description() {
return this.vulnerability.description;
}, },
projectNamespace() { projectNamespace() {
const { project } = this.vulnerability; const { project } = this.vulnerability;
...@@ -54,7 +59,13 @@ export default { ...@@ -54,7 +59,13 @@ export default {
{{ s__('Reports|Vulnerability') }} {{ s__('Reports|Vulnerability') }}
</div> </div>
<div class="table-mobile-content"> <div class="table-mobile-content">
<span>{{ description }}</span> <skeleton-loading
v-if="isLoading"
class="mt-2 js-skeleton-loader"
:lines="2"
/>
<div v-else>
<span>{{ vulnerability.description }}</span>
<br /> <br />
<span <span
v-if="projectNamespace" v-if="projectNamespace"
...@@ -63,6 +74,7 @@ export default { ...@@ -63,6 +74,7 @@ export default {
</span> </span>
</div> </div>
</div> </div>
</div>
<div class="table-section section-10"> <div class="table-section section-10">
<div <div
...@@ -71,7 +83,7 @@ export default { ...@@ -71,7 +83,7 @@ export default {
> >
{{ s__('Reports|Confidence') }} {{ s__('Reports|Confidence') }}
</div> </div>
<div class="table-mobile-content"> <div class="table-mobile-content text-capitalize">
{{ confidence }} {{ confidence }}
</div> </div>
</div> </div>
...@@ -97,7 +109,7 @@ export default { ...@@ -97,7 +109,7 @@ export default {
<style> <style>
@media (min-width: 768px) { @media (min-width: 768px) {
.vulnerabilities-row { .vulnerabilities-row {
padding: .6em .4em; padding: 0.6em 0.4em;
} }
.vulnerabilities-row:hover, .vulnerabilities-row:hover,
...@@ -126,6 +138,6 @@ export default { ...@@ -126,6 +138,6 @@ export default {
.vulnerability-namespace { .vulnerability-namespace {
color: #707070; color: #707070;
font-size: .8em; font-size: 0.8em;
} }
</style> </style>
<script>
export default {
name: 'VulnerabilityCount',
props: {
severity: {
type: String,
required: true,
},
count: {
type: Number,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
className() {
return `vulnerability-count-${this.severity}`;
},
},
};
</script>
<template>
<div
class="vulnerability-count"
:class="className"
>
<div class="vulnerability-count-header">
{{ severity }}
</div>
<div class="vulnerability-count-body">
<span v-if="isLoading">&nbsp;</span>
<span v-else>{{ count }}</span>
</div>
</div>
</template>
<style>
.vulnerability-count {
background-color: #fafafa;
border-radius: 0.6em;
color: #505050;
display: block;
font-weight: bold;
margin-bottom: 1em;
overflow: hidden;
text-align: center;
}
.vulnerability-count-header {
background-color: #f2f2f2;
display: block;
padding: 0.4em;
text-transform: capitalize;
}
.vulnerability-count-body {
display: block;
font-size: 2em;
padding: 0.8em;
}
.vulnerability-count-critical {
background-color: #fff6f5;
color: #c0341e;
}
.vulnerability-count-critical .vulnerability-count-header {
background-color: #fae5e1;
}
.vulnerability-count-high {
background-color: #fffaf3;
color: #de7e00;
}
.vulnerability-count-high .vulnerability-count-header {
background-color: #fff1de;
}
.vulnerability-count-medium {
background-color: #f9f7fd;
color: #6d49cb;
}
.vulnerability-count-medium .vulnerability-count-header {
background-color: #ede8fb;
}
.vulnerability-count-unknown {
background-color: #ffffff;
border: 1px solid;
color: #707070;
}
.vulnerability-count-unknown .vulnerability-count-header {
background-color: #ffffff;
border-bottom: 1px solid;
}
</style>
<script>
import { mapGetters, mapState } from 'vuex';
import VulnerabilityCount from './vulnerability_count.vue';
import { CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN } from '../store/modules/vulnerabilities/constants';
const SEVERITIES = [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN];
export default {
name: 'VulnerabilityCountList',
components: {
VulnerabilityCount,
},
computed: {
...mapGetters('vulnerabilities', ['vulnerabilitiesCountBySeverity']),
...mapState('vulnerabilities', ['isLoadingVulnerabilitiesCount']),
counts() {
return SEVERITIES.map(severity => {
const count = this.vulnerabilitiesCountBySeverity(severity);
return { severity, count };
});
},
},
};
</script>
<template>
<div class="vulnerabilities-count-list">
<div class="row">
<div
v-for="count in counts"
:key="count.severity"
class="col-md col-sm-6 js-count"
>
<vulnerability-count
:severity="count.severity"
:count="count.count"
:is-loading="isLoadingVulnerabilitiesCount"
/>
</div>
</div>
</div>
</template>
<style>
.vulnerabilities-count-list {
display: block;
padding: 2.5em 0 1.5em;
border-bottom: 1px solid #e5e5e5;
margin-bottom: 3px;
}
</style>
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
import mockData from './mock_data.json';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export const fetchVulnerabilities = ({ dispatch }, params = {}) => { export const fetchVulnerabilitiesCount = ({ state, dispatch }) => {
dispatch('requestVulnerabilitiesCount');
axios({
method: 'GET',
url: state.vulnerabilitiesCountEndpoint,
})
.then(response => {
const { data } = response;
dispatch('receiveVulnerabilitiesCountSuccess', { data });
})
.catch(() => {
dispatch('receiveVulnerabilitiesCountError');
});
};
export const requestVulnerabilitiesCount = ({ commit }) => {
commit(types.REQUEST_VULNERABILITIES_COUNT);
};
export const receiveVulnerabilitiesCountSuccess = ({ commit }, response) => {
commit(types.RECEIVE_VULNERABILITIES_COUNT_SUCCESS, response.data);
};
export const receiveVulnerabilitiesCountError = ({ commit }) => {
commit(types.RECEIVE_VULNERABILITIES_COUNT_ERROR);
};
export const fetchVulnerabilities = ({ state, dispatch }, page = 1) => {
dispatch('requestVulnerabilities'); dispatch('requestVulnerabilities');
// TODO: Replace with axios when we can use the actual API axios({
Promise.resolve({ method: 'GET',
data: mockData, url: state.vulnerabilitiesEndpoint,
headers: { params: { page },
'X-Page': params.page || 1, })
'X-Next-Page': 2,
'X-Prev-Page': 1,
'X-Per-Page': 20,
'X-Total': 100,
'X-Total-Pages': 5,
} })
.then(response => { .then(response => {
dispatch('receiveVulnerabilitiesSuccess', response); const { headers, data } = response;
dispatch('receiveVulnerabilitiesSuccess', { headers, data });
}) })
.catch(error => { .catch(() => {
dispatch('receiveVulnerabilitiesError', error); dispatch('receiveVulnerabilitiesError');
}); });
}; };
export const requestVulnerabilities = ({ commit }) => { export const requestVulnerabilities = ({ commit }) => {
commit(types.SET_LOADING, true); commit(types.REQUEST_VULNERABILITIES);
}; };
export const receiveVulnerabilitiesSuccess = ({ commit }, response = {}) => { export const receiveVulnerabilitiesSuccess = ({ commit }, response = {}) => {
const normalizedHeaders = normalizeHeaders(response.headers); const normalizedHeaders = normalizeHeaders(response.headers);
const paginationInformation = parseIntPagination(normalizedHeaders); const pageInfo = parseIntPagination(normalizedHeaders);
const vulnerabilities = response.data;
commit(types.SET_LOADING, false); commit(types.RECEIVE_VULNERABILITIES_SUCCESS, { pageInfo, vulnerabilities });
commit(types.SET_VULNERABILITIES, response.data);
commit(types.SET_PAGINATION, paginationInformation);
}; };
export const receiveVulnerabilitiesError = ({ commit }) => { export const receiveVulnerabilitiesError = ({ commit }) => {
// TODO: Show error state when we get it from UX commit(types.RECEIVE_VULNERABILITIES_ERROR);
commit(types.SET_LOADING, false);
}; };
export default () => {}; export default () => {};
export const CRITICAL = 'critical';
export const HIGH = 'high';
export const MEDIUM = 'medium';
export const LOW = 'low';
export const UNKNOWN = 'unknown';
export const isLoading = state => state.isLoading; import { sum } from '~/lib/utils/number_utils';
export const pageInfo = state => state.pageInfo;
export const vulnerabilities = state => state.vulnerabilities || []; export const vulnerabilitiesCountBySeverity = state => severity =>
Object.values(state.vulnerabilitiesCount)
.map(count => count[severity])
.reduce(sum, 0);
export const vulnerabilitiesCountByReportType = state => type => {
const counts = state.vulnerabilitiesCount[type];
return counts ? Object.values(counts).reduce(sum, 0) : 0;
};
export default () => {}; export default () => {};
...@@ -4,6 +4,7 @@ import * as getters from './getters'; ...@@ -4,6 +4,7 @@ import * as getters from './getters';
import * as actions from './actions'; import * as actions from './actions';
export default { export default {
namespaced: true,
state, state,
mutations, mutations,
getters, getters,
......
{
"sast": {
"critical": 2,
"high": 4,
"low": 7,
"medium": 8,
"unknown": 9
},
"container_scanning": {
"critical": 3,
"high": 3,
"low": 2,
"medium": 9,
"unknown": 7
},
"dependency_scanning": {
"critical": 2,
"high": 3,
"low": 9,
"medium": 4,
"unknown": 7
},
"dast": {
"critical": 2,
"high": 3,
"low": 9,
"medium": 4,
"unknown": 7
}
}
export const SET_LOADING = 'SET_LOADING'; export const REQUEST_VULNERABILITIES = 'REQUEST_VULNERABILITIES';
export const SET_PAGINATION = 'SET_PAGINATION'; export const RECEIVE_VULNERABILITIES_SUCCESS = 'RECEIVE_VULNERABILITIES_SUCCESS';
export const SET_VULNERABILITIES = 'SET_VULNERABILITIES'; export const RECEIVE_VULNERABILITIES_ERROR = 'RECEIVE_VULNERABILITIES_ERROR';
export const REQUEST_VULNERABILITIES_COUNT = 'REQUEST_VULNERABILITIES_COUNT';
export const RECEIVE_VULNERABILITIES_COUNT_SUCCESS = 'RECEIVE_VULNERABILITIES_COUNT_SUCCESS';
export const RECEIVE_VULNERABILITIES_COUNT_ERROR = 'RECEIVE_VULNERABILITIES_COUNT_ERROR';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_LOADING](state, payload) { [types.REQUEST_VULNERABILITIES](state) {
state.isLoading = payload; state.isLoadingVulnerabilities = true;
}, },
[types.SET_PAGINATION](state, payload) { [types.RECEIVE_VULNERABILITIES_SUCCESS](state, payload) {
state.pageInfo = payload; state.isLoadingVulnerabilities = false;
state.errorLoadingVulnerabilities = false;
state.pageInfo = payload.pageInfo;
state.vulnerabilities = payload.vulnerabilities;
}, },
[types.SET_VULNERABILITIES](state, payload) { [types.RECEIVE_VULNERABILITIES_ERROR](state) {
state.vulnerabilities = payload; state.isLoadingVulnerabilities = false;
state.errorLoadingVulnerabilities = true;
},
[types.REQUEST_VULNERABILITIES_COUNT](state) {
state.isLoadingVulnerabilitiesCount = true;
},
[types.RECEIVE_VULNERABILITIES_COUNT_SUCCESS](state, payload) {
state.isLoadingVulnerabilitiesCount = false;
state.errorLoadingVulnerabilities = false;
state.vulnerabilitiesCount = payload;
},
[types.RECEIVE_VULNERABILITIES_COUNT_ERROR](state) {
state.isLoadingVulnerabilitiesCount = false;
state.errorLoadingVulnerabilities = true;
}, },
}; };
export default () => ({ export default () => ({
vulnerabilitiesUrl: false, isLoadingVulnerabilities: false,
vulnerabilities: [], isLoadingVulnerabilitiesCount: false,
pageInfo: {}, pageInfo: {},
isLoading: false, vulnerabilities: [],
vulnerabilitiesCount: {},
errorLoadingVulnerabilities: false,
}); });
<script> <script>
export default { export default {
name: 'SeverityBadge', name: 'SeverityBadge',
props: { props: {
...@@ -10,7 +9,7 @@ export default { ...@@ -10,7 +9,7 @@ export default {
}, },
computed: { computed: {
className() { className() {
return `severity-badge severity-badge-${this.severity}`; return `severity-badge-${this.severity}`;
}, },
}, },
}; };
...@@ -18,6 +17,7 @@ export default { ...@@ -18,6 +17,7 @@ export default {
<template> <template>
<div <div
class="severity-badge"
:class="className" :class="className"
>{{ severity }}</div> >{{ severity }}</div>
</template> </template>
...@@ -25,13 +25,13 @@ export default { ...@@ -25,13 +25,13 @@ export default {
<style> <style>
.severity-badge { .severity-badge {
background-color: #f2f2f2; background-color: #f2f2f2;
border-radius: .3em; border-radius: 0.3em;
color: #505050; color: #505050;
display: inline-block; display: inline-block;
font-size: .9em; font-size: 0.9em;
font-weight: bold; font-weight: bold;
line-height: 1em; line-height: 1em;
padding: .6em .4em .4em; padding: 0.6em 0.4em 0.4em;
text-transform: uppercase; text-transform: uppercase;
} }
...@@ -56,4 +56,3 @@ export default { ...@@ -56,4 +56,3 @@ export default {
color: #707070; color: #707070;
} }
</style> </style>
---
title: Adds group-level Security Dashboard counts
merge_request: 7564
author:
type: added
...@@ -4,117 +4,73 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; ...@@ -4,117 +4,73 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Security Dashboard Table Row', () => { describe('Security Dashboard Table Row', () => {
let vm; let vm;
let vulnerability; let props;
const Component = Vue.extend(component); const Component = Vue.extend(component);
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
describe('severity', () => { describe('when loading', () => {
it('should pass high severity down to the component', () => { beforeEach(() => {
vulnerability = { severity: 'high' }; props = { isLoading: true };
vm = mountComponent(Component, props);
vm = mountComponent(Component, { vulnerability });
expect(vm.severity).toBe(vulnerability.severity);
});
it('should compute a `–` when no severity is passed', () => {
vulnerability = {};
vm = mountComponent(Component, { vulnerability });
expect(vm.severity).toBe('');
});
});
describe('description', () => {
it('should pass high confidence down to the component', () => {
vulnerability = { description: 'high' };
vm = mountComponent(Component, { vulnerability });
expect(vm.description).toBe(vulnerability.description);
});
});
describe('project namespace', () => {
it('should get the project namespace from the vulnerability', () => {
vulnerability = {
project: { name_with_namespace: 'project name' },
};
vm = mountComponent(Component, { vulnerability });
expect(vm.projectNamespace).toBe(vulnerability.project.name_with_namespace);
});
it('should return null when no namespace is set', () => {
vulnerability = { project: {} };
vm = mountComponent(Component, { vulnerability });
expect(vm.projectNamespace).toBeNull();
}); });
it('should return null when no project is set', () => { it('should display the skeleton loader', () => {
vulnerability = {}; expect(vm.$el.querySelector('.js-skeleton-loader')).not.toBeNull();
vm = mountComponent(Component, { vulnerability });
expect(vm.projectNamespace).toBeNull();
});
}); });
describe('confidence', () => { it('should render a ` ` for severity', () => {
it('should pass high confidence down to the component', () => { expect(vm.severity).toEqual(' ');
vulnerability = { confidence: 'high' }; expect(vm.$el.querySelectorAll('.table-mobile-content')[0].textContent).toContain(' ');
vm = mountComponent(Component, { vulnerability });
expect(vm.confidence).toBe(vulnerability.confidence);
}); });
it('should compute a `–` when no confidence is passed', () => { it('should render a `–` for confidence', () => {
vulnerability = {}; expect(vm.confidence).toEqual('');
expect(vm.$el.querySelectorAll('.table-mobile-content')[2].textContent).toContain('');
vm = mountComponent(Component, { vulnerability });
expect(vm.confidence).toBe('');
}); });
}); });
describe('rendered output', () => { describe('when loaded', () => {
beforeEach(() => { beforeEach(() => {
vulnerability = { const vulnerability = {
severity: 'high',
description: 'Test vulnerability',
confidence: 'medium',
project: { name_with_namespace: 'project name' }, project: { name_with_namespace: 'project name' },
confidence: 'high',
description: 'this is a description',
severity: 'low',
}; };
vm = mountComponent(Component, { vulnerability }); props = { vulnerability };
vm = mountComponent(Component, props);
});
it('should not display the skeleton loader', () => {
expect(vm.$el.querySelector('.js-skeleton-loader')).not.toExist();
}); });
it('should render the severity', () => { it('should render the severity', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[0].textContent) expect(vm.$el.querySelectorAll('.table-mobile-content')[0].textContent).toContain(
.toContain(vulnerability.severity); props.vulnerability.severity,
);
}); });
it('should render the description', () => { it('should render the description', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent) expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent).toContain(
.toContain(vulnerability.description); props.vulnerability.description,
);
}); });
it('should render the project namespace', () => { it('should render the project namespace', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent) expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent).toContain(
.toContain(vulnerability.project.name_with_namespace); props.vulnerability.project.name_with_namespace,
);
}); });
it('should render the confidence', () => { it('should render the confidence', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[2].textContent) expect(vm.$el.querySelectorAll('.table-mobile-content')[2].textContent).toContain(
.toContain(vulnerability.confidence); props.vulnerability.confidence,
);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import MockAdapater from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import component from 'ee/security_dashboard/components/security_dashboard_table.vue'; import component from 'ee/security_dashboard/components/security_dashboard_table.vue';
import createStore from 'ee/security_dashboard/store';
import mockDataVulnerabilities from 'ee/security_dashboard/store/modules/vulnerabilities/mock_data_vulnerabilities.json';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import waitForPromises from 'spec/helpers/wait_for_promises';
import { resetStore } from '../helpers';
describe('Security Dashboard Table', () => { describe('Security Dashboard Table', () => {
const vulnerabilities = [{ id: 0 }, { id: 1 }, { id: 2 }]; const Component = Vue.extend(component);
const vulnerabilitiesEndpoint = '/vulnerabilitiesEndpoint.json';
let store;
let mock;
let vm; let vm;
let getters;
let actions;
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(component); mock = new MockAdapater(axios);
getters = { store = createStore();
vulnerabilities: () => vulnerabilities, store.state.vulnerabilities.vulnerabilitiesEndpoint = vulnerabilitiesEndpoint;
pageInfo: () => null,
};
actions = {
fetchVulnerabilities: jasmine.createSpy('fetchVulnerabilities'),
};
const store = new Vuex.Store({ actions, getters });
vm = mountComponentWithStore(Component, { store });
}); });
afterEach(() => { afterEach(() => {
actions.fetchVulnerabilities.calls.reset(); resetStore(store);
vm.$destroy(); vm.$destroy();
mock.restore();
}); });
it('should dispatch a `fetchVulnerabilities` action on creation', () => { describe('while loading', () => {
expect(actions.fetchVulnerabilities).toHaveBeenCalledTimes(1); beforeEach(() => {
store.dispatch('vulnerabilities/requestVulnerabilities');
vm = mountComponentWithStore(Component, { store });
}); });
it('should render a row for each vulnerability', () => { it('should render 10 skeleton rows in the table', () => {
expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(vulnerabilities.length); expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(10);
});
});
describe('with success result', () => {
beforeEach(() => {
mock.onGet(vulnerabilitiesEndpoint).replyOnce(200, mockDataVulnerabilities);
vm = mountComponentWithStore(Component, { store });
});
it('should render a row for each vulnerability', done => {
waitForPromises()
.then(() => {
expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(
mockDataVulnerabilities.length,
);
done();
})
.catch(done.fail);
});
}); });
}); });
import Vue from 'vue';
import component from 'ee/security_dashboard/components/vulnerability_count_list.vue';
import createStore from 'ee/security_dashboard/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('Vulnerability Count List', () => {
const Component = Vue.extend(component);
const store = createStore();
const counts = {
sast: {
critical: 22,
},
};
let vm;
beforeEach(() => {
store.dispatch('vulnerabilities/receiveVulnerabilitiesCountSuccess', { data: counts });
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
vm.$destroy();
resetStore(store);
});
it('should fetch the counts for each severity', () => {
expect(vm.counts[0]).toEqual({ severity: 'critical', count: 22 });
});
it('should render a counter for each severity', () => {
expect(vm.$el.querySelectorAll('.js-count')).toHaveLength(vm.counts.length);
});
});
import Vue from 'vue';
import component from 'ee/security_dashboard/components/vulnerability_count.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Vulnerability Count', () => {
const Component = Vue.extend(component);
let vm;
let props;
beforeEach(() => {
const severity = 'high';
const count = 100;
props = { severity, count };
vm = mountComponent(Component, props);
});
afterEach(() => {
vm.$destroy();
});
it('should render the severity label', () => {
const header = vm.$el.querySelector('.vulnerability-count-header');
expect(header.textContent).toMatch(props.severity);
});
it('should render the count', () => {
const body = vm.$el.querySelector('.vulnerability-count-body');
expect(body.textContent).toMatch(props.count.toString());
});
});
import vulnerabilitiesState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
const newState = {
vulnerabilities: vulnerabilitiesState(),
};
store.replaceState(newState);
};
...@@ -3,13 +3,117 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,13 +3,117 @@ import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import mockData from 'ee/security_dashboard/store/modules/vulnerabilities/mock_data.json'; import mockDataVulnerabilities from 'ee/security_dashboard/store/modules/vulnerabilities/mock_data_vulnerabilities.json';
import mockDataVulnerabilitiesCount from 'ee/security_dashboard/store/modules/vulnerabilities/mock_data_vulnerabilities_count.json';
import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state'; import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types'; import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/vulnerabilities/actions'; import * as actions from 'ee/security_dashboard/store/modules/vulnerabilities/actions';
describe('vulnerabilities module actions', () => { describe('vulnerabiliites count actions', () => {
const data = mockData; const data = mockDataVulnerabilitiesCount;
describe('fetchVulnerabilitesCount', () => {
let mock;
const state = initialState;
beforeEach(() => {
state.vulnerabilitiesCountEndpoint = `${TEST_HOST}/vulnerabilities_count.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesCountEndpoint).replyOnce(200, data);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.fetchVulnerabilitiesCount,
{},
state,
[],
[
{ type: 'requestVulnerabilitiesCount' },
{
type: 'receiveVulnerabilitiesCountSuccess',
payload: { data },
},
],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet(state.vulnerabilitiesCountEndpoint).replyOnce(404, {});
});
it('should dispatch the request and error actions', done => {
testAction(
actions.fetchVulnerabilitiesCount,
{},
state,
[],
[{ type: 'requestVulnerabilitiesCount' }, { type: 'receiveVulnerabilitiesCountError' }],
done,
);
});
});
});
describe('requestVulnerabilitesCount', () => {
it('should commit the request mutation', done => {
const state = initialState;
testAction(
actions.requestVulnerabilitiesCount,
{},
state,
[{ type: types.REQUEST_VULNERABILITIES_COUNT }],
[],
done,
);
});
});
describe('receiveVulnerabilitesCountSuccess', () => {
it('should commit the success mutation', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesCountSuccess,
{ data },
state,
[{ type: types.RECEIVE_VULNERABILITIES_COUNT_SUCCESS, payload: data }],
[],
done,
);
});
});
describe('receivetVulnerabilitesCountError', () => {
it('should commit the error mutation', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesCountError,
{},
state,
[{ type: types.RECEIVE_VULNERABILITIES_COUNT_ERROR }],
[],
done,
);
});
});
});
describe('vulnerabilities actions', () => {
const data = mockDataVulnerabilities;
const pageInfo = { const pageInfo = {
page: 1, page: 1,
nextPage: 2, nextPage: 2,
...@@ -27,12 +131,12 @@ describe('vulnerabilities module actions', () => { ...@@ -27,12 +131,12 @@ describe('vulnerabilities module actions', () => {
'X-Total-Pages': pageInfo.totalPages, 'X-Total-Pages': pageInfo.totalPages,
}; };
describe('fetch vulnerabilities', () => { describe('fetchVulnerabilities', () => {
let mock; let mock;
const state = initialState; const state = initialState;
beforeEach(() => { beforeEach(() => {
state.vulnerabilitiesUrl = `${TEST_HOST}/vulnerabilities.json`; state.vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities.json`;
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
...@@ -42,9 +146,7 @@ describe('vulnerabilities module actions', () => { ...@@ -42,9 +146,7 @@ describe('vulnerabilities module actions', () => {
describe('on success', () => { describe('on success', () => {
beforeEach(() => { beforeEach(() => {
mock mock.onGet(state.vulnerabilitiesEndpoint).replyOnce(200, data, headers);
.onGet(state.vulnerabilitiesUrl)
.replyOnce(200, data, headers);
}); });
it('should dispatch the request and success actions', done => { it('should dispatch the request and success actions', done => {
...@@ -65,14 +167,9 @@ describe('vulnerabilities module actions', () => { ...@@ -65,14 +167,9 @@ describe('vulnerabilities module actions', () => {
}); });
}); });
// NOTE: This will fail as we're currently mocking the API call in the action describe('on error', () => {
// so the mock adaptor can't pick it up.
// eslint-disable-next-line
xdescribe('on error', () => {
beforeEach(() => { beforeEach(() => {
mock mock.onGet(state.vulnerabilitiesEndpoint).replyOnce(404, {});
.onGet(state.vulnerabilitiesUrl)
.replyOnce(404, {});
}); });
it('should dispatch the request and error actions', done => { it('should dispatch the request and error actions', done => {
...@@ -81,13 +178,7 @@ describe('vulnerabilities module actions', () => { ...@@ -81,13 +178,7 @@ describe('vulnerabilities module actions', () => {
{}, {},
state, state,
[], [],
[ [{ type: 'requestVulnerabilities' }, { type: 'receiveVulnerabilitiesError' }],
{ type: 'requestVulnerabilities' },
{
type: 'receiveVulnerabilitiesError',
payload: {},
},
],
done, done,
); );
}); });
...@@ -95,7 +186,7 @@ describe('vulnerabilities module actions', () => { ...@@ -95,7 +186,7 @@ describe('vulnerabilities module actions', () => {
}); });
describe('receiveVulnerabilitiesSuccess', () => { describe('receiveVulnerabilitiesSuccess', () => {
it('should commit the required mutations', done => { it('should commit the success mutation', done => {
const state = initialState; const state = initialState;
testAction( testAction(
...@@ -103,9 +194,10 @@ describe('vulnerabilities module actions', () => { ...@@ -103,9 +194,10 @@ describe('vulnerabilities module actions', () => {
{ headers, data }, { headers, data },
state, state,
[ [
{ type: types.SET_LOADING, payload: false }, {
{ type: types.SET_VULNERABILITIES, payload: data }, type: types.RECEIVE_VULNERABILITIES_SUCCESS,
{ type: types.SET_PAGINATION, payload: pageInfo }, payload: { pageInfo, vulnerabilities: data },
},
], ],
[], [],
done, done,
...@@ -114,16 +206,14 @@ describe('vulnerabilities module actions', () => { ...@@ -114,16 +206,14 @@ describe('vulnerabilities module actions', () => {
}); });
describe('receiveVulnerabilitiesError', () => { describe('receiveVulnerabilitiesError', () => {
it('should commit the loading mutation', done => { it('should commit the error mutation', done => {
const state = initialState; const state = initialState;
testAction( testAction(
actions.receiveVulnerabilitiesError, actions.receiveVulnerabilitiesError,
{}, {},
state, state,
[ [{ type: types.RECEIVE_VULNERABILITIES_ERROR }],
{ type: types.SET_LOADING, payload: false },
],
[], [],
done, done,
); );
...@@ -131,16 +221,14 @@ describe('vulnerabilities module actions', () => { ...@@ -131,16 +221,14 @@ describe('vulnerabilities module actions', () => {
}); });
describe('requestVulnerabilities', () => { describe('requestVulnerabilities', () => {
it('should commit the loading mutation', done => { it('should commit the request mutation', done => {
const state = initialState; const state = initialState;
testAction( testAction(
actions.requestVulnerabilities, actions.requestVulnerabilities,
{}, {},
state, state,
[ [{ type: types.REQUEST_VULNERABILITIES }],
{ type: types.SET_LOADING, payload: true },
],
[], [],
done, done,
); );
......
import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state'; import State from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import * as getters from 'ee/security_dashboard/store/modules/vulnerabilities/getters'; import * as getters from 'ee/security_dashboard/store/modules/vulnerabilities/getters';
describe('vulnerabilities module getters', () => { describe('vulnerabilities module getters', () => {
describe('vulnerabilities', () => { const initialState = State();
it('should get the vulnerabilities from the state', () => { describe('vulnerabilitiesCountBySeverity', () => {
const vulnerabilities = [1, 2, 3, 4, 5]; const sast = { critical: 10 };
const state = { vulnerabilities }; const dast = { critical: 66 };
const result = getters.vulnerabilities(state); const expectedValue = sast.critical + dast.critical;
const vulnerabilitiesCount = { sast, dast };
const state = { vulnerabilitiesCount };
expect(result).toBe(vulnerabilities); it('should add up all the counts with `high` severity', () => {
const result = getters.vulnerabilitiesCountBySeverity(state)('critical');
expect(result).toBe(expectedValue);
});
it('should return 0 if no counts match the severity name', () => {
const result = getters.vulnerabilitiesCountBySeverity(state)('medium');
expect(result).toBe(0);
}); });
it('should get an empty array when there are no vulnerabilities in the state', () => { it('should return 0 if there are no counts at all', () => {
const result = getters.vulnerabilities(initialState); const result = getters.vulnerabilitiesCountBySeverity(initialState)('critical');
expect(result).toEqual([]); expect(result).toBe(0);
}); });
}); });
describe('pageInfo', () => { describe('vulnerabilitiesCountByReportType', () => {
it('should get the pageInfo object from the state', () => { const sast = { critical: 10, medium: 22 };
const pageInfo = { page: 1 }; const dast = { critical: 66 };
const state = { pageInfo }; const expectedValue = sast.critical + sast.medium;
const result = getters.pageInfo(state); const vulnerabilitiesCount = { sast, dast };
const state = { vulnerabilitiesCount };
it('should add up all the counts in the sast report', () => {
const result = getters.vulnerabilitiesCountByReportType(state)('sast');
expect(result).toBe(expectedValue);
});
it('should return 0 if there are no reports for a severity type', () => {
const result = getters.vulnerabilitiesCountByReportType(initialState)('sast');
expect(result).toBe(pageInfo); expect(result).toBe(0);
}); });
}); });
}); });
...@@ -3,43 +3,96 @@ import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/muta ...@@ -3,43 +3,96 @@ import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/muta
import mutations from 'ee/security_dashboard/store/modules/vulnerabilities/mutations'; import mutations from 'ee/security_dashboard/store/modules/vulnerabilities/mutations';
describe('vulnerabilities module mutations', () => { describe('vulnerabilities module mutations', () => {
describe('SET_PAGINATION', () => { describe('REQUEST_VULNERABILITIES', () => {
it('should apply the payload to `pageInfo` in the state', () => { it('should set `isLoadingVulnerabilities` to `true`', () => {
const state = initialState; const state = initialState;
const payload = { page: 2 };
mutations[types.SET_PAGINATION](state, payload); mutations[types.REQUEST_VULNERABILITIES](state);
expect(state.pageInfo).toEqual(payload); expect(state.isLoadingVulnerabilities).toBeTruthy();
}); });
}); });
describe('SET_VULNERABILITIES', () => { describe('RECEIVE_VULNERABILITIES_SUCCESS', () => {
it('should apply the payload to `pageInfo` in the state', () => { let payload;
let state;
beforeEach(() => {
payload = {
vulnerabilities: [1, 2, 3, 4, 5],
pageInfo: { a: 1, b: 2, c: 3 },
};
state = initialState;
mutations[types.RECEIVE_VULNERABILITIES_SUCCESS](state, payload);
});
it('should set `isLoadingVulnerabilities` to `false`', () => {
expect(state.isLoadingVulnerabilities).toBeFalsy();
});
it('should set `errorLoadingData` to `false`', () => {
expect(state.errorLoadingData).toBeFalsy();
});
it('should set `pageInfo`', () => {
expect(state.pageInfo).toBe(payload.pageInfo);
});
it('should set `vulnerabilities`', () => {
expect(state.vulnerabilities).toBe(payload.vulnerabilities);
});
});
describe('RECEIVE_VULNERABILITIES_ERROR', () => {
it('should set `isLoadingVulnerabilities` to `false`', () => {
const state = initialState; const state = initialState;
const payload = [1, 2, 3, 4, 5];
mutations[types.SET_VULNERABILITIES](state, payload); mutations[types.RECEIVE_VULNERABILITIES_ERROR](state);
expect(state.vulnerabilities).toEqual(payload); expect(state.isLoadingVulnerabilities).toBeFalsy();
}); });
}); });
describe('SET_LOADING', () => { describe('REQUEST_VULNERABILITIES_COUNT', () => {
it('should set loading to true', () => { it('should set `isLoadingVulnerabilitiesCount` to `true`', () => {
const state = initialState; const state = initialState;
mutations[types.SET_LOADING](state, true); mutations[types.REQUEST_VULNERABILITIES_COUNT](state);
expect(state.isLoading).toBeTruthy(); expect(state.isLoadingVulnerabilitiesCount).toBeTruthy();
});
});
describe('RECEIVE_VULNERABILITIES_COUNT_SUCCESS', () => {
let payload;
let state;
beforeEach(() => {
payload = { a: 1, b: 2, c: 3 };
state = initialState;
mutations[types.RECEIVE_VULNERABILITIES_COUNT_SUCCESS](state, payload);
});
it('should set `isLoadingVulnerabilitiesCount` to `false`', () => {
expect(state.isLoadingVulnerabilitiesCount).toBeFalsy();
});
it('should set `errorLoadingData` to `false`', () => {
expect(state.errorLoadingData).toBeFalsy();
});
it('should set `vulnerabilitiesCount`', () => {
expect(state.vulnerabilitiesCount).toBe(payload);
});
}); });
it('should not modify loading values are the same', () => { describe('RECEIVE_VULNERABILITIES_COUNT_ERROR', () => {
it('should set `isLoadingVulnerabilitiesCount` to `false`', () => {
const state = initialState; const state = initialState;
mutations[types.SET_LOADING](state, false); mutations[types.RECEIVE_VULNERABILITIES_COUNT_ERROR](state);
expect(state.isLoading).toBeFalsy(); expect(state.isLoadingVulnerabilitiesCount).toBeFalsy();
}); });
}); });
}); });
import { formatRelevantDigits, bytesToKiB, bytesToMiB, bytesToGiB, numberToHumanSize } from '~/lib/utils/number_utils'; import {
formatRelevantDigits,
bytesToKiB,
bytesToMiB,
bytesToGiB,
numberToHumanSize,
sum,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => { describe('Number Utils', () => {
describe('formatRelevantDigits', () => { describe('formatRelevantDigits', () => {
...@@ -77,4 +84,14 @@ describe('Number Utils', () => { ...@@ -77,4 +84,14 @@ describe('Number Utils', () => {
expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB'); expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB');
}); });
}); });
describe('sum', () => {
it('should add up two values', () => {
expect(sum(1, 2)).toEqual(3);
});
it('should add up all the values in an array when passed to a reducer', () => {
expect([1, 2, 3, 4, 5].reduce(sum)).toEqual(15);
});
});
}); });
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