Adds middleware to logout user for 401 errors (#1121)

This commit is contained in:
Mike Stone 2017-01-30 15:20:06 -05:00 committed by Jason Meller
parent 1b54ce18ab
commit f655ca5966
13 changed files with 155 additions and 75 deletions

View File

@ -0,0 +1,3 @@
export default {
UNAUTHENTICATED: 401,
};

View File

@ -1,7 +1,9 @@
import APP_SETTINGS from 'app_constants/APP_SETTINGS';
import HTTP_STATUS from 'app_constants/HTTP_STATUS';
import PATHS from 'router/paths';
export default {
APP_SETTINGS,
HTTP_STATUS,
PATHS,
};

View File

@ -1,5 +1,5 @@
import { push } from 'react-router-redux';
import { join, values } from 'lodash';
import { join, omit, values } from 'lodash';
import queryActions from 'redux/nodes/entities/queries/actions';
import { renderFlash } from 'redux/nodes/notifications/actions';
@ -7,7 +7,7 @@ import { renderFlash } from 'redux/nodes/notifications/actions';
export const fetchQuery = (dispatch, queryID) => {
return dispatch(queryActions.load(queryID))
.catch((errors) => {
const errorMessage = join(values(errors), ', ');
const errorMessage = join(values(omit(errors, 'http_status')), ', ');
dispatch(push('/queries/new'));
dispatch(renderFlash('error', errorMessage));

View File

@ -39,9 +39,10 @@ class Base {
}
const error = new Error(response.statusText);
error.response = jsonResponse;
error.message = jsonResponse;
error.error = jsonResponse.error;
error.message = jsonResponse;
error.response = jsonResponse;
error.status = response.status;
throw error;
});

View File

@ -1,14 +1,23 @@
/* eslint-disable no-unused-vars */
import { get } from 'lodash';
import { push } from 'react-router-redux';
import kolide from '../../kolide';
import { LOGIN_FAILURE, LOGIN_SUCCESS, LOGOUT_SUCCESS } from '../nodes/auth/actions';
import local from '../../utilities/local';
import paths from '../../router/paths';
import APP_CONSTANTS from 'app_constants';
import kolide from 'kolide';
import { LOGIN_FAILURE, LOGIN_SUCCESS, LOGOUT_SUCCESS, logoutSuccess } from 'redux/nodes/auth/actions';
import local from 'utilities/local';
const { HTTP_STATUS, PATHS } = APP_CONSTANTS;
const authMiddleware = store => next => (action) => {
const { type, payload } = action;
if (type.endsWith('FAILURE')) {
if (get(payload, 'errors.http_status') === HTTP_STATUS.UNAUTHENTICATED) {
store.dispatch(logoutSuccess);
}
}
if (type === LOGIN_SUCCESS) {
const { token } = payload;
@ -19,7 +28,7 @@ const authMiddleware = store => next => (action) => {
}
if (type === LOGOUT_SUCCESS || type === LOGIN_FAILURE) {
const { LOGIN } = paths;
const { LOGIN } = PATHS;
local.clear();
kolide.setBearerToken(null);

View File

@ -65,6 +65,7 @@ describe('Auth - actions', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to perform reset',
errors,
@ -99,7 +100,7 @@ describe('Auth - actions', () => {
{ type: PERFORM_REQUIRED_PASSWORD_RESET_REQUEST },
{
type: PERFORM_REQUIRED_PASSWORD_RESET_FAILURE,
payload: { errors: { base: 'Unable to reset password' } },
payload: { errors: { base: 'Unable to reset password', http_status: 422 } },
},
];

View File

@ -99,7 +99,7 @@ describe('ForgotPasswordPage - reducer', () => {
.catch(() => {
const actions = store.getActions();
expect(actions).toInclude(forgotPasswordErrorAction({ base: 'Something went wrong' }));
expect(actions).toInclude(forgotPasswordErrorAction({ base: 'Something went wrong', http_status: 422 }));
expect(invalidRequest.isDone()).toEqual(true);
done();
});

View File

@ -38,6 +38,7 @@ describe('ManageHostsPage - reducer', () => {
},
});
});
it('dispatches the correct actions when successful', (done) => {
const statusLabelCounts = { online_count: 23, offline_count: 100, mia_count: 2 };
const store = { components: { ManageHostsPage: initialState } };
@ -65,12 +66,12 @@ describe('ManageHostsPage - reducer', () => {
const store = { components: { ManageHostsPage: initialState } };
const mockStore = reduxMockStore(store);
const errors = [{ name: 'error_name', reason: 'error reason' }];
const errorObject = { message: { message: 'oops', errors } };
const errorObject = { status: 422, message: { message: 'oops', errors } };
const expectedActions = [
{ type: 'LOAD_STATUS_LABEL_COUNTS' },
{
type: 'GET_STATUS_LABEL_COUNTS_FAILURE',
payload: { errors: { error_name: 'error reason' } },
payload: { errors: { error_name: 'error reason', http_status: 422 } },
},
];
@ -144,11 +145,11 @@ describe('ManageHostsPage - reducer', () => {
const store = { components: { ManageHostsPage: initialState } };
const mockStore = reduxMockStore(store);
const errors = [{ name: 'error_name', reason: 'error reason' }];
const errorObject = { message: { message: 'oops', errors } };
const errorObject = { status: 422, message: { message: 'oops', errors } };
const expectedActions = [
{
type: 'GET_STATUS_LABEL_COUNTS_FAILURE',
payload: { errors: { error_name: 'error reason' } },
payload: { errors: { error_name: 'error reason', http_status: 422 } },
},
];

View File

@ -29,7 +29,10 @@ const formatServerErrors = (errors) => {
export const formatErrorResponse = (errorResponse) => {
const errors = get(errorResponse, 'message.errors') || [];
return formatServerErrors(errors);
return {
...formatServerErrors(errors),
http_status: errorResponse.status,
};
};
export default { entitiesExceptID, formatErrorResponse };

View File

@ -40,6 +40,7 @@ describe('reduxConfig - helpers', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Validation Failed',
errors,
@ -48,6 +49,7 @@ describe('reduxConfig - helpers', () => {
expect(formatErrorResponse(errorResponse)).toEqual({
first_name: 'is not valid, must be something else',
http_status: 422,
last_name: 'must be changed or something',
});
});

View File

@ -22,6 +22,13 @@ const store = {
};
const invite = { id: 1, name: 'Gnar Dog', email: 'hi@thegnar.co' };
const user = { id: 1, email: 'hi@thegnar.co' };
const unauthenticatedError = {
status: 401,
message: {
message: 'Unauthenticated',
errors: [{ base: 'User is not authenticated' }],
},
};
describe('reduxConfig', () => {
afterEach(restoreSpies);
@ -102,6 +109,28 @@ describe('reduxConfig', () => {
describe('unsuccessful create call', () => {
const mockStore = reduxMockStore(store);
describe('unauthenticated error', () => {
const createFunc = createSpy().andCall(() => Promise.reject(unauthenticatedError));
const config = reduxConfig({
createFunc,
entityName: 'users',
schema: schemas.USERS,
});
const { actions } = config;
it('dispatches the LOGOUT_SUCCESS action', (done) => {
mockStore.dispatch(actions.create())
.then(done)
.catch(() => {
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toInclude({ type: 'LOGOUT_SUCCESS' });
done();
});
});
});
const errors = [
{ name: 'first_name',
reason: 'is not valid',
@ -222,69 +251,92 @@ describe('reduxConfig', () => {
});
describe('unsuccessful update call', () => {
const mockStore = reduxMockStore(store);
const errors = [
{ name: 'first_name',
reason: 'is not valid',
},
{ name: 'last_name',
reason: 'must be changed or something',
},
];
const errorResponse = {
message: {
message: 'Validation Failed',
errors,
},
};
const formattedErrors = formatErrorResponse(errorResponse);
const updateFunc = createSpy().andCall(() => {
return Promise.reject(errorResponse);
});
const config = reduxConfig({
entityName: 'users',
schema: schemas.USERS,
updateFunc,
});
const { actions, reducer } = config;
describe('unauthenticated error', () => {
const mockStore = reduxMockStore(store);
const updateFunc = createSpy().andCall(() => Promise.reject(unauthenticatedError));
const config = reduxConfig({ updateFunc, entityName: 'users', schema: schemas.USERS });
const { actions } = config;
it('calls the updateFunc', () => {
mockStore.dispatch(actions.update(user));
it('dispatches the LOGOUT_SUCCESS action', (done) => {
mockStore.dispatch(actions.update())
.then(done)
.catch(() => {
const dispatchedActions = mockStore.getActions();
expect(updateFunc).toHaveBeenCalledWith(user);
});
expect(dispatchedActions).toInclude({ type: 'LOGOUT_SUCCESS' });
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.update());
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });
expect(dispatchedActionTypes).toInclude('users_UPDATE_REQUEST');
expect(dispatchedActionTypes).toNotInclude('users_UPDATE_SUCCESS');
const updateFailureAction = find(dispatchedActions, { type: 'users_UPDATE_FAILURE' });
expect(updateFailureAction.payload).toEqual({
errors: formattedErrors,
done();
});
});
});
it('adds the returned errors to state', () => {
const updateFailureAction = {
type: 'users_UPDATE_FAILURE',
payload: {
errors: formattedErrors,
describe('unprocessable entitiy', () => {
const mockStore = reduxMockStore(store);
const errors = [
{ name: 'first_name',
reason: 'is not valid',
},
{ name: 'last_name',
reason: 'must be changed or something',
},
];
const errorResponse = {
status: 422,
message: {
message: 'Validation Failed',
errors,
},
};
const initialState = {
loading: false,
entities: {},
errors: {},
};
const newState = reducer(initialState, updateFailureAction);
const formattedErrors = formatErrorResponse(errorResponse);
const updateFunc = createSpy().andCall(() => {
return Promise.reject(errorResponse);
});
const config = reduxConfig({
entityName: 'users',
schema: schemas.USERS,
updateFunc,
});
const { actions, reducer } = config;
expect(newState.errors).toEqual(formattedErrors);
it('calls the updateFunc', () => {
mockStore.dispatch(actions.update(user));
expect(updateFunc).toHaveBeenCalledWith(user);
});
it('dispatches the correct actions', () => {
mockStore.dispatch(actions.update());
const dispatchedActions = mockStore.getActions();
const dispatchedActionTypes = dispatchedActions.map((action) => { return action.type; });
expect(dispatchedActionTypes).toInclude('users_UPDATE_REQUEST');
expect(dispatchedActionTypes).toNotInclude('users_UPDATE_SUCCESS');
const updateFailureAction = find(dispatchedActions, { type: 'users_UPDATE_FAILURE' });
expect(updateFailureAction.payload).toEqual({
errors: formattedErrors,
});
});
it('adds the returned errors to state', () => {
const updateFailureAction = {
type: 'users_UPDATE_FAILURE',
payload: {
errors: formattedErrors,
},
};
const initialState = {
loading: false,
entities: {},
errors: {},
};
const newState = reducer(initialState, updateFailureAction);
expect(newState.errors).toEqual(formattedErrors);
});
});
});
});

View File

@ -70,6 +70,7 @@ describe('Users - actions', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to enable the user',
errors,
@ -105,7 +106,7 @@ describe('Users - actions', () => {
expect(dispatchedActions).toEqual([
config.extendedActions.updateRequest,
config.extendedActions.updateFailure({ base: 'Unable to enable the user' }),
config.extendedActions.updateFailure({ base: 'Unable to enable the user', http_status: 422 }),
]);
done();
@ -168,6 +169,7 @@ describe('Users - actions', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to change password',
errors,
@ -203,7 +205,7 @@ describe('Users - actions', () => {
expect(dispatchedActions).toEqual([
config.extendedActions.updateRequest,
config.extendedActions.updateFailure({ base: 'Unable to change password' }),
config.extendedActions.updateFailure({ base: 'Unable to change password', http_status: 422 }),
]);
done();
@ -263,6 +265,7 @@ describe('Users - actions', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to make the user an admin',
errors,
@ -298,7 +301,7 @@ describe('Users - actions', () => {
expect(dispatchedActions).toEqual([
config.extendedActions.updateRequest,
config.extendedActions.updateFailure({ base: 'Unable to make the user an admin' }),
config.extendedActions.updateFailure({ base: 'Unable to make the user an admin', http_status: 422 }),
]);
done();
@ -352,6 +355,7 @@ describe('Users - actions', () => {
},
];
const errorResponse = {
status: 422,
message: {
message: 'Unable to require password reset',
errors,
@ -385,7 +389,7 @@ describe('Users - actions', () => {
{ type: REQUIRE_PASSWORD_RESET_REQUEST },
{
type: REQUIRE_PASSWORD_RESET_FAILURE,
payload: { errors: { base: 'Unable to require password reset' } },
payload: { errors: { base: 'Unable to require password reset', http_status: 422 } },
},
];

View File

@ -5,12 +5,14 @@ import { noop } from 'lodash';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import authMiddleware from 'redux/middlewares/auth';
export const fillInFormInput = (inputComponent, value) => {
return inputComponent.simulate('change', { target: { value } });
};
export const reduxMockStore = (store = {}) => {
const middlewares = [thunk];
const middlewares = [thunk, authMiddleware];
const mockStore = configureStore(middlewares);
return mockStore(store);