diff --git a/frontend/app_constants/HTTP_STATUS.js b/frontend/app_constants/HTTP_STATUS.js new file mode 100644 index 000000000..5d5ce0532 --- /dev/null +++ b/frontend/app_constants/HTTP_STATUS.js @@ -0,0 +1,3 @@ +export default { + UNAUTHENTICATED: 401, +}; diff --git a/frontend/app_constants/index.js b/frontend/app_constants/index.js index 70c10bfcc..d789e5275 100644 --- a/frontend/app_constants/index.js +++ b/frontend/app_constants/index.js @@ -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, }; diff --git a/frontend/components/queries/QueryPageWrapper/helpers.js b/frontend/components/queries/QueryPageWrapper/helpers.js index 076ecf1c0..7032a9c31 100644 --- a/frontend/components/queries/QueryPageWrapper/helpers.js +++ b/frontend/components/queries/QueryPageWrapper/helpers.js @@ -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)); diff --git a/frontend/kolide/base.js b/frontend/kolide/base.js index c1e26703a..49f0979d2 100644 --- a/frontend/kolide/base.js +++ b/frontend/kolide/base.js @@ -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; }); diff --git a/frontend/redux/middlewares/auth.js b/frontend/redux/middlewares/auth.js index 154cc52f4..a2403ed4c 100644 --- a/frontend/redux/middlewares/auth.js +++ b/frontend/redux/middlewares/auth.js @@ -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); diff --git a/frontend/redux/nodes/auth/actions.tests.js b/frontend/redux/nodes/auth/actions.tests.js index 8871517a5..28d4ffd61 100644 --- a/frontend/redux/nodes/auth/actions.tests.js +++ b/frontend/redux/nodes/auth/actions.tests.js @@ -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 } }, }, ]; diff --git a/frontend/redux/nodes/components/ForgotPasswordPage/reducer.tests.js b/frontend/redux/nodes/components/ForgotPasswordPage/reducer.tests.js index 7d66d6fff..4ce30e6a6 100644 --- a/frontend/redux/nodes/components/ForgotPasswordPage/reducer.tests.js +++ b/frontend/redux/nodes/components/ForgotPasswordPage/reducer.tests.js @@ -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(); }); diff --git a/frontend/redux/nodes/components/ManageHostsPage/reducer.tests.js b/frontend/redux/nodes/components/ManageHostsPage/reducer.tests.js index 2ae4c016f..b8d2999c7 100644 --- a/frontend/redux/nodes/components/ManageHostsPage/reducer.tests.js +++ b/frontend/redux/nodes/components/ManageHostsPage/reducer.tests.js @@ -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 } }, }, ]; diff --git a/frontend/redux/nodes/entities/base/helpers.js b/frontend/redux/nodes/entities/base/helpers.js index 68304d534..76d11ff3b 100644 --- a/frontend/redux/nodes/entities/base/helpers.js +++ b/frontend/redux/nodes/entities/base/helpers.js @@ -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 }; diff --git a/frontend/redux/nodes/entities/base/helpers.tests.js b/frontend/redux/nodes/entities/base/helpers.tests.js index 2f76b814f..0b01f1b04 100644 --- a/frontend/redux/nodes/entities/base/helpers.tests.js +++ b/frontend/redux/nodes/entities/base/helpers.tests.js @@ -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', }); }); diff --git a/frontend/redux/nodes/entities/base/reduxConfig.tests.js b/frontend/redux/nodes/entities/base/reduxConfig.tests.js index 4322fed4b..450425bd0 100644 --- a/frontend/redux/nodes/entities/base/reduxConfig.tests.js +++ b/frontend/redux/nodes/entities/base/reduxConfig.tests.js @@ -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); + }); }); }); }); diff --git a/frontend/redux/nodes/entities/users/actions.tests.js b/frontend/redux/nodes/entities/users/actions.tests.js index 9379dcd92..05f34807c 100644 --- a/frontend/redux/nodes/entities/users/actions.tests.js +++ b/frontend/redux/nodes/entities/users/actions.tests.js @@ -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 } }, }, ]; diff --git a/frontend/test/helpers.jsx b/frontend/test/helpers.jsx index bbdcfdc4f..ec6ef2b4c 100644 --- a/frontend/test/helpers.jsx +++ b/frontend/test/helpers.jsx @@ -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);