Commit c559c6df authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '6709-group-security-dashboard-list-fe-ee' into 'master'

Starts adding the dashboard page view

See merge request gitlab-org/gitlab-ee!7400
parents 726562cc a59269a9
......@@ -73,6 +73,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :ldap_group_links, only: [:index, :create, :destroy]
# EE-specific start
namespace :security do
resource :dashboard, only: [:show], controller: :dashboard
end
# EE-specific end
## EE-specific
resource :saml_providers, path: 'saml', only: [:show, :create, :update] do
post :callback, to: 'omniauth_callbacks#group_saml'
......
import initSecurityDashboard from 'ee/security_dashboard/index';
document.addEventListener('DOMContentLoaded', () => {
initSecurityDashboard();
});
<script>
import Tabs from '~/vue_shared/components/tabs/tabs';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import SecurityDashboardTable from './security_dashboard_table.vue';
export default {
name: 'SecurityDashboardApp',
components: {
Tabs,
Tab,
SecurityDashboardTable,
},
computed: {
count() {
// TODO: Get the count from the overview API
return {
sast: null,
};
},
showSastCount() {
return this.count && this.count.sast;
},
},
};
</script>
<template>
<div>
<tabs stop-propagation>
<tab active>
<template slot="title">
{{ __('SAST') }}
<span
v-if="showSastCount"
class="badge badge-pill">
{{ count.sast }}
</span>
</template>
<security-dashboard-table/>
</tab>
</tabs>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'SecurityDashboardActionButtons',
components: {
Icon,
},
props: {
vulnerability: {
type: Object,
required: true,
},
},
methods: {
openModal() {
// TODO: Open the modal
},
newIssue() {
this.$store.dispatch('newIssue', this.vulnerability);
},
dismissVulnerability() {
this.$store.dispatch('dismissVulnerability', this.vulnerability);
},
},
};
</script>
<template>
<div>
<button
:aria-label="s__('Reports|More info')"
class="btn btn-secondary js-more-info"
type="button"
@click="openModal()"
>
<icon
name="external-link"
/>
</button>
<button
:aria-label="s__('Reports|New Issue')"
class="btn btn-inverted btn-info js-new-issue"
type="button"
@click="newIssue()"
>
<icon
name="issue-new"
/>
</button>
<button
:aria-label="s__('Reports|Dismiss Vulnerability')"
class="btn btn-inverted btn-remove js-dismiss-vulnerability"
type="button"
@click="dismissVulnerability()"
>
<icon
name="cancel"
/>
</button>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import Pagination from '~/vue_shared/components/pagination_links.vue';
import SecurityDashboardTableRow from './security_dashboard_table_row.vue';
export default {
name: 'SecurityDashboardTable',
components: {
SecurityDashboardTableRow,
Pagination,
},
computed: {
...mapGetters(['vulnerabilities', 'pageInfo', 'isLoading']),
showPagination() {
return this.pageInfo && this.pageInfo.total;
},
},
created() {
this.fetchVulnerabilities();
},
methods: {
...mapActions(['fetchVulnerabilities']),
},
};
</script>
<template>
<div class="ci-table">
<div
class="gl-responsive-table-row table-row-header"
role="row"
>
<div
class="table-section section-10"
role="rowheader"
>
{{ s__('Reports|Severity') }}
</div>
<div
class="table-section section-60"
role="rowheader"
>
{{ s__('Reports|Vulnerability') }}
</div>
<div
class="table-section section-30"
role="rowheader"
>
{{ s__('Reports|Confidence') }}
</div>
</div>
<gl-loading-icon
v-if="isLoading"
:size="2"
/>
<div v-else>
<security-dashboard-table-row
v-for="vulnerability in vulnerabilities"
:key="vulnerability.id"
:vulnerability="vulnerability"
/>
<pagination
v-if="showPagination"
:change="fetchVulnerabilities"
:page-info="pageInfo"
class="justify-content-center prepend-top-default"
/>
</div>
</div>
</template>
<script>
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import SecurityDashboardActionButtons from './security_dashboard_action_buttons.vue';
export default {
name: 'SecurityDashboardTableRow',
components: {
SeverityBadge,
SecurityDashboardActionButtons,
},
props: {
vulnerability: {
type: Object,
required: true,
},
},
computed: {
confidence() {
return this.vulnerability.confidence || '';
},
severity() {
return this.vulnerability.severity || '';
},
description() {
return this.vulnerability.description;
},
projectNamespace() {
const { project } = this.vulnerability;
return project && project.name_with_namespace ? project.name_with_namespace : null;
},
},
};
</script>
<template>
<div class="gl-responsive-table-row vulnerabilities-row">
<div class="table-section section-10">
<div
class="table-mobile-header"
role="rowheader"
>
{{ s__('Reports|Severity') }}
</div>
<div class="table-mobile-content">
<severity-badge :severity="severity"/>
</div>
</div>
<div class="table-section section-60">
<div
class="table-mobile-header"
role="rowheader"
>
{{ s__('Reports|Vulnerability') }}
</div>
<div class="table-mobile-content">
<span>{{ description }}</span>
<br />
<span
v-if="projectNamespace"
class="vulnerability-namespace">
{{ projectNamespace }}
</span>
</div>
</div>
<div class="table-section section-10">
<div
class="table-mobile-header"
role="rowheader"
>
{{ s__('Reports|Confidence') }}
</div>
<div class="table-mobile-content">
{{ confidence }}
</div>
</div>
<!-- This is hidden till we can hook up the actions
<div class="table-section section-20">
<div
class="table-mobile-header"
role="rowheader"
>
{{ s__('Reports|Actions') }}
</div>
<div class="table-mobile-content vulnerabilities-action-buttons">
<security-dashboard-action-buttons
:vulnerability="vulnerability"
/>
</div>
</div>
-->
</div>
</template>
<style>
@media (min-width: 768px) {
.vulnerabilities-row {
padding: .6em .4em;
}
.vulnerabilities-row:hover,
.vulnerabilities-row:focus {
background: #f6fafd;
border-bottom: 1px solid #c1daf4;
border-top: 1px solid #c1daf4;
margin-top: -1px;
}
.vulnerabilities-row .vulnerabilities-action-buttons {
opacity: 0;
padding-right: 1em;
text-align: right;
}
.vulnerabilities-row:hover .vulnerabilities-action-buttons,
.vulnerabilities-row:focus .vulnerabilities-action-buttons {
opacity: 1;
}
}
.vulnerabilities-row .table-section {
white-space: normal;
}
.vulnerability-namespace {
color: #707070;
font-size: .8em;
}
</style>
import Vue from 'vue';
import GroupSecurityDashboardApp from './components/app.vue';
import store from './store';
export default () => {
const el = document.getElementById('js-group-security-dashboard');
return new Vue({
el,
store,
components: {
GroupSecurityDashboardApp,
},
render(createElement) {
return createElement('group-security-dashboard-app');
},
});
};
import Vue from 'vue';
import Vuex from 'vuex';
import vulnerabilities from './modules/vulnerabilities/index';
Vue.use(Vuex);
export default () => new Vuex.Store({
modules: {
vulnerabilities,
},
});
import * as types from './mutation_types';
import mockData from './mock_data.json';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export const fetchVulnerabilities = ({ dispatch }, params = {}) => {
dispatch('requestVulnerabilities');
// TODO: Replace with axios when we can use the actual API
Promise.resolve({
data: mockData,
headers: {
'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 => {
dispatch('receiveVulnerabilitiesSuccess', response);
})
.catch(error => {
dispatch('receiveVulnerabilitiesError', error);
});
};
export const requestVulnerabilities = ({ commit }) => {
commit(types.SET_LOADING, true);
};
export const receiveVulnerabilitiesSuccess = ({ commit }, response = {}) => {
const normalizedHeaders = normalizeHeaders(response.headers);
const paginationInformation = parseIntPagination(normalizedHeaders);
commit(types.SET_LOADING, false);
commit(types.SET_VULNERABILITIES, response.data);
commit(types.SET_PAGINATION, paginationInformation);
};
export const receiveVulnerabilitiesError = ({ commit }) => {
// TODO: Show error state when we get it from UX
commit(types.SET_LOADING, false);
};
export default () => {};
export const isLoading = state => state.isLoading;
export const pageInfo = state => state.pageInfo;
export const vulnerabilities = state => state.vulnerabilities || [];
export default () => {};
import state from './state';
import mutations from './mutations';
import * as getters from './getters';
import * as actions from './actions';
export default {
state,
mutations,
getters,
actions,
};
export const SET_LOADING = 'SET_LOADING';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_VULNERABILITIES = 'SET_VULNERABILITIES';
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, payload) {
state.isLoading = payload;
},
[types.SET_PAGINATION](state, payload) {
state.pageInfo = payload;
},
[types.SET_VULNERABILITIES](state, payload) {
state.vulnerabilities = payload;
},
};
export default () => ({
vulnerabilitiesUrl: false,
vulnerabilities: [],
pageInfo: {},
isLoading: false,
});
<script>
export default {
name: 'SeverityBadge',
props: {
severity: {
type: String,
required: true,
},
},
computed: {
className() {
return `severity-badge severity-badge-${this.severity}`;
},
},
};
</script>
<template>
<div
:class="className"
>{{ severity }}</div>
</template>
<style>
.severity-badge {
background-color: #f2f2f2;
border-radius: .3em;
color: #505050;
display: inline-block;
font-size: .9em;
font-weight: bold;
line-height: 1em;
padding: .6em .4em .4em;
text-transform: uppercase;
}
.severity-badge-critical {
background-color: #fae5e1;
color: #c0341e;
}
.severity-badge-high {
background-color: #fff1de;
color: #de7e00;
}
.severity-badge-medium {
background-color: #ede8fb;
color: #6d49cb;
}
.severity-badge-unknown {
background-color: #ffffff;
border: 1px solid;
color: #707070;
}
</style>
# frozen_string_literal: true
class Groups::Security::DashboardController < Groups::ApplicationController
before_action :group
layout 'group'
# Redirecting back to the group path till the page is ready
def show
redirect_to group_path(@group)
end
end
- breadcrumb_title _("Security Dashboard")
#js-group-security-dashboard
---
title: Starts adding the dashboard page view
merge_request: 7400
author:
type: added
import Vue from 'vue';
import Vuex from 'vuex';
import component from 'ee/security_dashboard/components/security_dashboard_action_buttons.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Security Dashboard Action Buttons', () => {
let vm;
let props;
let actions;
beforeEach(() => {
props = { vulnerability: { id: 123 } };
actions = {
newIssue: jasmine.createSpy('newIssue'),
dismissVulnerability: jasmine.createSpy('dismissVulnerability'),
};
const Component = Vue.extend(component);
const store = new Vuex.Store({ actions });
vm = mountComponentWithStore(Component, { props, store });
});
afterEach(() => {
vm.$destroy();
});
it('should render three buttons', () => {
expect(vm.$el.querySelectorAll('.btn')).toHaveLength(3);
});
describe('More Info Button', () => {
it('should render the More info button', () => {
expect(vm.$el.querySelector('.js-more-info')).not.toBeNull();
});
});
describe('New Issue Button', () => {
it('should render the New Issue button', () => {
expect(vm.$el.querySelector('.js-new-issue')).not.toBeNull();
});
it('should trigger the `newIssue` action when clicked', () => {
vm.$el.querySelector('.js-new-issue').click();
expect(actions.newIssue).toHaveBeenCalledTimes(1);
});
});
describe('Dismiss Vulnerability Button', () => {
it('should render the Dismiss Vulnerability button', () => {
expect(vm.$el.querySelector('.js-dismiss-vulnerability')).not.toBeNull();
});
it('should trigger the `dismissVulnerability` action when clicked', () => {
vm.$el.querySelector('.js-dismiss-vulnerability').click();
expect(actions.dismissVulnerability).toHaveBeenCalledTimes(1);
});
});
});
import Vue from 'vue';
import component from 'ee/security_dashboard/components/security_dashboard_table_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Security Dashboard Table Row', () => {
let vm;
let vulnerability;
const Component = Vue.extend(component);
afterEach(() => {
vm.$destroy();
});
describe('severity', () => {
it('should pass high severity down to the component', () => {
vulnerability = { severity: 'high' };
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', () => {
vulnerability = {};
vm = mountComponent(Component, { vulnerability });
expect(vm.projectNamespace).toBeNull();
});
});
describe('confidence', () => {
it('should pass high confidence down to the component', () => {
vulnerability = { confidence: 'high' };
vm = mountComponent(Component, { vulnerability });
expect(vm.confidence).toBe(vulnerability.confidence);
});
it('should compute a `–` when no confidence is passed', () => {
vulnerability = {};
vm = mountComponent(Component, { vulnerability });
expect(vm.confidence).toBe('');
});
});
describe('rendered output', () => {
beforeEach(() => {
vulnerability = {
project: { name_with_namespace: 'project name' },
confidence: 'high',
description: 'this is a description',
severity: 'low',
};
vm = mountComponent(Component, { vulnerability });
});
it('should render the severity', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[0].textContent)
.toContain(vulnerability.severity);
});
it('should render the description', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent)
.toContain(vulnerability.description);
});
it('should render the project namespace', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[1].textContent)
.toContain(vulnerability.project.name_with_namespace);
});
it('should render the confidence', () => {
expect(vm.$el.querySelectorAll('.table-mobile-content')[2].textContent)
.toContain(vulnerability.confidence);
});
});
});
import Vue from 'vue';
import Vuex from 'vuex';
import component from 'ee/security_dashboard/components/security_dashboard_table.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('Security Dashboard Table', () => {
const vulnerabilities = [{ id: 0 }, { id: 1 }, { id: 2 }];
let vm;
let getters;
let actions;
beforeEach(() => {
const Component = Vue.extend(component);
getters = {
vulnerabilities: () => vulnerabilities,
pageInfo: () => null,
};
actions = {
fetchVulnerabilities: jasmine.createSpy('fetchVulnerabilities'),
};
const store = new Vuex.Store({ actions, getters });
vm = mountComponentWithStore(Component, { store });
});
afterEach(() => {
actions.fetchVulnerabilities.calls.reset();
vm.$destroy();
});
it('should dispatch a `fetchVulnerabilities` action on creation', () => {
expect(actions.fetchVulnerabilities).toHaveBeenCalledTimes(1);
});
it('should render a row for each vulnerability', () => {
expect(vm.$el.querySelectorAll('.vulnerabilities-row')).toHaveLength(vulnerabilities.length);
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import mockData from 'ee/security_dashboard/store/modules/vulnerabilities/mock_data.json';
import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/vulnerabilities/actions';
describe('vulnerabilities module actions', () => {
const data = mockData;
const pageInfo = {
page: 1,
nextPage: 2,
previousPage: 1,
perPage: 20,
total: 100,
totalPages: 5,
};
const headers = {
'X-Next-Page': pageInfo.nextPage,
'X-Page': pageInfo.page,
'X-Per-Page': pageInfo.perPage,
'X-Prev-Page': pageInfo.previousPage,
'X-Total': pageInfo.total,
'X-Total-Pages': pageInfo.totalPages,
};
describe('fetch vulnerabilities', () => {
let mock;
const state = initialState;
beforeEach(() => {
state.vulnerabilitiesUrl = `${TEST_HOST}/vulnerabilities.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
beforeEach(() => {
mock
.onGet(state.vulnerabilitiesUrl)
.replyOnce(200, data, headers);
});
it('should dispatch the request and success actions', done => {
testAction(
actions.fetchVulnerabilities,
{},
state,
[],
[
{ type: 'requestVulnerabilities' },
{
type: 'receiveVulnerabilitiesSuccess',
payload: { data, headers },
},
],
done,
);
});
});
// NOTE: This will fail as we're currently mocking the API call in the action
// so the mock adaptor can't pick it up.
// eslint-disable-next-line
xdescribe('on error', () => {
beforeEach(() => {
mock
.onGet(state.vulnerabilitiesUrl)
.replyOnce(404, {});
});
it('should dispatch the request and error actions', done => {
testAction(
actions.fetchVulnerabilities,
{},
state,
[],
[
{ type: 'requestVulnerabilities' },
{
type: 'receiveVulnerabilitiesError',
payload: {},
},
],
done,
);
});
});
});
describe('receiveVulnerabilitiesSuccess', () => {
it('should commit the required mutations', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesSuccess,
{ headers, data },
state,
[
{ type: types.SET_LOADING, payload: false },
{ type: types.SET_VULNERABILITIES, payload: data },
{ type: types.SET_PAGINATION, payload: pageInfo },
],
[],
done,
);
});
});
describe('receiveVulnerabilitiesError', () => {
it('should commit the loading mutation', done => {
const state = initialState;
testAction(
actions.receiveVulnerabilitiesError,
{},
state,
[
{ type: types.SET_LOADING, payload: false },
],
[],
done,
);
});
});
describe('requestVulnerabilities', () => {
it('should commit the loading mutation', done => {
const state = initialState;
testAction(
actions.requestVulnerabilities,
{},
state,
[
{ type: types.SET_LOADING, payload: true },
],
[],
done,
);
});
});
});
import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import * as getters from 'ee/security_dashboard/store/modules/vulnerabilities/getters';
describe('vulnerabilities module getters', () => {
describe('vulnerabilities', () => {
it('should get the vulnerabilities from the state', () => {
const vulnerabilities = [1, 2, 3, 4, 5];
const state = { vulnerabilities };
const result = getters.vulnerabilities(state);
expect(result).toBe(vulnerabilities);
});
it('should get an empty array when there are no vulnerabilities in the state', () => {
const result = getters.vulnerabilities(initialState);
expect(result).toEqual([]);
});
});
describe('pageInfo', () => {
it('should get the pageInfo object from the state', () => {
const pageInfo = { page: 1 };
const state = { pageInfo };
const result = getters.pageInfo(state);
expect(result).toBe(pageInfo);
});
});
});
import initialState from 'ee/security_dashboard/store/modules/vulnerabilities/state';
import * as types from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
import mutations from 'ee/security_dashboard/store/modules/vulnerabilities/mutations';
describe('vulnerabilities module mutations', () => {
describe('SET_PAGINATION', () => {
it('should apply the payload to `pageInfo` in the state', () => {
const state = initialState;
const payload = { page: 2 };
mutations[types.SET_PAGINATION](state, payload);
expect(state.pageInfo).toEqual(payload);
});
});
describe('SET_VULNERABILITIES', () => {
it('should apply the payload to `pageInfo` in the state', () => {
const state = initialState;
const payload = [1, 2, 3, 4, 5];
mutations[types.SET_VULNERABILITIES](state, payload);
expect(state.vulnerabilities).toEqual(payload);
});
});
describe('SET_LOADING', () => {
it('should set loading to true', () => {
const state = initialState;
mutations[types.SET_LOADING](state, true);
expect(state.isLoading).toBeTruthy();
});
it('should not modify loading values are the same', () => {
const state = initialState;
mutations[types.SET_LOADING](state, false);
expect(state.isLoading).toBeFalsy();
});
});
});
......@@ -6328,12 +6328,27 @@ msgstr ""
msgid "Reports|Class"
msgstr ""
msgid "Reports|Confidence"
msgstr ""
msgid "Reports|Dismiss Vulnerability"
msgstr ""
msgid "Reports|Execution time"
msgstr ""
msgid "Reports|Failure"
msgstr ""
msgid "Reports|More info"
msgstr ""
msgid "Reports|New Issue"
msgstr ""
msgid "Reports|Severity"
msgstr ""
msgid "Reports|System output"
msgstr ""
......@@ -6346,6 +6361,9 @@ msgstr ""
msgid "Reports|Test summary results are being parsed"
msgstr ""
msgid "Reports|Vulnerability"
msgstr ""
msgid "Reports|no changed test results"
msgstr ""
......@@ -6522,6 +6540,9 @@ msgstr ""
msgid "SAML Single Sign On Settings"
msgstr ""
msgid "SAST"
msgstr ""
msgid "SHA1 fingerprint of the SAML token signing certificate. Get this from your identity provider, where it can also be called \"Thumbprint\"."
msgstr ""
......
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