Commit 08911370 authored by Clement Ho's avatar Clement Ho

Merge branch '10077-add-dependency-scanning-to-dl-vulnerable-tab-ee' into 'master'

Add vulnerable dependency list

See merge request gitlab-org/gitlab-ee!14665
parents 7ed07685 d8003b58
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlBadge, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import DependenciesActions from './dependencies_actions.vue'; import DependenciesActions from './dependencies_actions.vue';
import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue'; import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue'; import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
...@@ -14,10 +14,18 @@ export default { ...@@ -14,10 +14,18 @@ export default {
GlBadge, GlBadge,
GlEmptyState, GlEmptyState,
GlLoadingIcon, GlLoadingIcon,
GlTab,
GlTabs,
DependencyListIncompleteAlert, DependencyListIncompleteAlert,
DependencyListJobFailedAlert, DependencyListJobFailedAlert,
PaginatedDependenciesTable, PaginatedDependenciesTable,
}, },
inject: {
dependencyListVulnerabilities: {
from: 'dependencyListVulnerabilities',
default: false,
},
},
props: { props: {
endpoint: { endpoint: {
type: String, type: String,
...@@ -39,28 +47,47 @@ export default { ...@@ -39,28 +47,47 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['currentList']), ...mapState(['currentList', 'listTypes']),
...mapGetters(DEPENDENCY_LIST_TYPES.all, ['isJobNotSetUp', 'isJobFailed', 'isIncomplete']), ...mapGetters([
...mapState(DEPENDENCY_LIST_TYPES.all, ['initialized', 'pageInfo', 'reportInfo']), 'isInitialized',
'isJobNotSetUp',
'isJobFailed',
'isIncomplete',
'reportInfo',
'totals',
]),
...mapState(DEPENDENCY_LIST_TYPES.all.namespace, ['pageInfo']),
currentListIndex: {
get() {
return this.listTypes.map(({ namespace }) => namespace).indexOf(this.currentList);
},
set(index) {
const { namespace } = this.listTypes[index] || {};
this.setCurrentList(namespace);
},
},
}, },
created() { created() {
this.setDependenciesEndpoint(this.endpoint); this.setDependenciesEndpoint(this.endpoint);
this.fetchDependencies(); this.fetchDependencies();
}, },
methods: { methods: {
...mapActions(DEPENDENCY_LIST_TYPES.all, ['setDependenciesEndpoint', 'fetchDependencies']), ...mapActions(['setDependenciesEndpoint', 'fetchDependencies', 'setCurrentList']),
dismissIncompleteListAlert() { dismissIncompleteListAlert() {
this.isIncompleteAlertDismissed = true; this.isIncompleteAlertDismissed = true;
}, },
dismissJobFailedAlert() { dismissJobFailedAlert() {
this.isJobFailedAlertDismissed = true; this.isJobFailedAlertDismissed = true;
}, },
isTabDisabled(namespace) {
return this.totals[namespace] <= 0;
},
}, },
}; };
</script> </script>
<template> <template>
<gl-loading-icon v-if="!initialized" size="md" class="mt-4" /> <gl-loading-icon v-if="!isInitialized" size="md" class="mt-4" />
<gl-empty-state <gl-empty-state
v-else-if="isJobNotSetUp" v-else-if="isJobNotSetUp"
...@@ -85,15 +112,41 @@ export default { ...@@ -85,15 +112,41 @@ export default {
@close="dismissJobFailedAlert" @close="dismissJobFailedAlert"
/> />
<template v-if="dependencyListVulnerabilities">
<h3 class="h5">{{ __('Dependencies') }}</h3>
<gl-tabs v-model="currentListIndex">
<gl-tab
v-for="listType in listTypes"
:key="listType.namespace"
:disabled="isTabDisabled(listType.namespace)"
title-link-class="js-tab"
>
<template v-slot:title>
{{ listType.label }}
<gl-badge pill>{{ totals[listType.namespace] }}</gl-badge>
</template>
<paginated-dependencies-table :namespace="listType.namespace" />
</gl-tab>
<template v-slot:tabs>
<li class="d-flex align-items-center ml-sm-auto">
<dependencies-actions :namespace="currentList" class="my-2 my-sm-0" />
</li>
</template>
</gl-tabs>
</template>
<template v-else>
<div class="d-sm-flex justify-content-between align-items-baseline my-2"> <div class="d-sm-flex justify-content-between align-items-baseline my-2">
<h4 class="h5"> <h3 class="h5">
{{ __('Dependencies') }} {{ __('Dependencies') }}
<gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge> <gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge>
</h4> </h3>
<dependencies-actions :namespace="currentList" /> <dependencies-actions :namespace="currentList" />
</div> </div>
<paginated-dependencies-table :namespace="currentList" /> <paginated-dependencies-table :namespace="currentList" />
</template>
</div> </div>
</template> </template>
...@@ -30,7 +30,8 @@ export default { ...@@ -30,7 +30,8 @@ export default {
namespace: { namespace: {
type: String, type: String,
required: true, required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value), validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
}, },
}, },
data() { data() {
......
...@@ -14,7 +14,8 @@ export default { ...@@ -14,7 +14,8 @@ export default {
namespace: { namespace: {
type: String, type: String,
required: true, required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value), validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
}, },
}, },
computed: { computed: {
......
import Vue from 'vue'; import Vue from 'vue';
import DependenciesApp from './components/app.vue'; import DependenciesApp from './components/app.vue';
import createStore from './store'; import createStore from './store';
import { DEPENDENCY_LIST_TYPES } from './store/constants';
import { addListType } from './store/utils';
export default () => { export default () => {
const el = document.querySelector('#js-dependencies-app'); const el = document.querySelector('#js-dependencies-app');
...@@ -9,6 +11,10 @@ export default () => { ...@@ -9,6 +11,10 @@ export default () => {
const store = createStore(); const store = createStore();
if (dependencyListVulnerabilities) {
addListType(store, DEPENDENCY_LIST_TYPES.vulnerable);
}
return new Vue({ return new Vue({
el, el,
store, store,
......
import * as types from './mutation_types';
export const addListType = ({ commit }, payload) => commit(types.ADD_LIST_TYPE, payload);
export const setDependenciesEndpoint = ({ state, dispatch }, endpoint) =>
Promise.all(
state.listTypes.map(({ namespace }) =>
dispatch(`${namespace}/setDependenciesEndpoint`, endpoint),
),
);
export const fetchDependencies = ({ state, dispatch }, payload) =>
Promise.all(
state.listTypes.map(({ namespace }) => dispatch(`${namespace}/fetchDependencies`, payload)),
);
export const setCurrentList = ({ state, commit }, payload) => {
if (state.listTypes.map(({ namespace }) => namespace).includes(payload)) {
commit(types.SET_CURRENT_LIST, payload);
}
};
import { s__ } from '~/locale';
import { FILTER } from './modules/list/constants';
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const DEPENDENCY_LIST_TYPES = { export const DEPENDENCY_LIST_TYPES = {
all: 'allDependencies', all: {
namespace: 'allDependencies',
label: s__('Dependencies|All'),
initialState: {
filter: FILTER.all,
},
},
vulnerable: {
namespace: 'vulnerableDependencies',
label: s__('Dependencies|Vulnerable components'),
initialState: {
filter: FILTER.vulnerable,
},
},
}; };
export const isInitialized = ({ currentList, ...state }) => state[currentList].initialized;
export const reportInfo = ({ currentList, ...state }) => state[currentList].reportInfo;
export const isJobNotSetUp = ({ currentList }, getters) => getters[`${currentList}/isJobNotSetUp`];
export const isJobFailed = ({ currentList }, getters) => getters[`${currentList}/isJobFailed`];
export const isIncomplete = ({ currentList }, getters) => getters[`${currentList}/isIncomplete`];
export const totals = state =>
state.listTypes.reduce(
(acc, { namespace }) => ({
...acc,
[namespace]: state[namespace].pageInfo.total || 0,
}),
{},
);
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import listModule from './modules/list'; import listModule from './modules/list';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state'; import state from './state';
import { DEPENDENCY_LIST_TYPES } from './constants';
Vue.use(Vuex); Vue.use(Vuex);
export default () => { export default () =>
const allDependencies = listModule(); new Vuex.Store({
return new Vuex.Store({
modules: { modules: {
allDependencies, [DEPENDENCY_LIST_TYPES.all.namespace]: listModule(),
}, },
actions,
getters,
mutations,
state, state,
}); });
};
...@@ -9,6 +9,8 @@ import { __ } from '~/locale'; ...@@ -9,6 +9,8 @@ import { __ } from '~/locale';
export const setDependenciesEndpoint = ({ commit }, endpoint) => export const setDependenciesEndpoint = ({ commit }, endpoint) =>
commit(types.SET_DEPENDENCIES_ENDPOINT, endpoint); commit(types.SET_DEPENDENCIES_ENDPOINT, endpoint);
export const setInitialState = ({ commit }, payload) => commit(types.SET_INITIAL_STATE, payload);
export const requestDependencies = ({ commit }) => commit(types.REQUEST_DEPENDENCIES); export const requestDependencies = ({ commit }) => commit(types.REQUEST_DEPENDENCIES);
export const receiveDependenciesSuccess = ({ commit }, { headers, data }) => { export const receiveDependenciesSuccess = ({ commit }, { headers, data }) => {
...@@ -35,6 +37,7 @@ export const fetchDependencies = ({ state, dispatch }, params = {}) => { ...@@ -35,6 +37,7 @@ export const fetchDependencies = ({ state, dispatch }, params = {}) => {
sort_by: state.sortField, sort_by: state.sortField,
sort: state.sortOrder, sort: state.sortOrder,
page: state.pageInfo.page || 1, page: state.pageInfo.page || 1,
filter: state.filter,
...params, ...params,
}, },
}) })
......
...@@ -23,6 +23,11 @@ export const REPORT_STATUS = { ...@@ -23,6 +23,11 @@ export const REPORT_STATUS = {
incomplete: 'no_dependency_files', incomplete: 'no_dependency_files',
}; };
export const FILTER = {
all: 'all',
vulnerable: 'vulnerable',
};
export const FETCH_ERROR_MESSAGE = __( export const FETCH_ERROR_MESSAGE = __(
'Error fetching the dependency list. Please check your network connection and try again.', 'Error fetching the dependency list. Please check your network connection and try again.',
); );
export const SET_DEPENDENCIES_ENDPOINT = 'SET_DEPENDENCIES_ENDPOINT'; export const SET_DEPENDENCIES_ENDPOINT = 'SET_DEPENDENCIES_ENDPOINT';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_DEPENDENCIES = 'REQUEST_DEPENDENCIES'; export const REQUEST_DEPENDENCIES = 'REQUEST_DEPENDENCIES';
export const RECEIVE_DEPENDENCIES_SUCCESS = 'RECEIVE_DEPENDENCIES_SUCCESS'; export const RECEIVE_DEPENDENCIES_SUCCESS = 'RECEIVE_DEPENDENCIES_SUCCESS';
......
...@@ -5,6 +5,9 @@ export default { ...@@ -5,6 +5,9 @@ export default {
[types.SET_DEPENDENCIES_ENDPOINT](state, payload) { [types.SET_DEPENDENCIES_ENDPOINT](state, payload) {
state.endpoint = payload; state.endpoint = payload;
}, },
[types.SET_INITIAL_STATE](state, payload) {
Object.assign(state, payload);
},
[types.REQUEST_DEPENDENCIES](state) { [types.REQUEST_DEPENDENCIES](state) {
state.isLoading = true; state.isLoading = true;
state.errorLoading = false; state.errorLoading = false;
......
import { REPORT_STATUS, SORT_ORDER } from './constants'; import { FILTER, REPORT_STATUS, SORT_ORDER } from './constants';
export default () => ({ export default () => ({
endpoint: '', endpoint: '',
...@@ -6,11 +6,14 @@ export default () => ({ ...@@ -6,11 +6,14 @@ export default () => ({
isLoading: false, isLoading: false,
errorLoading: false, errorLoading: false,
dependencies: [], dependencies: [],
pageInfo: {}, pageInfo: {
total: 0,
},
reportInfo: { reportInfo: {
status: REPORT_STATUS.ok, status: REPORT_STATUS.ok,
jobPath: '', jobPath: '',
}, },
filter: FILTER.all,
sortField: 'name', sortField: 'name',
sortOrder: SORT_ORDER.ascending, sortOrder: SORT_ORDER.ascending,
}); });
export const ADD_LIST_TYPE = 'ADD_LIST_TYPE';
export const SET_CURRENT_LIST = 'SET_CURRENT_LIST';
import * as types from './mutation_types';
export default {
[types.ADD_LIST_TYPE](state, payload) {
state.listTypes.push(payload);
},
[types.SET_CURRENT_LIST](state, payload) {
state.currentList = payload;
},
};
import { DEPENDENCY_LIST_TYPES } from './constants'; import { DEPENDENCY_LIST_TYPES } from './constants';
export default () => ({ export default () => ({
currentList: DEPENDENCY_LIST_TYPES.all, listTypes: [DEPENDENCY_LIST_TYPES.all],
currentList: DEPENDENCY_LIST_TYPES.all.namespace,
}); });
import listModule from './modules/list';
// eslint-disable-next-line import/prefer-default-export
export const addListType = (store, listType) => {
const { initialState, namespace } = listType;
store.registerModule(namespace, listModule());
store.dispatch('addListType', listType);
store.dispatch(`${namespace}/setInitialState`, initialState);
};
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import createStore from 'ee/dependencies/store'; import createStore from 'ee/dependencies/store';
import { addListType } from 'ee/dependencies/store/utils';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants'; import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
import { REPORT_STATUS } from 'ee/dependencies/store/modules/list/constants'; import { REPORT_STATUS } from 'ee/dependencies/store/modules/list/constants';
import DependenciesApp from 'ee/dependencies/components/app.vue'; import DependenciesApp from 'ee/dependencies/components/app.vue';
import DependenciesActions from 'ee/dependencies/components/dependencies_actions.vue';
import DependencyListIncompleteAlert from 'ee/dependencies/components/dependency_list_incomplete_alert.vue'; import DependencyListIncompleteAlert from 'ee/dependencies/components/dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from 'ee/dependencies/components/dependency_list_job_failed_alert.vue'; import DependencyListJobFailedAlert from 'ee/dependencies/components/dependency_list_job_failed_alert.vue';
import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dependencies_table.vue'; import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dependencies_table.vue';
...@@ -11,7 +14,7 @@ import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dep ...@@ -11,7 +14,7 @@ import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dep
describe('DependenciesApp component', () => { describe('DependenciesApp component', () => {
let store; let store;
let wrapper; let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all; const { namespace } = DEPENDENCY_LIST_TYPES.all;
const basicAppProps = { const basicAppProps = {
endpoint: '/foo', endpoint: '/foo',
...@@ -52,8 +55,8 @@ describe('DependenciesApp component', () => { ...@@ -52,8 +55,8 @@ describe('DependenciesApp component', () => {
it('dispatches the correct initial actions', () => { it('dispatches the correct initial actions', () => {
expect(store.dispatch.mock.calls).toEqual([ expect(store.dispatch.mock.calls).toEqual([
[`${listType}/setDependenciesEndpoint`, basicAppProps.endpoint], ['setDependenciesEndpoint', basicAppProps.endpoint],
[`${listType}/fetchDependencies`, undefined], ['fetchDependencies'],
]); ]);
}); });
...@@ -65,13 +68,13 @@ describe('DependenciesApp component', () => { ...@@ -65,13 +68,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => { beforeEach(() => {
dependencies = ['foo', 'bar']; dependencies = ['foo', 'bar'];
Object.assign(store.state[listType], { Object.assign(store.state[namespace], {
initialized: true, initialized: true,
isLoading: false, isLoading: false,
dependencies, dependencies,
}); });
store.state[listType].pageInfo.total = 100; store.state[namespace].pageInfo.total = 100;
store.state[listType].reportInfo.status = REPORT_STATUS.ok; store.state[namespace].reportInfo.status = REPORT_STATUS.ok;
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -82,7 +85,7 @@ describe('DependenciesApp component', () => { ...@@ -82,7 +85,7 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => { it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, { expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType, namespace,
}); });
}); });
}); });
...@@ -91,13 +94,13 @@ describe('DependenciesApp component', () => { ...@@ -91,13 +94,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => { beforeEach(() => {
dependencies = []; dependencies = [];
Object.assign(store.state[listType], { Object.assign(store.state[namespace], {
initialized: true, initialized: true,
isLoading: false, isLoading: false,
dependencies, dependencies,
}); });
store.state[listType].pageInfo.total = 0; store.state[namespace].pageInfo.total = 0;
store.state[listType].reportInfo.status = REPORT_STATUS.jobNotSetUp; store.state[namespace].reportInfo.status = REPORT_STATUS.jobNotSetUp;
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -111,14 +114,14 @@ describe('DependenciesApp component', () => { ...@@ -111,14 +114,14 @@ describe('DependenciesApp component', () => {
beforeEach(() => { beforeEach(() => {
dependencies = []; dependencies = [];
Object.assign(store.state[listType], { Object.assign(store.state[namespace], {
initialized: true, initialized: true,
isLoading: false, isLoading: false,
dependencies, dependencies,
}); });
store.state[listType].pageInfo.total = 0; store.state[namespace].pageInfo.total = 0;
store.state[listType].reportInfo.status = REPORT_STATUS.jobFailed; store.state[namespace].reportInfo.status = REPORT_STATUS.jobFailed;
store.state[listType].reportInfo.jobPath = '/jobs/foo/321'; store.state[namespace].reportInfo.jobPath = '/jobs/foo/321';
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -129,13 +132,13 @@ describe('DependenciesApp component', () => { ...@@ -129,13 +132,13 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the job failure alert', () => { it('passes the correct props to the job failure alert', () => {
expectComponentWithProps(DependencyListJobFailedAlert, { expectComponentWithProps(DependencyListJobFailedAlert, {
jobPath: store.state[listType].reportInfo.jobPath, jobPath: store.state[namespace].reportInfo.jobPath,
}); });
}); });
it('passes the correct props to the paginated dependencies table', () => { it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, { expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType, namespace,
}); });
}); });
...@@ -156,13 +159,13 @@ describe('DependenciesApp component', () => { ...@@ -156,13 +159,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => { beforeEach(() => {
dependencies = ['foo', 'bar']; dependencies = ['foo', 'bar'];
Object.assign(store.state[listType], { Object.assign(store.state[namespace], {
initialized: true, initialized: true,
isLoading: false, isLoading: false,
dependencies, dependencies,
}); });
store.state[listType].pageInfo.total = 100; store.state[namespace].pageInfo.total = 100;
store.state[listType].reportInfo.status = REPORT_STATUS.incomplete; store.state[namespace].reportInfo.status = REPORT_STATUS.incomplete;
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -178,7 +181,7 @@ describe('DependenciesApp component', () => { ...@@ -178,7 +181,7 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => { it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, { expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType, namespace,
}); });
}); });
...@@ -199,7 +202,7 @@ describe('DependenciesApp component', () => { ...@@ -199,7 +202,7 @@ describe('DependenciesApp component', () => {
beforeEach(() => { beforeEach(() => {
dependencies = []; dependencies = [];
Object.assign(store.state[listType], { Object.assign(store.state[namespace], {
initialized: true, initialized: true,
isLoading: false, isLoading: false,
errorLoading: true, errorLoading: true,
...@@ -215,7 +218,282 @@ describe('DependenciesApp component', () => { ...@@ -215,7 +218,282 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => { it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, { expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType, namespace,
});
});
});
});
});
describe('DependenciesApp component with dependencyListVulnerabilities feature flag enabled', () => {
let store;
let wrapper;
const { namespace: allNamespace } = DEPENDENCY_LIST_TYPES.all;
const { namespace: vulnerableNamespace } = DEPENDENCY_LIST_TYPES.vulnerable;
const basicAppProps = {
endpoint: '/foo',
emptyStateSvgPath: '/bar.svg',
documentationPath: TEST_HOST,
};
const factory = (props = basicAppProps) => {
const localVue = createLocalVue();
store = createStore();
addListType(store, DEPENDENCY_LIST_TYPES.vulnerable);
jest.spyOn(store, 'dispatch').mockImplementation();
const canBeStubbed = component => !['GlTab', 'GlTabs'].includes(component);
const stubs = Object.keys(DependenciesApp.components).filter(canBeStubbed);
wrapper = mount(DependenciesApp, {
localVue,
store,
sync: false,
propsData: { ...props },
provide: {
dependencyListVulnerabilities: true,
},
stubs,
});
};
const setStateJobNotRun = () => {
Object.assign(store.state[allNamespace], {
initialized: true,
isLoading: false,
dependencies: [],
});
store.state[allNamespace].pageInfo.total = 0;
store.state[allNamespace].reportInfo.status = REPORT_STATUS.jobNotSetUp;
};
const setStateLoaded = () => {
[allNamespace, vulnerableNamespace].forEach((namespace, i, { length }) => {
const total = length - i;
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
dependencies: Array(total)
.fill(null)
.map((_, id) => ({ id })),
});
store.state[namespace].pageInfo.total = total;
store.state[namespace].reportInfo.status = REPORT_STATUS.ok;
});
};
const setStateJobFailed = () => {
Object.assign(store.state[allNamespace], {
initialized: true,
isLoading: false,
dependencies: [],
});
store.state[allNamespace].pageInfo.total = 0;
store.state[allNamespace].reportInfo.status = REPORT_STATUS.jobFailed;
store.state[allNamespace].reportInfo.jobPath = '/jobs/foo/321';
};
const setStateListIncomplete = () => {
Object.assign(store.state[allNamespace], {
initialized: true,
isLoading: false,
dependencies: [{ id: 0 }],
});
store.state[allNamespace].pageInfo.total = 1;
store.state[allNamespace].reportInfo.status = REPORT_STATUS.incomplete;
};
const findJobFailedAlert = () => wrapper.find(DependencyListJobFailedAlert);
const findIncompleteListAlert = () => wrapper.find(DependencyListIncompleteAlert);
const findDependenciesTables = () => wrapper.findAll(PaginatedDependenciesTable);
const findTabControls = () => wrapper.findAll('.js-tab');
const findVulnerableTabControl = () => findTabControls().at(1);
const findVulnerableTabComponent = () => wrapper.findAll(GlTab).at(1);
const expectComponentWithProps = (Component, props = {}) => {
const componentWrapper = wrapper.find(Component);
expect(componentWrapper.isVisible()).toBe(true);
expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
};
const expectNoDependenciesTables = () => expect(findDependenciesTables()).toHaveLength(0);
const expectDependenciesTables = () => {
const { wrappers } = findDependenciesTables();
expect(wrappers).toHaveLength(2);
expect(wrappers[0].props()).toEqual({ namespace: allNamespace });
expect(wrappers[1].props()).toEqual({ namespace: vulnerableNamespace });
};
afterEach(() => {
wrapper.destroy();
});
describe('on creation', () => {
beforeEach(() => {
factory();
});
it('dispatches the correct initial actions', () => {
expect(store.dispatch.mock.calls).toEqual([
['setDependenciesEndpoint', basicAppProps.endpoint],
['fetchDependencies'],
]);
});
it('shows only the loading icon', () => {
expectComponentWithProps(GlLoadingIcon);
expectNoDependenciesTables();
});
describe('given the dependency list job has not yet run', () => {
beforeEach(() => {
setStateJobNotRun();
return wrapper.vm.$nextTick();
});
it('shows only the empty state', () => {
expectComponentWithProps(GlEmptyState, { svgPath: basicAppProps.emptyStateSvgPath });
expectNoDependenciesTables();
});
});
describe('given a list of dependencies and ok report', () => {
beforeEach(() => {
setStateLoaded();
return wrapper.vm.$nextTick();
});
it('shows both dependencies tables with the correct props', () => {
expectDependenciesTables();
});
it('displays the tabs correctly', () => {
const expected = [
{
text: 'All',
total: '2',
},
{
text: 'Vulnerable',
total: '1',
},
];
const tabs = findTabControls();
expected.forEach(({ text, total }, i) => {
const tab = tabs.at(i);
expect(tab.text()).toEqual(expect.stringContaining(text));
expect(
tab
.find(GlBadge)
.text()
.trim(),
).toEqual(total);
});
});
it('passes the correct namespace to dependencies actions component', () => {
expectComponentWithProps(DependenciesActions, { namespace: allNamespace });
});
describe('given the user clicks on the vulnerable tab', () => {
beforeEach(() => {
findVulnerableTabControl().trigger('click');
return wrapper.vm.$nextTick();
});
it('changes the current list', () => {
expect(store.dispatch).toHaveBeenCalledWith('setCurrentList', vulnerableNamespace);
});
});
describe('given the current list is the vulnerable dependencies list', () => {
const namespace = vulnerableNamespace;
beforeEach(() => {
store.state.currentList = namespace;
return wrapper.vm.$nextTick();
});
it('passes the correct namespace to dependencies actions component', () => {
expectComponentWithProps(DependenciesActions, { namespace });
});
});
describe('given there are no vulnerable dependencies', () => {
beforeEach(() => {
store.state[vulnerableNamespace].dependencies = [];
store.state[vulnerableNamespace].pageInfo.total = 0;
return wrapper.vm.$nextTick();
});
it('disables the vulnerable tab', () => {
expect(findVulnerableTabComponent().props()).toEqual(
expect.objectContaining({
disabled: true,
}),
);
});
});
});
describe('given the dependency list job failed', () => {
beforeEach(() => {
setStateJobFailed();
return wrapper.vm.$nextTick();
});
it('passes the correct props to the job failure alert', () => {
expectComponentWithProps(DependencyListJobFailedAlert, {
jobPath: '/jobs/foo/321',
});
});
it('shows both dependencies tables with the correct props', expectDependenciesTables);
describe('when the job failure alert emits the close event', () => {
beforeEach(() => {
const alertWrapper = findJobFailedAlert();
alertWrapper.vm.$emit('close');
return wrapper.vm.$nextTick();
});
it('does not render the job failure alert', () => {
expect(findJobFailedAlert().exists()).toBe(false);
});
});
});
describe('given a dependency list which is known to be incomplete', () => {
beforeEach(() => {
setStateListIncomplete();
return wrapper.vm.$nextTick();
});
it('passes the correct props to the incomplete-list alert', () => {
expectComponentWithProps(DependencyListIncompleteAlert);
});
it('shows both dependencies tables with the correct props', expectDependenciesTables);
describe('when the incomplete-list alert emits the close event', () => {
beforeEach(() => {
const alertWrapper = findIncompleteListAlert();
alertWrapper.vm.$emit('close');
return wrapper.vm.$nextTick();
});
it('does not render the incomplete-list alert', () => {
expect(findIncompleteListAlert().exists()).toBe(false);
}); });
}); });
}); });
......
...@@ -16,7 +16,7 @@ describe.each` ...@@ -16,7 +16,7 @@ describe.each`
`('DependenciesActions component$context', ({ isFeatureFlagEnabled, sortFields }) => { `('DependenciesActions component$context', ({ isFeatureFlagEnabled, sortFields }) => {
let store; let store;
let wrapper; let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all; const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = ({ propsData, ...options } = {}) => { const factory = ({ propsData, ...options } = {}) => {
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -35,10 +35,10 @@ describe.each` ...@@ -35,10 +35,10 @@ describe.each`
beforeEach(() => { beforeEach(() => {
factory({ factory({
propsData: { namespace: listType }, propsData: { namespace },
provide: { dependencyListVulnerabilities: isFeatureFlagEnabled }, provide: { dependencyListVulnerabilities: isFeatureFlagEnabled },
}); });
store.state[listType].endpoint = `${TEST_HOST}/dependencies`; store.state[namespace].endpoint = `${TEST_HOST}/dependencies`;
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -61,7 +61,7 @@ describe.each` ...@@ -61,7 +61,7 @@ describe.each`
expect(store.dispatch.mock.calls).toEqual( expect(store.dispatch.mock.calls).toEqual(
expect.arrayContaining( expect.arrayContaining(
Object.keys(sortFields).map(field => [`${listType}/setSortField`, field]), Object.keys(sortFields).map(field => [`${namespace}/setSortField`, field]),
), ),
); );
}); });
...@@ -69,14 +69,14 @@ describe.each` ...@@ -69,14 +69,14 @@ describe.each`
it('dispatches the toggleSortOrder action on clicking the sort order button', () => { it('dispatches the toggleSortOrder action on clicking the sort order button', () => {
const sortButton = wrapper.find('.js-sort-order'); const sortButton = wrapper.find('.js-sort-order');
sortButton.vm.$emit('click'); sortButton.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith(`${listType}/toggleSortOrder`); expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/toggleSortOrder`);
}); });
it('has a button to export the dependency list', () => { it('has a button to export the dependency list', () => {
const download = wrapper.find('.js-download'); const download = wrapper.find('.js-download');
expect(download.attributes()).toEqual( expect(download.attributes()).toEqual(
expect.objectContaining({ expect.objectContaining({
href: store.getters[`${listType}/downloadEndpoint`], href: store.getters[`${namespace}/downloadEndpoint`],
download: expect.any(String), download: expect.any(String),
}), }),
); );
......
...@@ -9,7 +9,7 @@ import mockDependenciesResponse from '../store/modules/list/data/mock_dependenci ...@@ -9,7 +9,7 @@ import mockDependenciesResponse from '../store/modules/list/data/mock_dependenci
describe('PaginatedDependenciesTable component', () => { describe('PaginatedDependenciesTable component', () => {
let store; let store;
let wrapper; let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all; const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = (props = {}) => { const factory = (props = {}) => {
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -31,11 +31,11 @@ describe('PaginatedDependenciesTable component', () => { ...@@ -31,11 +31,11 @@ describe('PaginatedDependenciesTable component', () => {
}; };
beforeEach(() => { beforeEach(() => {
factory({ namespace: listType }); factory({ namespace });
const originalDispatch = store.dispatch; const originalDispatch = store.dispatch;
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
originalDispatch(`${listType}/receiveDependenciesSuccess`, { originalDispatch(`${namespace}/receiveDependenciesSuccess`, {
data: mockDependenciesResponse, data: mockDependenciesResponse,
headers: { 'X-Total': mockDependenciesResponse.dependencies.length }, headers: { 'X-Total': mockDependenciesResponse.dependencies.length },
}); });
...@@ -50,14 +50,14 @@ describe('PaginatedDependenciesTable component', () => { ...@@ -50,14 +50,14 @@ describe('PaginatedDependenciesTable component', () => {
it('passes the correct props to the dependencies table', () => { it('passes the correct props to the dependencies table', () => {
expectComponentWithProps(DependenciesTable, { expectComponentWithProps(DependenciesTable, {
dependencies: mockDependenciesResponse.dependencies, dependencies: mockDependenciesResponse.dependencies,
isLoading: store.state[listType].isLoading, isLoading: store.state[namespace].isLoading,
}); });
}); });
it('passes the correct props to the pagination', () => { it('passes the correct props to the pagination', () => {
expectComponentWithProps(Pagination, { expectComponentWithProps(Pagination, {
change: wrapper.vm.fetchPage, change: wrapper.vm.fetchPage,
pageInfo: store.state[listType].pageInfo, pageInfo: store.state[namespace].pageInfo,
}); });
}); });
...@@ -65,7 +65,7 @@ describe('PaginatedDependenciesTable component', () => { ...@@ -65,7 +65,7 @@ describe('PaginatedDependenciesTable component', () => {
const page = 2; const page = 2;
wrapper.vm.fetchPage(page); wrapper.vm.fetchPage(page);
expect(store.dispatch).toHaveBeenCalledTimes(1); expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(`${listType}/fetchDependencies`, { page }); expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/fetchDependencies`, { page });
}); });
describe.each` describe.each`
...@@ -77,7 +77,7 @@ describe('PaginatedDependenciesTable component', () => { ...@@ -77,7 +77,7 @@ describe('PaginatedDependenciesTable component', () => {
let module; let module;
beforeEach(() => { beforeEach(() => {
module = store.state[listType]; module = store.state[namespace];
if (isListEmpty) { if (isListEmpty) {
module.dependencies = []; module.dependencies = [];
module.pageInfo.total = 0; module.pageInfo.total = 0;
......
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/dependencies/store/actions';
import * as types from 'ee/dependencies/store/mutation_types';
import createState from 'ee/dependencies/store/state';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
describe('Dependencies actions', () => {
describe('addListType', () => {
it('commits the ADD_LIST_TYPE mutation', () => {
const payload = DEPENDENCY_LIST_TYPES.vulnerable;
return testAction(
actions.addListType,
payload,
createState(),
[
{
type: types.ADD_LIST_TYPE,
payload,
},
],
[],
);
});
});
describe.each`
actionName | payload
${'setDependenciesEndpoint'} | ${TEST_HOST}
${'fetchDependencies'} | ${undefined}
`('$actionName', ({ actionName, payload }) => {
it(`dispatches the ${actionName} action on each list module`, () => {
const state = createState();
state.listTypes.push({ namespace: 'foo' });
return testAction(
actions[actionName],
payload,
state,
[],
[
{
type: `allDependencies/${actionName}`,
payload,
},
{
type: `foo/${actionName}`,
payload,
},
],
);
});
});
describe('setCurrentList', () => {
let payload;
let state;
beforeEach(() => {
state = {
listTypes: [{ namespace: 'foo' }, { namespace: 'bar' }],
};
});
describe('given an existing namespace', () => {
beforeEach(() => {
payload = 'bar';
});
it('commits the SET_CURRENT_LIST mutation if given a valid list', () =>
testAction(
actions.setCurrentList,
payload,
state,
[
{
type: types.SET_CURRENT_LIST,
payload: 'bar',
},
],
[],
));
});
describe('given a non-existent namespace', () => {
beforeEach(() => {
payload = 'qux';
});
it('does nothing', () => testAction(actions.setCurrentList, payload, state, [], []));
});
});
});
import * as getters from 'ee/dependencies/store/getters';
describe('Dependencies getters', () => {
describe.each`
getterName | propertyName
${'isInitialized'} | ${'initialized'}
${'reportInfo'} | ${'reportInfo'}
`('$getterName', ({ getterName, propertyName }) => {
it(`returns the value from the current list module's state`, () => {
const mockValue = {};
const state = {
listFoo: {
[propertyName]: mockValue,
},
currentList: 'listFoo',
};
expect(getters[getterName](state)).toBe(mockValue);
});
});
describe.each`
getterName
${'isJobNotSetUp'}
${'isJobFailed'}
${'isIncomplete'}
`('$getterName', ({ getterName }) => {
it(`delegates to the current list module's ${getterName} getter`, () => {
const mockValue = {};
const currentList = 'fooList';
const state = { currentList };
const rootGetters = {
[`${currentList}/${getterName}`]: mockValue,
};
expect(getters[getterName](state, rootGetters)).toBe(mockValue);
});
});
describe('totals', () => {
it('returns a map of list module namespaces to total counts', () => {
const state = {
listTypes: [
{ namespace: 'foo' },
{ namespace: 'bar' },
{ namespace: 'qux' },
{ namespace: 'foobar' },
],
foo: { pageInfo: { total: 1 } },
bar: { pageInfo: { total: 2 } },
qux: { pageInfo: { total: NaN } },
foobar: { pageInfo: {} },
};
expect(getters.totals(state)).toEqual({
foo: 1,
bar: 2,
qux: 0,
foobar: 0,
});
});
});
});
...@@ -6,7 +6,11 @@ import { TEST_HOST } from 'helpers/test_constants'; ...@@ -6,7 +6,11 @@ import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/dependencies/store/modules/list/actions'; import * as actions from 'ee/dependencies/store/modules/list/actions';
import * as types from 'ee/dependencies/store/modules/list/mutation_types'; import * as types from 'ee/dependencies/store/modules/list/mutation_types';
import getInitialState from 'ee/dependencies/store/modules/list/state'; import getInitialState from 'ee/dependencies/store/modules/list/state';
import { SORT_ORDER, FETCH_ERROR_MESSAGE } from 'ee/dependencies/store/modules/list/constants'; import {
FILTER,
SORT_ORDER,
FETCH_ERROR_MESSAGE,
} from 'ee/dependencies/store/modules/list/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import mockDependenciesResponse from './data/mock_dependencies'; import mockDependenciesResponse from './data/mock_dependencies';
...@@ -52,6 +56,25 @@ describe('Dependencies actions', () => { ...@@ -52,6 +56,25 @@ describe('Dependencies actions', () => {
)); ));
}); });
describe('setInitialState', () => {
it('commits the SET_INITIAL_STATE mutation', () => {
const payload = { filter: 'foo' };
return testAction(
actions.setInitialState,
payload,
getInitialState(),
[
{
type: types.SET_INITIAL_STATE,
payload,
},
],
[],
);
});
});
describe('requestDependencies', () => { describe('requestDependencies', () => {
it('commits the REQUEST_DEPENDENCIES mutation', () => it('commits the REQUEST_DEPENDENCIES mutation', () =>
testAction( testAction(
...@@ -141,6 +164,7 @@ describe('Dependencies actions', () => { ...@@ -141,6 +164,7 @@ describe('Dependencies actions', () => {
sort_by: state.sortField, sort_by: state.sortField,
sort: state.sortOrder, sort: state.sortOrder,
page: state.pageInfo.page, page: state.pageInfo.page,
filter: state.filter,
}; };
mock mock
...@@ -167,7 +191,12 @@ describe('Dependencies actions', () => { ...@@ -167,7 +191,12 @@ describe('Dependencies actions', () => {
}); });
describe('given params', () => { describe('given params', () => {
const paramsGiven = { sort_by: 'packager', sort: SORT_ORDER.descending, page: 4 }; const paramsGiven = {
sort_by: 'packager',
sort: SORT_ORDER.descending,
page: 4,
filter: FILTER.vulnerable,
};
beforeEach(() => { beforeEach(() => {
mock mock
......
...@@ -19,6 +19,15 @@ describe('Dependencies mutations', () => { ...@@ -19,6 +19,15 @@ describe('Dependencies mutations', () => {
}); });
}); });
describe(types.SET_INITIAL_STATE, () => {
it('correctly mutates the state', () => {
const filter = 'foo';
mutations[types.SET_INITIAL_STATE](state, { filter });
expect(state.filter).toBe(filter);
});
});
describe(types.REQUEST_DEPENDENCIES, () => { describe(types.REQUEST_DEPENDENCIES, () => {
beforeEach(() => { beforeEach(() => {
mutations[types.REQUEST_DEPENDENCIES](state); mutations[types.REQUEST_DEPENDENCIES](state);
......
import * as types from 'ee/dependencies/store/mutation_types';
import mutations from 'ee/dependencies/store/mutations';
import getInitialState from 'ee/dependencies/store/state';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
describe('Dependencies mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe(types.ADD_LIST_TYPE, () => {
it('adds the given module type to the list of modules', () => {
const typeToAdd = DEPENDENCY_LIST_TYPES.vulnerable;
mutations[types.ADD_LIST_TYPE](state, typeToAdd);
const lastAdded = state.listTypes[state.listTypes.length - 1];
expect(lastAdded).toEqual(typeToAdd);
});
});
describe(types.SET_CURRENT_LIST, () => {
it('sets the current list namespace', () => {
const namespace = 'foobar';
mutations[types.SET_CURRENT_LIST](state, namespace);
expect(state.currentList).toBe(namespace);
});
});
});
import { addListType } from 'ee/dependencies/store/utils';
import listModule from 'ee/dependencies/store/modules/list';
const mockModule = { mock: true };
jest.mock('ee/dependencies/store/modules/list', () => ({
// `__esModule: true` is required when mocking modules with default exports:
// https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options
__esModule: true,
default: jest.fn(() => mockModule),
}));
describe('Dependencies store utils', () => {
describe('addListType', () => {
it('calls the correct store methods', () => {
const store = {
dispatch: jest.fn(),
registerModule: jest.fn(),
};
const listType = {
namespace: 'foo',
initialState: { bar: true },
};
addListType(store, listType);
expect(listModule).toHaveBeenCalled();
expect(store.registerModule.mock.calls).toEqual([[listType.namespace, mockModule]]);
expect(store.dispatch.mock.calls).toEqual([
['addListType', listType],
[`${listType.namespace}/setInitialState`, listType.initialState],
]);
});
});
});
...@@ -4513,6 +4513,9 @@ msgid_plural "Dependencies|%d vulnerabilities" ...@@ -4513,6 +4513,9 @@ msgid_plural "Dependencies|%d vulnerabilities"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Dependencies|All"
msgstr ""
msgid "Dependencies|Component" msgid "Dependencies|Component"
msgstr "" msgstr ""
...@@ -4546,6 +4549,9 @@ msgstr "" ...@@ -4546,6 +4549,9 @@ msgstr ""
msgid "Dependencies|Version" msgid "Dependencies|Version"
msgstr "" msgstr ""
msgid "Dependencies|Vulnerable components"
msgstr ""
msgid "Dependency List" msgid "Dependency List"
msgstr "" 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