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>
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 DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
......@@ -14,10 +14,18 @@ export default {
GlBadge,
GlEmptyState,
GlLoadingIcon,
GlTab,
GlTabs,
DependencyListIncompleteAlert,
DependencyListJobFailedAlert,
PaginatedDependenciesTable,
},
inject: {
dependencyListVulnerabilities: {
from: 'dependencyListVulnerabilities',
default: false,
},
},
props: {
endpoint: {
type: String,
......@@ -39,28 +47,47 @@ export default {
};
},
computed: {
...mapState(['currentList']),
...mapGetters(DEPENDENCY_LIST_TYPES.all, ['isJobNotSetUp', 'isJobFailed', 'isIncomplete']),
...mapState(DEPENDENCY_LIST_TYPES.all, ['initialized', 'pageInfo', 'reportInfo']),
...mapState(['currentList', 'listTypes']),
...mapGetters([
'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() {
this.setDependenciesEndpoint(this.endpoint);
this.fetchDependencies();
},
methods: {
...mapActions(DEPENDENCY_LIST_TYPES.all, ['setDependenciesEndpoint', 'fetchDependencies']),
...mapActions(['setDependenciesEndpoint', 'fetchDependencies', 'setCurrentList']),
dismissIncompleteListAlert() {
this.isIncompleteAlertDismissed = true;
},
dismissJobFailedAlert() {
this.isJobFailedAlertDismissed = true;
},
isTabDisabled(namespace) {
return this.totals[namespace] <= 0;
},
},
};
</script>
<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
v-else-if="isJobNotSetUp"
......@@ -85,15 +112,41 @@ export default {
@close="dismissJobFailedAlert"
/>
<div class="d-sm-flex justify-content-between align-items-baseline my-2">
<h4 class="h5">
{{ __('Dependencies') }}
<gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge>
</h4>
<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">
<h3 class="h5">
{{ __('Dependencies') }}
<gl-badge v-if="pageInfo.total" pill>{{ pageInfo.total }}</gl-badge>
</h3>
<dependencies-actions :namespace="currentList" />
</div>
<dependencies-actions :namespace="currentList" />
</div>
<paginated-dependencies-table :namespace="currentList" />
<paginated-dependencies-table :namespace="currentList" />
</template>
</div>
</template>
......@@ -30,7 +30,8 @@ export default {
namespace: {
type: String,
required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value),
validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
},
},
data() {
......
......@@ -14,7 +14,8 @@ export default {
namespace: {
type: String,
required: true,
validator: value => Object.values(DEPENDENCY_LIST_TYPES).includes(value),
validator: value =>
Object.values(DEPENDENCY_LIST_TYPES).some(({ namespace }) => value === namespace),
},
},
computed: {
......
import Vue from 'vue';
import DependenciesApp from './components/app.vue';
import createStore from './store';
import { DEPENDENCY_LIST_TYPES } from './store/constants';
import { addListType } from './store/utils';
export default () => {
const el = document.querySelector('#js-dependencies-app');
......@@ -9,6 +11,10 @@ export default () => {
const store = createStore();
if (dependencyListVulnerabilities) {
addListType(store, DEPENDENCY_LIST_TYPES.vulnerable);
}
return new Vue({
el,
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
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 Vuex from 'vuex';
import listModule from './modules/list';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
import { DEPENDENCY_LIST_TYPES } from './constants';
Vue.use(Vuex);
export default () => {
const allDependencies = listModule();
return new Vuex.Store({
export default () =>
new Vuex.Store({
modules: {
allDependencies,
[DEPENDENCY_LIST_TYPES.all.namespace]: listModule(),
},
actions,
getters,
mutations,
state,
});
};
......@@ -9,6 +9,8 @@ import { __ } from '~/locale';
export const setDependenciesEndpoint = ({ commit }, 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 receiveDependenciesSuccess = ({ commit }, { headers, data }) => {
......@@ -35,6 +37,7 @@ export const fetchDependencies = ({ state, dispatch }, params = {}) => {
sort_by: state.sortField,
sort: state.sortOrder,
page: state.pageInfo.page || 1,
filter: state.filter,
...params,
},
})
......
......@@ -23,6 +23,11 @@ export const REPORT_STATUS = {
incomplete: 'no_dependency_files',
};
export const FILTER = {
all: 'all',
vulnerable: 'vulnerable',
};
export const FETCH_ERROR_MESSAGE = __(
'Error fetching the dependency list. Please check your network connection and try again.',
);
export const SET_DEPENDENCIES_ENDPOINT = 'SET_DEPENDENCIES_ENDPOINT';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_DEPENDENCIES = 'REQUEST_DEPENDENCIES';
export const RECEIVE_DEPENDENCIES_SUCCESS = 'RECEIVE_DEPENDENCIES_SUCCESS';
......
......@@ -5,6 +5,9 @@ export default {
[types.SET_DEPENDENCIES_ENDPOINT](state, payload) {
state.endpoint = payload;
},
[types.SET_INITIAL_STATE](state, payload) {
Object.assign(state, payload);
},
[types.REQUEST_DEPENDENCIES](state) {
state.isLoading = true;
state.errorLoading = false;
......
import { REPORT_STATUS, SORT_ORDER } from './constants';
import { FILTER, REPORT_STATUS, SORT_ORDER } from './constants';
export default () => ({
endpoint: '',
......@@ -6,11 +6,14 @@ export default () => ({
isLoading: false,
errorLoading: false,
dependencies: [],
pageInfo: {},
pageInfo: {
total: 0,
},
reportInfo: {
status: REPORT_STATUS.ok,
jobPath: '',
},
filter: FILTER.all,
sortField: 'name',
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';
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);
};
......@@ -13,8 +13,8 @@ exports[`DependenciesApp component on creation given a dependency list which is
class="h5"
>
Dependencies
Dependencies
<glbadge-stub
pill=""
>
......@@ -46,8 +46,8 @@ exports[`DependenciesApp component on creation given a fetch error matches the s
class="h5"
>
Dependencies
Dependencies
<!---->
</h4>
......@@ -75,8 +75,8 @@ exports[`DependenciesApp component on creation given a list of dependencies and
class="h5"
>
Dependencies
Dependencies
<glbadge-stub
pill=""
>
......@@ -110,8 +110,8 @@ exports[`DependenciesApp component on creation given the dependency list job fai
class="h5"
>
Dependencies
Dependencies
<!---->
</h4>
......
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 createStore from 'ee/dependencies/store';
import { addListType } from 'ee/dependencies/store/utils';
import { DEPENDENCY_LIST_TYPES } from 'ee/dependencies/store/constants';
import { REPORT_STATUS } from 'ee/dependencies/store/modules/list/constants';
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 DependencyListJobFailedAlert from 'ee/dependencies/components/dependency_list_job_failed_alert.vue';
import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dependencies_table.vue';
......@@ -11,7 +14,7 @@ import PaginatedDependenciesTable from 'ee/dependencies/components/paginated_dep
describe('DependenciesApp component', () => {
let store;
let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all;
const { namespace } = DEPENDENCY_LIST_TYPES.all;
const basicAppProps = {
endpoint: '/foo',
......@@ -52,8 +55,8 @@ describe('DependenciesApp component', () => {
it('dispatches the correct initial actions', () => {
expect(store.dispatch.mock.calls).toEqual([
[`${listType}/setDependenciesEndpoint`, basicAppProps.endpoint],
[`${listType}/fetchDependencies`, undefined],
['setDependenciesEndpoint', basicAppProps.endpoint],
['fetchDependencies'],
]);
});
......@@ -65,13 +68,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => {
dependencies = ['foo', 'bar'];
Object.assign(store.state[listType], {
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
dependencies,
});
store.state[listType].pageInfo.total = 100;
store.state[listType].reportInfo.status = REPORT_STATUS.ok;
store.state[namespace].pageInfo.total = 100;
store.state[namespace].reportInfo.status = REPORT_STATUS.ok;
return wrapper.vm.$nextTick();
});
......@@ -82,7 +85,7 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType,
namespace,
});
});
});
......@@ -91,13 +94,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => {
dependencies = [];
Object.assign(store.state[listType], {
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
dependencies,
});
store.state[listType].pageInfo.total = 0;
store.state[listType].reportInfo.status = REPORT_STATUS.jobNotSetUp;
store.state[namespace].pageInfo.total = 0;
store.state[namespace].reportInfo.status = REPORT_STATUS.jobNotSetUp;
return wrapper.vm.$nextTick();
});
......@@ -111,14 +114,14 @@ describe('DependenciesApp component', () => {
beforeEach(() => {
dependencies = [];
Object.assign(store.state[listType], {
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
dependencies,
});
store.state[listType].pageInfo.total = 0;
store.state[listType].reportInfo.status = REPORT_STATUS.jobFailed;
store.state[listType].reportInfo.jobPath = '/jobs/foo/321';
store.state[namespace].pageInfo.total = 0;
store.state[namespace].reportInfo.status = REPORT_STATUS.jobFailed;
store.state[namespace].reportInfo.jobPath = '/jobs/foo/321';
return wrapper.vm.$nextTick();
});
......@@ -129,13 +132,13 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the job failure alert', () => {
expectComponentWithProps(DependencyListJobFailedAlert, {
jobPath: store.state[listType].reportInfo.jobPath,
jobPath: store.state[namespace].reportInfo.jobPath,
});
});
it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType,
namespace,
});
});
......@@ -156,13 +159,13 @@ describe('DependenciesApp component', () => {
beforeEach(() => {
dependencies = ['foo', 'bar'];
Object.assign(store.state[listType], {
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
dependencies,
});
store.state[listType].pageInfo.total = 100;
store.state[listType].reportInfo.status = REPORT_STATUS.incomplete;
store.state[namespace].pageInfo.total = 100;
store.state[namespace].reportInfo.status = REPORT_STATUS.incomplete;
return wrapper.vm.$nextTick();
});
......@@ -178,7 +181,7 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => {
expectComponentWithProps(PaginatedDependenciesTable, {
namespace: listType,
namespace,
});
});
......@@ -199,7 +202,7 @@ describe('DependenciesApp component', () => {
beforeEach(() => {
dependencies = [];
Object.assign(store.state[listType], {
Object.assign(store.state[namespace], {
initialized: true,
isLoading: false,
errorLoading: true,
......@@ -215,7 +218,282 @@ describe('DependenciesApp component', () => {
it('passes the correct props to the paginated dependencies table', () => {
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`
`('DependenciesActions component$context', ({ isFeatureFlagEnabled, sortFields }) => {
let store;
let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all;
const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = ({ propsData, ...options } = {}) => {
const localVue = createLocalVue();
......@@ -35,10 +35,10 @@ describe.each`
beforeEach(() => {
factory({
propsData: { namespace: listType },
propsData: { namespace },
provide: { dependencyListVulnerabilities: isFeatureFlagEnabled },
});
store.state[listType].endpoint = `${TEST_HOST}/dependencies`;
store.state[namespace].endpoint = `${TEST_HOST}/dependencies`;
return wrapper.vm.$nextTick();
});
......@@ -61,7 +61,7 @@ describe.each`
expect(store.dispatch.mock.calls).toEqual(
expect.arrayContaining(
Object.keys(sortFields).map(field => [`${listType}/setSortField`, field]),
Object.keys(sortFields).map(field => [`${namespace}/setSortField`, field]),
),
);
});
......@@ -69,14 +69,14 @@ describe.each`
it('dispatches the toggleSortOrder action on clicking the sort order button', () => {
const sortButton = wrapper.find('.js-sort-order');
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', () => {
const download = wrapper.find('.js-download');
expect(download.attributes()).toEqual(
expect.objectContaining({
href: store.getters[`${listType}/downloadEndpoint`],
href: store.getters[`${namespace}/downloadEndpoint`],
download: expect.any(String),
}),
);
......
......@@ -9,7 +9,7 @@ import mockDependenciesResponse from '../store/modules/list/data/mock_dependenci
describe('PaginatedDependenciesTable component', () => {
let store;
let wrapper;
const listType = DEPENDENCY_LIST_TYPES.all;
const { namespace } = DEPENDENCY_LIST_TYPES.all;
const factory = (props = {}) => {
const localVue = createLocalVue();
......@@ -31,11 +31,11 @@ describe('PaginatedDependenciesTable component', () => {
};
beforeEach(() => {
factory({ namespace: listType });
factory({ namespace });
const originalDispatch = store.dispatch;
jest.spyOn(store, 'dispatch').mockImplementation();
originalDispatch(`${listType}/receiveDependenciesSuccess`, {
originalDispatch(`${namespace}/receiveDependenciesSuccess`, {
data: mockDependenciesResponse,
headers: { 'X-Total': mockDependenciesResponse.dependencies.length },
});
......@@ -50,14 +50,14 @@ describe('PaginatedDependenciesTable component', () => {
it('passes the correct props to the dependencies table', () => {
expectComponentWithProps(DependenciesTable, {
dependencies: mockDependenciesResponse.dependencies,
isLoading: store.state[listType].isLoading,
isLoading: store.state[namespace].isLoading,
});
});
it('passes the correct props to the pagination', () => {
expectComponentWithProps(Pagination, {
change: wrapper.vm.fetchPage,
pageInfo: store.state[listType].pageInfo,
pageInfo: store.state[namespace].pageInfo,
});
});
......@@ -65,7 +65,7 @@ describe('PaginatedDependenciesTable component', () => {
const page = 2;
wrapper.vm.fetchPage(page);
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith(`${listType}/fetchDependencies`, { page });
expect(store.dispatch).toHaveBeenCalledWith(`${namespace}/fetchDependencies`, { page });
});
describe.each`
......@@ -77,7 +77,7 @@ describe('PaginatedDependenciesTable component', () => {
let module;
beforeEach(() => {
module = store.state[listType];
module = store.state[namespace];
if (isListEmpty) {
module.dependencies = [];
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';
import * as actions from 'ee/dependencies/store/modules/list/actions';
import * as types from 'ee/dependencies/store/modules/list/mutation_types';
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 mockDependenciesResponse from './data/mock_dependencies';
......@@ -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', () => {
it('commits the REQUEST_DEPENDENCIES mutation', () =>
testAction(
......@@ -141,6 +164,7 @@ describe('Dependencies actions', () => {
sort_by: state.sortField,
sort: state.sortOrder,
page: state.pageInfo.page,
filter: state.filter,
};
mock
......@@ -167,7 +191,12 @@ describe('Dependencies actions', () => {
});
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(() => {
mock
......
......@@ -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, () => {
beforeEach(() => {
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"
msgstr[0] ""
msgstr[1] ""
msgid "Dependencies|All"
msgstr ""
msgid "Dependencies|Component"
msgstr ""
......@@ -4546,6 +4549,9 @@ msgstr ""
msgid "Dependencies|Version"
msgstr ""
msgid "Dependencies|Vulnerable components"
msgstr ""
msgid "Dependency List"
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