From 000ce71d3e7c6dd0a8d1326bc29d48447368711e Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 5 Nov 2016 12:11:41 +0200 Subject: [PATCH] #48: integrate accounts with app --- src/components/accounts/actions.js | 20 ++- src/components/accounts/reducer.js | 23 ++- src/components/auth/actions.js | 8 +- src/components/i18n/actions.js | 24 ++- src/components/user/User.js | 4 - src/components/user/actions.js | 61 +++---- src/components/user/factory.js | 9 +- .../middlewares/bearerHeaderMiddleware.js | 4 +- .../middlewares/refreshTokenMiddleware.js | 32 +++- src/services/api/authentication.js | 29 ++- src/storeFactory.js | 3 +- tests/components/accounts/actions.test.js | 96 ++++++---- tests/components/accounts/reducer.test.js | 69 +++---- tests/components/user/actions.test.js | 6 +- .../bearerHeaderMiddleware.test.js | 50 +++++- .../refreshTokenMiddleware.test.js | 169 +++++++++++++++--- tests/services/api/authentication.test.js | 91 ++++++++++ 17 files changed, 517 insertions(+), 181 deletions(-) create mode 100644 tests/services/api/authentication.test.js diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js index 7365783..db8a22c 100644 --- a/src/components/accounts/actions.js +++ b/src/components/accounts/actions.js @@ -1,6 +1,7 @@ import authentication from 'services/api/authentication'; import accounts from 'services/api/accounts'; import { updateUser, logout } from 'components/user/actions'; +import { setLocale } from 'components/i18n/actions'; /** * @typedef {object} Account @@ -35,9 +36,13 @@ export function authenticate({token, refreshToken}) { .then(({user, account}) => { dispatch(add(account)); dispatch(activate(account)); - dispatch(updateUser(user)); + dispatch(updateUser({ + isGuest: false, + ...user + })); - return account; + return dispatch(setLocale(user.lang)) + .then(() => account); }); }; } @@ -95,3 +100,14 @@ export function activate(account) { payload: account }; } + +export const UPDATE_TOKEN = 'accounts:updateToken'; +/** + * @param {string} token + */ +export function updateToken(token) { + return { + type: UPDATE_TOKEN, + payload: token + }; +} diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js index eea892a..a08c3aa 100644 --- a/src/components/accounts/reducer.js +++ b/src/components/accounts/reducer.js @@ -1,4 +1,4 @@ -import { ADD, REMOVE, ACTIVATE } from './actions'; +import { ADD, REMOVE, ACTIVATE, UPDATE_TOKEN } from './actions'; /** * @typedef {AccountsState} @@ -14,7 +14,10 @@ import { ADD, REMOVE, ACTIVATE } from './actions'; * @return {AccountsState} */ export default function accounts( - state, + state = { + active: null, + available: [] + }, {type, payload = {}} ) { switch (type) { @@ -49,10 +52,20 @@ export default function accounts( available: state.available.filter((account) => account.id !== payload.id) }; - default: + case UPDATE_TOKEN: + if (typeof payload !== 'string') { + throw new Error('payload must be a jwt token'); + } + return { - active: null, - available: [] + ...state, + active: { + ...state.active, + token: payload + } }; + + default: + return state; } } diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index cbe2fce..c61c6ac 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -1,6 +1,7 @@ import { routeActions } from 'react-router-redux'; -import { updateUser, logout as logoutUser, acceptRules as userAcceptRules, authenticate } from 'components/user/actions'; +import { updateUser, logout as logoutUser, acceptRules as userAcceptRules } from 'components/user/actions'; +import { authenticate } from 'components/accounts/actions'; import authentication from 'services/api/authentication'; import oauth from 'services/api/oauth'; import signup from 'services/api/signup'; @@ -305,7 +306,10 @@ function needActivation() { } function authHandler(dispatch) { - return (resp) => dispatch(authenticate(resp.access_token, resp.refresh_token)); + return (resp) => dispatch(authenticate({ + token: resp.access_token, + refreshToken: resp.refresh_token + })); } function validationErrorsHandler(dispatch, repeatUrl) { diff --git a/src/components/i18n/actions.js b/src/components/i18n/actions.js index 5b532d4..9869ffa 100644 --- a/src/components/i18n/actions.js +++ b/src/components/i18n/actions.js @@ -1,18 +1,26 @@ import i18n from 'services/i18n'; +import captcha from 'services/captcha'; -export const SET_LOCALE = 'SET_LOCALE'; +export const SET_LOCALE = 'i18n:setLocale'; export function setLocale(locale) { return (dispatch) => i18n.require( i18n.detectLanguage(locale) ).then(({locale, messages}) => { - dispatch({ - type: SET_LOCALE, - payload: { - locale, - messages - } - }); + dispatch(_setLocale({locale, messages})); + + // TODO: probably should be moved from here, because it is a side effect + captcha.setLang(locale); return locale; }); } + +function _setLocale({locale, messages}) { + return { + type: SET_LOCALE, + payload: { + locale, + messages + } + }; +} diff --git a/src/components/user/User.js b/src/components/user/User.js index 568d8b2..d90ae98 100644 --- a/src/components/user/User.js +++ b/src/components/user/User.js @@ -33,10 +33,6 @@ export default class User { // frontend app specific attributes isGuest: true, goal: null, // the goal with wich user entered site - - // TODO: the following does not belongs here. Move it later - token: '', - refreshToken: '', }; const user = Object.keys(defaults).reduce((user, key) => { diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 9d21232..713233f 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -1,14 +1,15 @@ import { routeActions } from 'react-router-redux'; -import captcha from 'services/captcha'; import accounts from 'services/api/accounts'; import authentication from 'services/api/authentication'; import { setLocale } from 'components/i18n/actions'; export const UPDATE = 'USER_UPDATE'; /** - * @param {string|object} payload jwt token or user object - * @return {object} action definition + * Merge data into user's state + * + * @param {object} payload + * @return {object} - action definition */ export function updateUser(payload) { return { @@ -23,23 +24,26 @@ export function changeLang(lang) { .then((lang) => { const {user: {isGuest, lang: oldLang}} = getState(); - if (!isGuest && oldLang !== lang) { - accounts.changeLang(lang); + if (oldLang !== lang) { + !isGuest && accounts.changeLang(lang); + + dispatch({ + type: CHANGE_LANG, + payload: { + lang + } + }); } - - // TODO: probably should be moved from here, because it is side effect - captcha.setLang(lang); - - dispatch({ - type: CHANGE_LANG, - payload: { - lang - } - }); }); } export const SET = 'USER_SET'; +/** + * Replace current user's state with a new one + * + * @param {User} payload + * @return {object} - action definition + */ export function setUser(payload) { return { type: SET, @@ -72,7 +76,10 @@ export function fetchUserData() { return (dispatch) => accounts.current() .then((resp) => { - dispatch(updateUser(resp)); + dispatch(updateUser({ + isGuest: false, + ...resp + })); return dispatch(changeLang(resp.lang)); }); @@ -80,31 +87,11 @@ export function fetchUserData() { export function acceptRules() { return (dispatch) => - accounts.acceptRules() - .then((resp) => { + accounts.acceptRules().then((resp) => { dispatch(updateUser({ shouldAcceptRules: false })); - return resp; - }) - ; -} - -export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth - return (dispatch, getState) => { - refreshToken = refreshToken || getState().user.refreshToken; - dispatch(updateUser({ - token, - refreshToken - })); - - return dispatch(fetchUserData()).then((resp) => { - dispatch(updateUser({ - isGuest: false - })); return resp; }); - }; } - diff --git a/src/components/user/factory.js b/src/components/user/factory.js index 2c471fa..d7cd7e6 100644 --- a/src/components/user/factory.js +++ b/src/components/user/factory.js @@ -1,4 +1,5 @@ -import { authenticate, changeLang } from 'components/user/actions'; +import { changeLang } from 'components/user/actions'; +import { authenticate } from 'components/accounts/actions'; import request from 'services/request'; import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware'; @@ -22,11 +23,11 @@ export function factory(store) { request.addMiddleware(bearerHeaderMiddleware(store)); promise = new Promise((resolve, reject) => { - const {user} = store.getState(); + const {user, accounts} = store.getState(); - if (user.token) { + if (accounts.active || user.token) { // authorizing user if it is possible - return store.dispatch(authenticate(user.token)).then(resolve, reject); + return store.dispatch(authenticate(accounts.active || user)).then(resolve, reject); } // auto-detect guests language diff --git a/src/components/user/middlewares/bearerHeaderMiddleware.js b/src/components/user/middlewares/bearerHeaderMiddleware.js index cb1c9db..077d5d1 100644 --- a/src/components/user/middlewares/bearerHeaderMiddleware.js +++ b/src/components/user/middlewares/bearerHeaderMiddleware.js @@ -9,7 +9,9 @@ export default function bearerHeaderMiddleware({getState}) { return { before(req) { - let {token} = getState().user; + const {user, accounts} = getState(); + + let {token} = accounts.active ? accounts.active : user; if (req.options.token) { token = req.options.token; diff --git a/src/components/user/middlewares/refreshTokenMiddleware.js b/src/components/user/middlewares/refreshTokenMiddleware.js index 9f6cc9c..5bbcd93 100644 --- a/src/components/user/middlewares/refreshTokenMiddleware.js +++ b/src/components/user/middlewares/refreshTokenMiddleware.js @@ -1,5 +1,6 @@ import authentication from 'services/api/authentication'; -import {updateUser, logout} from '../actions'; +import { updateToken } from 'components/accounts/actions'; +import { logout } from '../actions'; /** * Ensures, that all user's requests have fresh access token @@ -13,9 +14,21 @@ import {updateUser, logout} from '../actions'; export default function refreshTokenMiddleware({dispatch, getState}) { return { before(req) { - const {refreshToken, token} = getState().user; + const {user, accounts} = getState(); + + let refreshToken; + let token; + const isRefreshTokenRequest = req.url.includes('refresh-token'); + if (accounts.active) { + token = accounts.active.token; + refreshToken = accounts.active.refreshToken; + } else { // #legacy token + token = user.token; + refreshToken = user.refreshToken; + } + if (!token || isRefreshTokenRequest || req.options.autoRefreshToken === false) { return req; } @@ -28,21 +41,24 @@ export default function refreshTokenMiddleware({dispatch, getState}) { return requestAccessToken(refreshToken, dispatch).then(() => req); } } catch (err) { - dispatch(logout()); + // console.error('Bad token', err); // TODO: it would be cool to log such things to backend + return dispatch(logout()).then(() => req); } - return req; + return Promise.resolve(req); }, catch(resp, req, restart) { if (resp && resp.status === 401 && req.options.autoRefreshToken !== false) { - const {refreshToken} = getState().user; + const {user, accounts} = getState(); + const {refreshToken} = accounts.active ? accounts.active : user; + if (resp.message === 'Token expired' && refreshToken) { // request token and retry return requestAccessToken(refreshToken, dispatch).then(restart); } - dispatch(logout()); + return dispatch(logout()).then(() => Promise.reject(resp)); } return Promise.reject(resp); @@ -59,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) { } return promise - .then(({token}) => dispatch(updateUser({ - token - }))) + .then(({token}) => dispatch(updateToken(token))) .catch(() => dispatch(logout())); } diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 7c005db..0d2c2cb 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -1,7 +1,7 @@ import request from 'services/request'; import accounts from 'services/api/accounts'; -export default { +const authentication = { login({ login = '', password = '', @@ -48,10 +48,27 @@ export default { * if it was refreshed */ validateToken({token, refreshToken}) { - // TODO: use refresh token to get fresh token. Dont forget, that it may be broken by refreshTokenMiddleware - // TODO: cover with tests - return accounts.current({token, autoRefreshToken: false}) - .then(() => ({token, refreshToken})); + return new Promise((resolve) => { + if (typeof token !== 'string') { + throw new Error('token must be a string'); + } + + if (typeof refreshToken !== 'string') { + throw new Error('refreshToken must be a string'); + } + + resolve(); + }) + .then(() => accounts.current({token, autoRefreshToken: false})) + .then(() => ({token, refreshToken})) + .catch((resp) => { + if (resp.message === 'Token expired') { + return authentication.requestToken(refreshToken) + .then(({token}) => ({token, refreshToken})); + } + + return Promise.reject(resp); + }); }, /** @@ -70,3 +87,5 @@ export default { })); } }; + +export default authentication; diff --git a/src/storeFactory.js b/src/storeFactory.js index 90b1dd6..b393efd 100644 --- a/src/storeFactory.js +++ b/src/storeFactory.js @@ -17,7 +17,8 @@ export default function storeFactory() { thunk ); const persistStateEnhancer = persistState([ - 'accounts' + 'accounts', + 'user' ], {key: 'redux-storage'}); /* global process: false */ diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index 2cd69d7..9c26c5f 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -2,21 +2,23 @@ import expect from 'unexpected'; import accounts from 'services/api/accounts'; import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; +import { SET_LOCALE } from 'components/i18n/actions'; -import { updateUser, logout } from 'components/user/actions'; +import { updateUser } from 'components/user/actions'; const account = { id: 1, username: 'username', email: 'email@test.com', token: 'foo', - refreshToken: 'foo' + refreshToken: 'bar' }; const user = { id: 1, username: 'username', email: 'email@test.com', + lang: 'be' }; describe('Accounts actions', () => { @@ -24,10 +26,10 @@ describe('Accounts actions', () => { let getState; beforeEach(() => { - dispatch = sinon.spy(function dispatch(arg) { - return typeof arg === 'function' ? arg(dispatch, getState) : arg; - }).named('dispatch'); - getState = sinon.stub().named('getState'); + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); + getState = sinon.stub().named('store.getState'); getState.returns({ accounts: [], @@ -43,13 +45,13 @@ describe('Accounts actions', () => { }); describe('#authenticate()', () => { - it('should request user state using token', () => { - authenticate(account)(dispatch); - - expect(accounts.current, 'to have a call satisfying', [ - {token: account.token} - ]); - }); + it('should request user state using token', () => + authenticate(account)(dispatch).then(() => + expect(accounts.current, 'to have a call satisfying', [ + {token: account.token} + ]) + ) + ); it(`dispatches ${ADD} action`, () => authenticate(account)(dispatch).then(() => @@ -67,10 +69,18 @@ describe('Accounts actions', () => { ) ); + it(`dispatches ${SET_LOCALE} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + {type: SET_LOCALE, payload: {locale: 'be'}} + ]) + ) + ); + it('should update user state', () => authenticate(account)(dispatch).then(() => expect(dispatch, 'to have a call satisfying', [ - updateUser(user) + updateUser({...user, isGuest: false}) ]) ) ); @@ -84,14 +94,9 @@ describe('Accounts actions', () => { it('rejects when bad auth data', () => { accounts.current.returns(Promise.reject({})); - const promise = authenticate(account)(dispatch); - - expect(promise, 'to be rejected'); - - return promise.catch(() => { - expect(dispatch, 'was not called'); - return Promise.resolve(); - }); + return expect(authenticate(account)(dispatch), 'to be rejected').then(() => + expect(dispatch, 'was not called') + ); }); }); @@ -108,27 +113,42 @@ describe('Accounts actions', () => { const account2 = {...account, id: 2}; getState.returns({ - accounts: [account2] + accounts: [account] }); - return revoke(account)(dispatch, getState).then(() => - expect(dispatch, 'to have calls satisfying', [ - [remove(account)], - [expect.it('to be a function')] - // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? - ]) - ); + return revoke(account2)(dispatch, getState).then(() => { + expect(dispatch, 'to have a call satisfying', [ + remove(account2) + ]); + expect(dispatch, 'to have a call satisfying', [ + activate(account) + ]); + expect(dispatch, 'to have a call satisfying', [ + updateUser({...user, isGuest: false}) + ]); + // expect(dispatch, 'to have calls satisfying', [ + // [remove(account2)], + // [expect.it('to be a function')] + // // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? + // ]) + }); }); it('should logout if no other accounts available', () => { - revoke(account)(dispatch, getState) - .then(() => - expect(dispatch, 'to have calls satisfying', [ - [remove(account)], - [expect.it('to be a function')] - // [logout()] // TODO: this is not a plain action. How should we simplify its testing? - ]) - ); + revoke(account)(dispatch, getState).then(() => { + expect(dispatch, 'to have a call satisfying', [ + remove(account) + ]); + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + // updateUser({isGuest: true}) + ]); + // expect(dispatch, 'to have calls satisfying', [ + // [remove(account)], + // [expect.it('to be a function')] + // // [logout()] // TODO: this is not a plain action. How should we simplify its testing? + // ]) + }); }); }); }); diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index d9d34ec..58ef367 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -1,7 +1,10 @@ import expect from 'unexpected'; import accounts from 'components/accounts/reducer'; -import { ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; +import { + updateToken, add, remove, activate, + ADD, REMOVE, ACTIVATE, UPDATE_TOKEN +} from 'components/accounts/actions'; const account = { id: 1, @@ -15,20 +18,21 @@ describe('Accounts reducer', () => { let initial; beforeEach(() => { - initial = accounts(null, {}); + initial = accounts(undefined, {}); }); - it('should be empty', () => expect(accounts(null, {}), 'to equal', { + it('should be empty', () => expect(accounts(undefined, {}), 'to equal', { active: null, available: [] })); + it('should return last state if unsupported action', () => + expect(accounts({state: 'foo'}, {}), 'to equal', {state: 'foo'}) + ); + describe(ACTIVATE, () => { it('sets active account', () => { - expect(accounts(initial, { - type: ACTIVATE, - payload: account - }), 'to satisfy', { + expect(accounts(initial, activate(account)), 'to satisfy', { active: account }); }); @@ -36,52 +40,49 @@ describe('Accounts reducer', () => { describe(ADD, () => { it('adds an account', () => - expect(accounts(initial, { - type: ADD, - payload: account - }), 'to satisfy', { + expect(accounts(initial, add(account)), 'to satisfy', { available: [account] }) ); it('should not add the same account twice', () => - expect(accounts({...initial, available: [account]}, { - type: ADD, - payload: account - }), 'to satisfy', { + expect(accounts({...initial, available: [account]}, add(account)), 'to satisfy', { available: [account] }) ); it('throws, when account is invalid', () => { - expect(() => accounts(initial, { - type: ADD - }), 'to throw', 'Invalid or empty payload passed for accounts.add'); - - expect(() => accounts(initial, { - type: ADD, - payload: {} - }), 'to throw', 'Invalid or empty payload passed for accounts.add'); + expect(() => accounts(initial, add()), + 'to throw', 'Invalid or empty payload passed for accounts.add'); }); }); describe(REMOVE, () => { it('should remove an account', () => - expect(accounts({...initial, available: [account]}, { - type: REMOVE, - payload: account - }), 'to equal', initial) + expect(accounts({...initial, available: [account]}, remove(account)), + 'to equal', initial) ); it('throws, when account is invalid', () => { - expect(() => accounts(initial, { - type: REMOVE - }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + expect(() => accounts(initial, remove()), + 'to throw', 'Invalid or empty payload passed for accounts.remove'); + }); + }); - expect(() => accounts(initial, { - type: REMOVE, - payload: {} - }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + describe(UPDATE_TOKEN, () => { + it('should update token', () => { + const newToken = 'newToken'; + + expect(accounts( + {active: account, available: [account]}, + updateToken(newToken) + ), 'to satisfy', { + active: { + ...account, + token: newToken + }, + available: [account] + }); }); }); }); diff --git a/tests/components/user/actions.test.js b/tests/components/user/actions.test.js index 003cebd..0b05cb0 100644 --- a/tests/components/user/actions.test.js +++ b/tests/components/user/actions.test.js @@ -11,8 +11,10 @@ import { describe('components/user/actions', () => { - const dispatch = sinon.stub().named('dispatch'); - const getState = sinon.stub().named('getState'); + const getState = sinon.stub().named('store.getState'); + const dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); const callThunk = function(fn, ...args) { const thunk = fn(...args); diff --git a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js index 49ee57b..f9dc8ef 100644 --- a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js +++ b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js @@ -3,11 +3,21 @@ import expect from 'unexpected'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; describe('bearerHeaderMiddleware', () => { + const emptyState = { + user: {}, + accounts: { + active: null + } + }; + describe('when token available', () => { const token = 'foo'; const middleware = bearerHeaderMiddleware({ getState: () => ({ - user: {token} + ...emptyState, + accounts: { + active: {token} + } }) }); @@ -20,9 +30,7 @@ describe('bearerHeaderMiddleware', () => { middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${token}` - }); + expectBearerHeader(data, token); }); it('overrides user.token with options.token if available', () => { @@ -36,16 +44,36 @@ describe('bearerHeaderMiddleware', () => { middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${tokenOverride}` - }); + expectBearerHeader(data, tokenOverride); + }); + }); + + describe('when legacy token available', () => { + const token = 'foo'; + const middleware = bearerHeaderMiddleware({ + getState: () => ({ + ...emptyState, + user: {token} + }) + }); + + it('should set Authorization header', () => { + const data = { + options: { + headers: {} + } + }; + + middleware.before(data); + + expectBearerHeader(data, token); }); }); it('should not set Authorization header if no token', () => { const middleware = bearerHeaderMiddleware({ getState: () => ({ - user: {} + ...emptyState }) }); @@ -59,4 +87,10 @@ describe('bearerHeaderMiddleware', () => { expect(data.options.headers.Authorization, 'to be undefined'); }); + + function expectBearerHeader(data, token) { + expect(data.options.headers, 'to satisfy', { + Authorization: `Bearer ${token}` + }); + } }); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index 8282b02..7421738 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -3,6 +3,7 @@ import expect from 'unexpected'; import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware'; import authentication from 'services/api/authentication'; +import { updateToken } from 'components/accounts/actions'; const refreshToken = 'foo'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8'; @@ -18,7 +19,9 @@ describe('refreshTokenMiddleware', () => { sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); getState = sinon.stub().named('store.getState'); - dispatch = sinon.stub().named('store.dispatch'); + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); middleware = refreshTokenMiddleware({getState, dispatch}); }); @@ -27,14 +30,21 @@ describe('refreshTokenMiddleware', () => { authentication.requestToken.restore(); }); + it('must be till 2100 to test with validToken', () => + expect(new Date().getFullYear(), 'to be less than', 2100) + ); + describe('#before', () => { describe('when token expired', () => { beforeEach(() => { getState.returns({ - user: { - token: expiredToken, - refreshToken - } + accounts: { + active: { + token: expiredToken, + refreshToken + } + }, + user: {} }); }); @@ -76,21 +86,94 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); - xit('should update user with new token'); // TODO: need a way to test, that action was called - xit('should logout if invalid token'); // TODO: need a way to test, that action was called + it('should update user with new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; - xit('should logout if token request failed', () => { + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then(() => + expect(dispatch, 'to have a call satisfying', [ + updateToken(validToken) + ]) + ); + }); + + it('should if token can not be parsed', () => { + getState.returns({ + accounts: { + active: { + token: 'realy bad token', + refreshToken + } + }, + user: {} + }); + + const req = {url: 'foo', options: {}}; + + return expect(middleware.before(req), 'to be fulfilled with', req).then(() => { + expect(authentication.requestToken, 'was not called'); + + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]); + }); + }); + + it('should logout if token request failed', () => { authentication.requestToken.returns(Promise.reject()); - return middleware.before({url: 'foo'}).then((resp) => { - // TODO: need a way to test, that action was called - expect(dispatch, 'to have a call satisfying', logout); + return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ); + }); + }); + + describe('when token expired legacy user', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null + }, + user: { + token: expiredToken, + refreshToken + } + }); + }); + + it('should request new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; + + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then((resp) => { + expect(resp, 'to satisfy', data); + + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); }); }); }); it('should not be applied if no token', () => { getState.returns({ + accounts: { + active: null + }, user: {} }); @@ -114,7 +197,15 @@ describe('refreshTokenMiddleware', () => { const badTokenReponse = { name: 'Unauthorized', - message: 'Token expired', + message: 'You are requesting with an invalid credential.', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + const incorrectTokenReponse = { + name: 'Unauthorized', + message: 'Incorrect token', code: 0, status: 401, type: 'yii\\web\\UnauthorizedHttpException' @@ -124,9 +215,10 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { getState.returns({ - user: { - refreshToken - } + accounts: { + active: {refreshToken} + }, + user: {} }); restart = sinon.stub().named('restart'); @@ -143,12 +235,27 @@ describe('refreshTokenMiddleware', () => { }) ); - xit('should logout user if token cannot be refreshed', () => { - // TODO: need a way to test, that action was called - return middleware.catch(badTokenReponse, {options: {}}, restart).then(() => { - // TODO - }); - }); + it('should logout user if invalid credential', () => + expect( + middleware.catch(badTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); + + it('should logout user if token is incorrect', () => + expect( + middleware.catch(incorrectTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); it('should pass the request through if options.autoRefreshToken === false', () => { const promise = middleware.catch(expiredResponse, { @@ -175,5 +282,25 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); }); + + describe('legacy user.refreshToken', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null + }, + user: {refreshToken} + }); + }); + + it('should request new token if expired', () => + middleware.catch(expiredResponse, {options: {}}, restart).then(() => { + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + expect(restart, 'was called'); + }) + ); + }); }); }); diff --git a/tests/services/api/authentication.test.js b/tests/services/api/authentication.test.js new file mode 100644 index 0000000..ef21005 --- /dev/null +++ b/tests/services/api/authentication.test.js @@ -0,0 +1,91 @@ +import expect from 'unexpected'; + +import authentication from 'services/api/authentication'; +import accounts from 'services/api/accounts'; + +describe('authentication api', () => { + describe('#validateToken()', () => { + const validTokens = {token: 'foo', refreshToken: 'bar'}; + + beforeEach(() => { + sinon.stub(accounts, 'current'); + + accounts.current.returns(Promise.resolve()); + }); + + afterEach(() => { + accounts.current.restore(); + }); + + it('should request accounts.current', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled') + .then(() => { + expect(accounts.current, 'to have a call satisfying', [ + {token: 'foo', autoRefreshToken: false} + ]); + }) + ); + + it('should resolve with both tokens', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled with', validTokens) + ); + + it('rejects if token has a bad type', () => + expect(authentication.validateToken({token: {}}), + 'to be rejected with', 'token must be a string' + ) + ); + + it('rejects if refreshToken has a bad type', () => + expect(authentication.validateToken({token: 'foo', refreshToken: {}}), + 'to be rejected with', 'refreshToken must be a string' + ) + ); + + it('rejects if accounts.current request is unexpectedly failed', () => { + const error = 'Something wrong'; + accounts.current.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + + describe('when token is expired', () => { + const expiredResponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + const newToken = 'baz'; + + beforeEach(() => { + sinon.stub(authentication, 'requestToken'); + + accounts.current.returns(Promise.reject(expiredResponse)); + authentication.requestToken.returns(Promise.resolve({token: newToken})); + }); + + afterEach(() => { + authentication.requestToken.restore(); + }); + + it('resolves with new token', () => + expect(authentication.validateToken(validTokens), + 'to be fulfilled with', {...validTokens, token: newToken} + ) + ); + + it('rejects if token request failed', () => { + const error = 'Something wrong'; + authentication.requestToken.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + }); + }); +});