Commit 2851fbee authored by David Pisek's avatar David Pisek Committed by Paul Slaughter

Add unscanned projects: Vuex store module

This commit adds a new vuex-module to fetch unscanned projects that
are used to render a widget on the group-security dashboard.

This includes:

* Store module
* Getters, actions, mutations and utils
* Tests
* Translation files
parent 2b3ac7af
...@@ -24,3 +24,10 @@ export const DASHBOARD_TYPES = { ...@@ -24,3 +24,10 @@ export const DASHBOARD_TYPES = {
GROUP: 'group', GROUP: 'group',
INSTANCE: 'instance', INSTANCE: 'instance',
}; };
export const UNSCANNED_PROJECTS_DATE_RANGES = [
{ description: s__('UnscannedProjects|5 or more days'), fromDay: 5, toDay: 15 },
{ description: s__('UnscannedProjects|15 or more days'), fromDay: 15, toDay: 30 },
{ description: s__('UnscannedProjects|30 or more days'), fromDay: 30, toDay: 60 },
{ description: s__('UnscannedProjects|60 or more days'), fromDay: 60, toDay: Infinity },
];
...@@ -7,6 +7,7 @@ import mediator from './plugins/mediator'; ...@@ -7,6 +7,7 @@ import mediator from './plugins/mediator';
import filters from './modules/filters/index'; import filters from './modules/filters/index';
import vulnerabilities from './modules/vulnerabilities/index'; import vulnerabilities from './modules/vulnerabilities/index';
import vulnerableProjects from './modules/vulnerable_projects/index'; import vulnerableProjects from './modules/vulnerable_projects/index';
import unscannedProjects from './modules/unscanned_projects/index';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -19,6 +20,7 @@ export default ({ dashboardType = DASHBOARD_TYPES.PROJECT, plugins = [] } = {}) ...@@ -19,6 +20,7 @@ export default ({ dashboardType = DASHBOARD_TYPES.PROJECT, plugins = [] } = {})
vulnerableProjects, vulnerableProjects,
filters, filters,
vulnerabilities, vulnerabilities,
unscannedProjects,
}, },
plugins: [mediator, ...plugins], plugins: [mediator, ...plugins],
}); });
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
REQUEST_UNSCANNED_PROJECTS,
RECEIVE_UNSCANNED_PROJECTS_SUCCESS,
RECEIVE_UNSCANNED_PROJECTS_ERROR,
} from './mutation_types';
export const fetchUnscannedProjects = ({ dispatch }, endpoint) => {
dispatch('requestUnscannedProjects');
return axios
.get(endpoint)
.then(({ data }) => data.map(convertObjectPropsToCamelCase))
.then(data => {
dispatch('receiveUnscannedProjectsSuccess', data);
})
.catch(() => {
dispatch('receiveUnscannedProjectsError');
});
};
export const requestUnscannedProjects = ({ commit }) => {
commit(REQUEST_UNSCANNED_PROJECTS);
};
export const receiveUnscannedProjectsSuccess = ({ commit }, payload) => {
commit(RECEIVE_UNSCANNED_PROJECTS_SUCCESS, payload);
};
export const receiveUnscannedProjectsError = ({ commit }) => {
createFlash(__('Unable to fetch unscanned projects'));
commit(RECEIVE_UNSCANNED_PROJECTS_ERROR);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { groupByDateRanges } from './utils';
import { UNSCANNED_PROJECTS_DATE_RANGES } from '../../constants';
export const untestedProjects = ({ projects }) =>
projects.filter(({ securityTestsUnconfigured }) => securityTestsUnconfigured === true);
export const untestedProjectsCount = (state, getters) => getters.untestedProjects.length;
export const outdatedProjects = ({ projects }) =>
groupByDateRanges({
ranges: UNSCANNED_PROJECTS_DATE_RANGES,
dateFn: x => x.securityTestsLastSuccessfulRun,
projects,
});
export const outdatedProjectsCount = (state, getters) =>
getters.outdatedProjects.reduce((count, currentGroup) => count + currentGroup.projects.length, 0);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};
export const REQUEST_UNSCANNED_PROJECTS = 'REQUEST_UNSCANNED_PROJECTS';
export const RECEIVE_UNSCANNED_PROJECTS_SUCCESS = 'RECEIVE_UNSCANNED_PROJECTS_SUCCESS';
export const RECEIVE_UNSCANNED_PROJECTS_ERROR = 'RECEIVE_UNSCANNED_PROJECTS_ERROR';
import {
REQUEST_UNSCANNED_PROJECTS,
RECEIVE_UNSCANNED_PROJECTS_SUCCESS,
RECEIVE_UNSCANNED_PROJECTS_ERROR,
} from './mutation_types';
export default {
[REQUEST_UNSCANNED_PROJECTS](state) {
state.isLoading = true;
},
[RECEIVE_UNSCANNED_PROJECTS_SUCCESS](state, projects) {
state.isLoading = false;
state.projects = projects;
},
[RECEIVE_UNSCANNED_PROJECTS_ERROR](state) {
state.isLoading = false;
},
};
/* eslint-disable import/prefer-default-export */
import { getDayDifference } from '~/lib/utils/datetime_utility';
/**
* Checks if a given date instance has been created with a valid date
*
* @param date {Date}
* @returns {boolean}
*/
const isValidDate = date => !Number.isNaN(date.getTime());
/**
* Checks if a given number of days is within a date range
*
* @param daysInPast {number}
* @returns {function({fromDay: Number, toDay: Number}): boolean}
*/
const isWithinDateRange = daysInPast => ({ fromDay, toDay }) =>
daysInPast >= fromDay && daysInPast < toDay;
/**
* Adds an empty 'projects' array to each item of a given array
*
* @param ranges {*}[]
* @returns {{projects: []}}[]
*/
const withEmptyProjectsArray = ranges => ranges.map(range => ({ ...range, projects: [] }));
/**
* Checks if a given group-object has any projects
*
* @param group {{ projects: [] }}
* @returns {boolean}
*/
const hasProjects = group => group.projects.length > 0;
/**
* Takes an array of objects and groups them based on the given ranges
*
* @param ranges {*}[]
* @param dateFn {Function}
* @param projects {*}[]
* @returns {*}[]
*/
export const groupByDateRanges = ({ ranges = [], dateFn = () => {}, projects = [] }) => {
const today = new Date(Date.now());
return projects
.reduce((groups, currentProject) => {
const timeString = dateFn(currentProject);
const pastDate = new Date(timeString);
if (!isValidDate(pastDate) || !timeString) {
return groups;
}
const numDaysInPast = getDayDifference(pastDate, today);
const matchingGroup = groups.find(isWithinDateRange(numDaysInPast));
if (matchingGroup) {
matchingGroup.projects.push(currentProject);
}
return groups;
}, withEmptyProjectsArray(ranges))
.filter(hasProjects);
};
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import createState from 'ee/security_dashboard/store/modules/unscanned_projects/state';
import * as types from 'ee/security_dashboard/store/modules/unscanned_projects/mutation_types';
import * as actions from 'ee/security_dashboard/store/modules/unscanned_projects/actions';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
jest.mock('~/flash');
describe('EE Unscanned Projects actions', () => {
const mockEndpoint = 'mock-list-endpoint';
const mockResponse = [{ key_foo: 'valueFoo' }];
let mockAxios;
let state;
beforeEach(() => {
mockAxios = new MockAdapter(axios);
state = createState();
});
afterEach(() => {
mockAxios.restore();
});
describe('fetchUnscannedProjects', () => {
it('calls the unscanned projects endpoint and transforms the response keys into camel case', () => {
mockAxios.onGet(mockEndpoint).replyOnce(200, mockResponse);
const mockResponseCamelCased = [{ keyFoo: 'valueFoo' }];
return testAction(
actions.fetchUnscannedProjects,
mockEndpoint,
state,
[],
[
{ type: 'requestUnscannedProjects' },
{ type: 'receiveUnscannedProjectsSuccess', payload: mockResponseCamelCased },
],
);
});
it('handles an API error by dispatching "receiveUnscannedProjectsError"', () => {
mockAxios.onGet(mockEndpoint).replyOnce(500);
return testAction(
actions.fetchUnscannedProjects,
mockEndpoint,
state,
[],
[{ type: 'requestUnscannedProjects' }, { type: 'receiveUnscannedProjectsError' }],
);
});
});
describe('requestUnscannedProjects', () => {
it('commits the REQUEST_UNSCANNED_PROJECTS mutations', () =>
testAction(
actions.requestUnscannedProjects,
null,
state,
[
{
type: types.REQUEST_UNSCANNED_PROJECTS,
},
],
[],
));
});
describe('receiveUnscannedProjectsSuccess', () => {
it('commits the RECEIVE_UNSCANNED_PROJECTS_SUCCESS mutation', () => {
const projects = [];
return testAction(
actions.receiveUnscannedProjectsSuccess,
projects,
state,
[
{
type: types.RECEIVE_UNSCANNED_PROJECTS_SUCCESS,
payload: projects,
},
],
[],
);
});
});
describe('receiveUnscannedProjectsError', () => {
it('commits the RECEIVE_UNSCANNED_PROJECTS_ERROR mutation', () => {
const projects = [];
return testAction(
actions.receiveUnscannedProjectsError,
projects,
state,
[
{
type: types.RECEIVE_UNSCANNED_PROJECTS_ERROR,
},
],
[],
);
});
it('creates a flash error message', () => {
const mockDispatchContext = { dispatch: () => {}, commit: () => {}, state };
actions.receiveUnscannedProjectsError(mockDispatchContext);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith('Unable to fetch unscanned projects');
});
});
});
import * as getters from 'ee/security_dashboard/store/modules/unscanned_projects/getters';
import { UNSCANNED_PROJECTS_DATE_RANGES } from 'ee/security_dashboard/store/constants';
import { groupByDateRanges } from 'ee/security_dashboard/store/modules/unscanned_projects/utils';
describe('Unscanned projects getters', () => {
describe('untestedProjects', () => {
it('takes an array of projects and returns only projects that have "securityTestsUnconfigured" set to be "true"', () => {
const projects = [
{ securityTestsUnconfigured: null },
{ securityTestsUnconfigured: true },
{ securityTestsUnconfigured: false },
{ securityTestsUnconfigured: true },
{},
];
expect(getters.untestedProjects({ projects })).toStrictEqual([projects[1], projects[3]]);
});
});
describe('untestedProjectsCount', () => {
it('returns the amount of untestedProjects', () => {
const untestedProjects = [{}, {}, {}];
expect(getters.untestedProjectsCount({}, { untestedProjects })).toBe(untestedProjects.length);
});
});
describe('outdatedProjects', () => {
it('groups the given projects by date ranges', () => {
const mockedDate = new Date(2015, 4, 15);
jest.spyOn(global.Date, 'now').mockImplementation(() => mockedDate.valueOf());
const projects = [
{
description: '5 days ago',
securityTestsLastSuccessfulRun: '2015-05-10T10:00:00.0000',
},
{
description: '6 days ago',
securityTestsLastSuccessfulRun: '2015-05-09T10:00:00.0000',
},
{
description: '30 days ago',
securityTestsLastSuccessfulRun: '2015-04-15T10:00:00.0000',
},
{
description: '60 days ago',
securityTestsLastSuccessfulRun: '2015-03-16T10:00:00',
},
{
description: 'more than 60 days ago',
securityTestsLastSuccessfulRun: '2012-03-16T10:00:00',
},
];
const result = getters.outdatedProjects({ projects });
expect(result.length).toBe(3);
expect(result).toEqual(
groupByDateRanges({
ranges: UNSCANNED_PROJECTS_DATE_RANGES,
dateFn: x => x.securityTestsLastSuccessfulRun,
projects,
}),
);
});
});
describe('outdatedProjectsCount', () => {
it('returns the amount of outdated projects', () => {
const dateRangeOne = [{}, {}];
const dateRangeTwo = [{}];
const outdatedProjects = [{ projects: dateRangeOne }, { projects: dateRangeTwo }];
expect(getters.outdatedProjectsCount({}, { outdatedProjects })).toBe(
dateRangeOne.length + dateRangeTwo.length,
);
});
});
});
import createState from 'ee/security_dashboard/store/modules/unscanned_projects/state';
import mutations from 'ee/security_dashboard/store/modules/unscanned_projects/mutations';
import * as types from 'ee/security_dashboard/store/modules/unscanned_projects/mutation_types';
describe('unscannedProjects mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('REQUEST_UNSCANNED_PROJECTS', () => {
it('sets state.isLoading to be "true"', () => {
state.isLoading = false;
mutations[types.REQUEST_UNSCANNED_PROJECTS](state, true);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_UNSCANNED_PROJECTS_SUCCESS', () => {
it('sets state.isLoading to be "false"', () => {
state.isLoading = true;
const payload = [];
mutations[types.RECEIVE_UNSCANNED_PROJECTS_SUCCESS](state, payload);
expect(state.isLoading).toBe(false);
});
it('sets state.projects to the given payload', () => {
const payload = [];
mutations[types.RECEIVE_UNSCANNED_PROJECTS_SUCCESS](state, payload);
expect(state.projects).toBe(payload);
});
});
describe('RECEIVE_UNSCANNED_PROJECTS_ERROR', () => {
it('sets state.isLoading to be "false"', () => {
state.isLoading = true;
mutations[types.RECEIVE_UNSCANNED_PROJECTS_ERROR](state);
expect(state.isLoading).toBe(false);
});
});
});
import { groupByDateRanges } from 'ee/security_dashboard/store/modules/unscanned_projects/utils';
describe('Project scanning store utils', () => {
describe('groupByDayRanges', () => {
beforeEach(() => {
const mockedDate = new Date(2015, 4, 15);
jest.spyOn(global.Date, 'now').mockImplementation(() => mockedDate.valueOf());
});
afterEach(() => {
jest.clearAllMocks();
});
const ranges = [
{ fromDay: 5, toDay: 15, description: '5 days or older' },
{ fromDay: 30, toDay: 60, description: '30 days or older' },
{ fromDay: 60, toDay: Infinity, description: '60 days or older' },
];
it('groups an array of projects into day-ranges, based on when they were last updated', () => {
const projects = [
{
description: '5 days ago',
lastUpdated: '2015-05-10T10:00:00.0000',
},
{
description: '6 days ago',
lastUpdated: '2015-05-09T10:00:00.0000',
},
{
description: '30 days ago',
lastUpdated: '2015-04-15T10:00:00.0000',
},
{
description: '60 days ago',
lastUpdated: '2015-03-16T10:00:00',
},
{
description: 'more than 60 days ago',
lastUpdated: '2012-03-16T10:00:00',
},
];
const groups = groupByDateRanges({
ranges,
dateFn: x => x.lastUpdated,
projects,
});
expect(groups[0].projects).toEqual([projects[0], projects[1]]);
expect(groups[1].projects).toEqual([projects[2]]);
expect(groups[2].projects).toEqual([projects[3], projects[4]]);
});
it('ignores projects that do not match any given group', () => {
const projectWithoutMatchingGroup = {
description: '4 days ago',
lastUpdated: '2015-05-11T10:00:00.0000',
};
const projectWithMatchingGroup = {
description: '6 days ago',
lastUpdated: '2015-05-09T10:00:00.0000',
};
const projects = [projectWithMatchingGroup, projectWithoutMatchingGroup];
const groups = groupByDateRanges({
ranges,
dateFn: x => x.lastUpdated,
projects,
});
expect(groups.length).toBe(1);
expect(groups[0].projects).toEqual([projectWithMatchingGroup]);
});
it('ignores projects that do not contain valid time values', () => {
const projectsWithoutTimeStamp = [
{
description: 'No timestamp prop',
},
{
description: 'No timestamp prop',
lastUpdated: 'foo',
},
{
description: 'No timestamp prop',
lastUpdated: false,
},
];
const groups = groupByDateRanges({
ranges,
dateFn: x => x.lastUpdated,
projects: projectsWithoutTimeStamp,
});
expect(groups.length).toBe(0);
});
});
});
...@@ -20750,6 +20750,9 @@ msgstr "" ...@@ -20750,6 +20750,9 @@ msgstr ""
msgid "Unable to connect to server: %{error}" msgid "Unable to connect to server: %{error}"
msgstr "" msgstr ""
msgid "Unable to fetch unscanned projects"
msgstr ""
msgid "Unable to fetch vulnerable projects" msgid "Unable to fetch vulnerable projects"
msgstr "" msgstr ""
...@@ -20864,6 +20867,18 @@ msgstr "" ...@@ -20864,6 +20867,18 @@ msgstr ""
msgid "Unresolve thread" msgid "Unresolve thread"
msgstr "" msgstr ""
msgid "UnscannedProjects|15 or more days"
msgstr ""
msgid "UnscannedProjects|30 or more days"
msgstr ""
msgid "UnscannedProjects|5 or more days"
msgstr ""
msgid "UnscannedProjects|60 or more days"
msgstr ""
msgid "UnscannedProjects|Default branch scanning by project" msgid "UnscannedProjects|Default branch scanning by project"
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