#48: integrate accounts with app

This commit is contained in:
SleepWalker 2016-11-05 12:11:41 +02:00
parent 8601da786c
commit 000ce71d3e
17 changed files with 517 additions and 181 deletions

View File

@ -1,6 +1,7 @@
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
import { updateUser, logout } from 'components/user/actions'; import { updateUser, logout } from 'components/user/actions';
import { setLocale } from 'components/i18n/actions';
/** /**
* @typedef {object} Account * @typedef {object} Account
@ -35,9 +36,13 @@ export function authenticate({token, refreshToken}) {
.then(({user, account}) => { .then(({user, account}) => {
dispatch(add(account)); dispatch(add(account));
dispatch(activate(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 payload: account
}; };
} }
export const UPDATE_TOKEN = 'accounts:updateToken';
/**
* @param {string} token
*/
export function updateToken(token) {
return {
type: UPDATE_TOKEN,
payload: token
};
}

View File

@ -1,4 +1,4 @@
import { ADD, REMOVE, ACTIVATE } from './actions'; import { ADD, REMOVE, ACTIVATE, UPDATE_TOKEN } from './actions';
/** /**
* @typedef {AccountsState} * @typedef {AccountsState}
@ -14,7 +14,10 @@ import { ADD, REMOVE, ACTIVATE } from './actions';
* @return {AccountsState} * @return {AccountsState}
*/ */
export default function accounts( export default function accounts(
state, state = {
active: null,
available: []
},
{type, payload = {}} {type, payload = {}}
) { ) {
switch (type) { switch (type) {
@ -49,10 +52,20 @@ export default function accounts(
available: state.available.filter((account) => account.id !== payload.id) 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 { return {
active: null, ...state,
available: [] active: {
...state.active,
token: payload
}
}; };
default:
return state;
} }
} }

View File

@ -1,6 +1,7 @@
import { routeActions } from 'react-router-redux'; 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 authentication from 'services/api/authentication';
import oauth from 'services/api/oauth'; import oauth from 'services/api/oauth';
import signup from 'services/api/signup'; import signup from 'services/api/signup';
@ -305,7 +306,10 @@ function needActivation() {
} }
function authHandler(dispatch) { 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) { function validationErrorsHandler(dispatch, repeatUrl) {

View File

@ -1,18 +1,26 @@
import i18n from 'services/i18n'; 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) { export function setLocale(locale) {
return (dispatch) => i18n.require( return (dispatch) => i18n.require(
i18n.detectLanguage(locale) i18n.detectLanguage(locale)
).then(({locale, messages}) => { ).then(({locale, messages}) => {
dispatch({ 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, type: SET_LOCALE,
payload: { payload: {
locale, locale,
messages messages
} }
}); };
return locale;
});
} }

View File

@ -33,10 +33,6 @@ export default class User {
// frontend app specific attributes // frontend app specific attributes
isGuest: true, isGuest: true,
goal: null, // the goal with wich user entered site 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) => { const user = Object.keys(defaults).reduce((user, key) => {

View File

@ -1,14 +1,15 @@
import { routeActions } from 'react-router-redux'; import { routeActions } from 'react-router-redux';
import captcha from 'services/captcha';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { setLocale } from 'components/i18n/actions'; import { setLocale } from 'components/i18n/actions';
export const UPDATE = 'USER_UPDATE'; export const UPDATE = 'USER_UPDATE';
/** /**
* @param {string|object} payload jwt token or user object * Merge data into user's state
* @return {object} action definition *
* @param {object} payload
* @return {object} - action definition
*/ */
export function updateUser(payload) { export function updateUser(payload) {
return { return {
@ -23,12 +24,8 @@ export function changeLang(lang) {
.then((lang) => { .then((lang) => {
const {user: {isGuest, lang: oldLang}} = getState(); const {user: {isGuest, lang: oldLang}} = getState();
if (!isGuest && oldLang !== lang) { if (oldLang !== lang) {
accounts.changeLang(lang); !isGuest && accounts.changeLang(lang);
}
// TODO: probably should be moved from here, because it is side effect
captcha.setLang(lang);
dispatch({ dispatch({
type: CHANGE_LANG, type: CHANGE_LANG,
@ -36,10 +33,17 @@ export function changeLang(lang) {
lang lang
} }
}); });
}
}); });
} }
export const SET = 'USER_SET'; 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) { export function setUser(payload) {
return { return {
type: SET, type: SET,
@ -72,7 +76,10 @@ export function fetchUserData() {
return (dispatch) => return (dispatch) =>
accounts.current() accounts.current()
.then((resp) => { .then((resp) => {
dispatch(updateUser(resp)); dispatch(updateUser({
isGuest: false,
...resp
}));
return dispatch(changeLang(resp.lang)); return dispatch(changeLang(resp.lang));
}); });
@ -80,31 +87,11 @@ export function fetchUserData() {
export function acceptRules() { export function acceptRules() {
return (dispatch) => return (dispatch) =>
accounts.acceptRules() accounts.acceptRules().then((resp) => {
.then((resp) => {
dispatch(updateUser({ dispatch(updateUser({
shouldAcceptRules: false 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; return resp;
}); });
};
} }

View File

@ -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 request from 'services/request';
import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware'; import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware';
@ -22,11 +23,11 @@ export function factory(store) {
request.addMiddleware(bearerHeaderMiddleware(store)); request.addMiddleware(bearerHeaderMiddleware(store));
promise = new Promise((resolve, reject) => { 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 // 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 // auto-detect guests language

View File

@ -9,7 +9,9 @@
export default function bearerHeaderMiddleware({getState}) { export default function bearerHeaderMiddleware({getState}) {
return { return {
before(req) { before(req) {
let {token} = getState().user; const {user, accounts} = getState();
let {token} = accounts.active ? accounts.active : user;
if (req.options.token) { if (req.options.token) {
token = req.options.token; token = req.options.token;

View File

@ -1,5 +1,6 @@
import authentication from 'services/api/authentication'; 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 * 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}) { export default function refreshTokenMiddleware({dispatch, getState}) {
return { return {
before(req) { before(req) {
const {refreshToken, token} = getState().user; const {user, accounts} = getState();
let refreshToken;
let token;
const isRefreshTokenRequest = req.url.includes('refresh-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) { if (!token || isRefreshTokenRequest || req.options.autoRefreshToken === false) {
return req; return req;
} }
@ -28,21 +41,24 @@ export default function refreshTokenMiddleware({dispatch, getState}) {
return requestAccessToken(refreshToken, dispatch).then(() => req); return requestAccessToken(refreshToken, dispatch).then(() => req);
} }
} catch (err) { } 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) { catch(resp, req, restart) {
if (resp && resp.status === 401 && req.options.autoRefreshToken !== false) { 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) { if (resp.message === 'Token expired' && refreshToken) {
// request token and retry // request token and retry
return requestAccessToken(refreshToken, dispatch).then(restart); return requestAccessToken(refreshToken, dispatch).then(restart);
} }
dispatch(logout()); return dispatch(logout()).then(() => Promise.reject(resp));
} }
return Promise.reject(resp); return Promise.reject(resp);
@ -59,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) {
} }
return promise return promise
.then(({token}) => dispatch(updateUser({ .then(({token}) => dispatch(updateToken(token)))
token
})))
.catch(() => dispatch(logout())); .catch(() => dispatch(logout()));
} }

View File

@ -1,7 +1,7 @@
import request from 'services/request'; import request from 'services/request';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
export default { const authentication = {
login({ login({
login = '', login = '',
password = '', password = '',
@ -48,10 +48,27 @@ export default {
* if it was refreshed * if it was refreshed
*/ */
validateToken({token, refreshToken}) { validateToken({token, refreshToken}) {
// TODO: use refresh token to get fresh token. Dont forget, that it may be broken by refreshTokenMiddleware return new Promise((resolve) => {
// TODO: cover with tests if (typeof token !== 'string') {
return accounts.current({token, autoRefreshToken: false}) throw new Error('token must be a string');
.then(() => ({token, refreshToken})); }
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;

View File

@ -17,7 +17,8 @@ export default function storeFactory() {
thunk thunk
); );
const persistStateEnhancer = persistState([ const persistStateEnhancer = persistState([
'accounts' 'accounts',
'user'
], {key: 'redux-storage'}); ], {key: 'redux-storage'});
/* global process: false */ /* global process: false */

View File

@ -2,21 +2,23 @@ import expect from 'unexpected';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; 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 = { const account = {
id: 1, id: 1,
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
token: 'foo', token: 'foo',
refreshToken: 'foo' refreshToken: 'bar'
}; };
const user = { const user = {
id: 1, id: 1,
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
lang: 'be'
}; };
describe('Accounts actions', () => { describe('Accounts actions', () => {
@ -24,10 +26,10 @@ describe('Accounts actions', () => {
let getState; let getState;
beforeEach(() => { beforeEach(() => {
dispatch = sinon.spy(function dispatch(arg) { dispatch = sinon.spy((arg) =>
return typeof arg === 'function' ? arg(dispatch, getState) : arg; typeof arg === 'function' ? arg(dispatch, getState) : arg
}).named('dispatch'); ).named('store.dispatch');
getState = sinon.stub().named('getState'); getState = sinon.stub().named('store.getState');
getState.returns({ getState.returns({
accounts: [], accounts: [],
@ -43,13 +45,13 @@ describe('Accounts actions', () => {
}); });
describe('#authenticate()', () => { describe('#authenticate()', () => {
it('should request user state using token', () => { it('should request user state using token', () =>
authenticate(account)(dispatch); authenticate(account)(dispatch).then(() =>
expect(accounts.current, 'to have a call satisfying', [ expect(accounts.current, 'to have a call satisfying', [
{token: account.token} {token: account.token}
]); ])
}); )
);
it(`dispatches ${ADD} action`, () => it(`dispatches ${ADD} action`, () =>
authenticate(account)(dispatch).then(() => 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', () => it('should update user state', () =>
authenticate(account)(dispatch).then(() => authenticate(account)(dispatch).then(() =>
expect(dispatch, 'to have a call satisfying', [ 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', () => { it('rejects when bad auth data', () => {
accounts.current.returns(Promise.reject({})); accounts.current.returns(Promise.reject({}));
const promise = authenticate(account)(dispatch); return expect(authenticate(account)(dispatch), 'to be rejected').then(() =>
expect(dispatch, 'was not called')
expect(promise, 'to be rejected'); );
return promise.catch(() => {
expect(dispatch, 'was not called');
return Promise.resolve();
});
}); });
}); });
@ -108,27 +113,42 @@ describe('Accounts actions', () => {
const account2 = {...account, id: 2}; const account2 = {...account, id: 2};
getState.returns({ getState.returns({
accounts: [account2] accounts: [account]
}); });
return revoke(account)(dispatch, getState).then(() => return revoke(account2)(dispatch, getState).then(() => {
expect(dispatch, 'to have calls satisfying', [ expect(dispatch, 'to have a call satisfying', [
[remove(account)], remove(account2)
[expect.it('to be a function')] ]);
// [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? 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', () => { it('should logout if no other accounts available', () => {
revoke(account)(dispatch, getState) revoke(account)(dispatch, getState).then(() => {
.then(() => expect(dispatch, 'to have a call satisfying', [
expect(dispatch, 'to have calls satisfying', [ remove(account)
[remove(account)], ]);
[expect.it('to be a function')] expect(dispatch, 'to have a call satisfying', [
// [logout()] // TODO: this is not a plain action. How should we simplify its testing? {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?
// ])
});
}); });
}); });
}); });

View File

@ -1,7 +1,10 @@
import expect from 'unexpected'; import expect from 'unexpected';
import accounts from 'components/accounts/reducer'; 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 = { const account = {
id: 1, id: 1,
@ -15,20 +18,21 @@ describe('Accounts reducer', () => {
let initial; let initial;
beforeEach(() => { 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, active: null,
available: [] available: []
})); }));
it('should return last state if unsupported action', () =>
expect(accounts({state: 'foo'}, {}), 'to equal', {state: 'foo'})
);
describe(ACTIVATE, () => { describe(ACTIVATE, () => {
it('sets active account', () => { it('sets active account', () => {
expect(accounts(initial, { expect(accounts(initial, activate(account)), 'to satisfy', {
type: ACTIVATE,
payload: account
}), 'to satisfy', {
active: account active: account
}); });
}); });
@ -36,52 +40,49 @@ describe('Accounts reducer', () => {
describe(ADD, () => { describe(ADD, () => {
it('adds an account', () => it('adds an account', () =>
expect(accounts(initial, { expect(accounts(initial, add(account)), 'to satisfy', {
type: ADD,
payload: account
}), 'to satisfy', {
available: [account] available: [account]
}) })
); );
it('should not add the same account twice', () => it('should not add the same account twice', () =>
expect(accounts({...initial, available: [account]}, { expect(accounts({...initial, available: [account]}, add(account)), 'to satisfy', {
type: ADD,
payload: account
}), 'to satisfy', {
available: [account] available: [account]
}) })
); );
it('throws, when account is invalid', () => { it('throws, when account is invalid', () => {
expect(() => accounts(initial, { expect(() => accounts(initial, add()),
type: ADD 'to throw', 'Invalid or empty payload passed for accounts.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');
}); });
}); });
describe(REMOVE, () => { describe(REMOVE, () => {
it('should remove an account', () => it('should remove an account', () =>
expect(accounts({...initial, available: [account]}, { expect(accounts({...initial, available: [account]}, remove(account)),
type: REMOVE, 'to equal', initial)
payload: account
}), 'to equal', initial)
); );
it('throws, when account is invalid', () => { it('throws, when account is invalid', () => {
expect(() => accounts(initial, { expect(() => accounts(initial, remove()),
type: REMOVE 'to throw', 'Invalid or empty payload passed for accounts.remove');
}), 'to throw', 'Invalid or empty payload passed for accounts.remove'); });
});
expect(() => accounts(initial, { describe(UPDATE_TOKEN, () => {
type: REMOVE, it('should update token', () => {
payload: {} const newToken = 'newToken';
}), 'to throw', 'Invalid or empty payload passed for accounts.remove');
expect(accounts(
{active: account, available: [account]},
updateToken(newToken)
), 'to satisfy', {
active: {
...account,
token: newToken
},
available: [account]
});
}); });
}); });
}); });

View File

@ -11,8 +11,10 @@ import {
describe('components/user/actions', () => { describe('components/user/actions', () => {
const dispatch = sinon.stub().named('dispatch'); const getState = sinon.stub().named('store.getState');
const getState = sinon.stub().named('getState'); const dispatch = sinon.spy((arg) =>
typeof arg === 'function' ? arg(dispatch, getState) : arg
).named('store.dispatch');
const callThunk = function(fn, ...args) { const callThunk = function(fn, ...args) {
const thunk = fn(...args); const thunk = fn(...args);

View File

@ -3,11 +3,21 @@ import expect from 'unexpected';
import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware';
describe('bearerHeaderMiddleware', () => { describe('bearerHeaderMiddleware', () => {
const emptyState = {
user: {},
accounts: {
active: null
}
};
describe('when token available', () => { describe('when token available', () => {
const token = 'foo'; const token = 'foo';
const middleware = bearerHeaderMiddleware({ const middleware = bearerHeaderMiddleware({
getState: () => ({ getState: () => ({
user: {token} ...emptyState,
accounts: {
active: {token}
}
}) })
}); });
@ -20,9 +30,7 @@ describe('bearerHeaderMiddleware', () => {
middleware.before(data); middleware.before(data);
expect(data.options.headers, 'to satisfy', { expectBearerHeader(data, token);
Authorization: `Bearer ${token}`
});
}); });
it('overrides user.token with options.token if available', () => { it('overrides user.token with options.token if available', () => {
@ -36,16 +44,36 @@ describe('bearerHeaderMiddleware', () => {
middleware.before(data); middleware.before(data);
expect(data.options.headers, 'to satisfy', { expectBearerHeader(data, tokenOverride);
Authorization: `Bearer ${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', () => { it('should not set Authorization header if no token', () => {
const middleware = bearerHeaderMiddleware({ const middleware = bearerHeaderMiddleware({
getState: () => ({ getState: () => ({
user: {} ...emptyState
}) })
}); });
@ -59,4 +87,10 @@ describe('bearerHeaderMiddleware', () => {
expect(data.options.headers.Authorization, 'to be undefined'); expect(data.options.headers.Authorization, 'to be undefined');
}); });
function expectBearerHeader(data, token) {
expect(data.options.headers, 'to satisfy', {
Authorization: `Bearer ${token}`
});
}
}); });

View File

@ -3,6 +3,7 @@ import expect from 'unexpected';
import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware'; import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { updateToken } from 'components/accounts/actions';
const refreshToken = 'foo'; const refreshToken = 'foo';
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8';
@ -18,7 +19,9 @@ describe('refreshTokenMiddleware', () => {
sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); sinon.stub(authentication, 'requestToken').named('authentication.requestToken');
getState = sinon.stub().named('store.getState'); 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}); middleware = refreshTokenMiddleware({getState, dispatch});
}); });
@ -27,14 +30,21 @@ describe('refreshTokenMiddleware', () => {
authentication.requestToken.restore(); authentication.requestToken.restore();
}); });
it('must be till 2100 to test with validToken', () =>
expect(new Date().getFullYear(), 'to be less than', 2100)
);
describe('#before', () => { describe('#before', () => {
describe('when token expired', () => { describe('when token expired', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
user: { accounts: {
active: {
token: expiredToken, token: expiredToken,
refreshToken refreshToken
} }
},
user: {}
}); });
}); });
@ -76,21 +86,94 @@ describe('refreshTokenMiddleware', () => {
expect(authentication.requestToken, 'was not called'); expect(authentication.requestToken, 'was not called');
}); });
xit('should update user with new token'); // TODO: need a way to test, that action was called it('should update user with new token', () => {
xit('should logout if invalid token'); // TODO: need a way to test, that action was called 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()); authentication.requestToken.returns(Promise.reject());
return middleware.before({url: 'foo'}).then((resp) => { return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() =>
// TODO: need a way to test, that action was called expect(dispatch, 'to have a call satisfying', [
expect(dispatch, 'to have a call satisfying', logout); {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', () => { it('should not be applied if no token', () => {
getState.returns({ getState.returns({
accounts: {
active: null
},
user: {} user: {}
}); });
@ -114,7 +197,15 @@ describe('refreshTokenMiddleware', () => {
const badTokenReponse = { const badTokenReponse = {
name: 'Unauthorized', 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, code: 0,
status: 401, status: 401,
type: 'yii\\web\\UnauthorizedHttpException' type: 'yii\\web\\UnauthorizedHttpException'
@ -124,9 +215,10 @@ describe('refreshTokenMiddleware', () => {
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
user: { accounts: {
refreshToken active: {refreshToken}
} },
user: {}
}); });
restart = sinon.stub().named('restart'); restart = sinon.stub().named('restart');
@ -143,12 +235,27 @@ describe('refreshTokenMiddleware', () => {
}) })
); );
xit('should logout user if token cannot be refreshed', () => { it('should logout user if invalid credential', () =>
// TODO: need a way to test, that action was called expect(
return middleware.catch(badTokenReponse, {options: {}}, restart).then(() => { middleware.catch(badTokenReponse, {options: {}}, restart),
// TODO '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', () => { it('should pass the request through if options.autoRefreshToken === false', () => {
const promise = middleware.catch(expiredResponse, { const promise = middleware.catch(expiredResponse, {
@ -175,5 +282,25 @@ describe('refreshTokenMiddleware', () => {
expect(authentication.requestToken, 'was not called'); 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');
})
);
});
}); });
}); });

View File

@ -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
);
});
});
});
});