#48: initial logic for multy-accounts actions

This commit is contained in:
SleepWalker
2016-10-30 14:12:49 +02:00
parent 200a1f339f
commit 7dd58acede
13 changed files with 572 additions and 116 deletions

View File

@@ -0,0 +1,97 @@
import authentication from 'services/api/authentication';
import accounts from 'services/api/accounts';
import { updateUser, logout } from 'components/user/actions';
/**
* @typedef {object} Account
* @property {string} account.id
* @property {string} account.username
* @property {string} account.email
* @property {string} account.token
* @property {string} account.refreshToken
*/
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*/
export function authenticate({token, refreshToken}) {
return (dispatch) => {
return authentication.validateToken({token, refreshToken})
.then(({token, refreshToken}) =>
accounts.current({token})
.then((user) => ({
user,
account: {
id: user.id,
username: user.username,
email: user.email,
token,
refreshToken
}
}))
)
.then(({user, account}) => {
dispatch(add(account));
dispatch(activate(account));
dispatch(updateUser(user));
return account;
});
};
}
/**
* @param {Account} account
*/
export function revoke(account) {
return (dispatch, getState) => {
dispatch(remove(account));
if (getState().accounts.length) {
return dispatch(authenticate(getState().accounts[0]));
} else {
return dispatch(logout());
}
};
}
export const ADD = 'accounts:add';
/**
* @api private
*
* @param {Account} account
*/
export function add(account) {
return {
type: ADD,
payload: account
};
}
export const REMOVE = 'accounts:remove';
/**
* @api private
*
* @param {Account} account
*/
export function remove(account) {
return {
type: REMOVE,
payload: account
};
}
export const ACTIVATE = 'accounts:activate';
/**
* @api private
*
* @param {Account} account
*/
export function activate(account) {
return {
type: ACTIVATE,
payload: account
};
}

View File

@@ -0,0 +1,58 @@
import { ADD, REMOVE, ACTIVATE } from './actions';
/**
* @typedef {AccountsState}
* @property {Account} active
* @property {Account[]} available
*/
/**
* @param {AccountsState} state
* @param {string} options.type
* @param {object} options.payload
*
* @return {AccountsState}
*/
export default function accounts(
state,
{type, payload = {}}
) {
switch (type) {
case ADD:
if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
if (!state.available.some((account) => account.id === payload.id)) {
state.available = state.available.concat(payload);
}
return state;
case ACTIVATE:
if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
return {
...state,
active: payload
};
case REMOVE:
if (!payload || !payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
return {
...state,
available: state.available.filter((account) => account.id !== payload.id)
};
default:
return {
active: null,
available: []
};
}
}

View File

@@ -36,7 +36,7 @@ export function login({login = '', password = '', rememberMe = false}) {
return dispatch(needActivation()); return dispatch(needActivation());
} else if (resp.errors.login === LOGIN_REQUIRED && password) { } else if (resp.errors.login === LOGIN_REQUIRED && password) {
// return to the first step // return to the first step
dispatch(logout()); return dispatch(logout());
} }
} }

View File

@@ -4,7 +4,7 @@ const KEY_USER = 'user';
export default class User { export default class User {
/** /**
* @param {object|string|undefined} data plain object or jwt token or empty to load from storage * @param {object} [data] - plain object or jwt token or empty to load from storage
* *
* @return {User} * @return {User}
*/ */
@@ -18,8 +18,6 @@ export default class User {
const defaults = { const defaults = {
id: null, id: null,
uuid: null, uuid: null,
token: '',
refreshToken: '',
username: '', username: '',
email: '', email: '',
// will contain user's email or masked email // will contain user's email or masked email
@@ -27,12 +25,18 @@ export default class User {
maskedEmail: '', maskedEmail: '',
avatar: '', avatar: '',
lang: '', lang: '',
goal: null, // the goal with wich user entered site
isGuest: true,
isActive: false, isActive: false,
shouldAcceptRules: false, // whether user need to review updated rules shouldAcceptRules: false, // whether user need to review updated rules
passwordChangedAt: null, passwordChangedAt: null,
hasMojangUsernameCollision: false, hasMojangUsernameCollision: false,
// 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) => { const user = Object.keys(defaults).reduce((user, key) => {

View File

@@ -8,14 +8,18 @@
*/ */
export default function bearerHeaderMiddleware({getState}) { export default function bearerHeaderMiddleware({getState}) {
return { return {
before(data) { before(req) {
const {token} = getState().user; let {token} = getState().user;
if (token) { if (req.options.token) {
data.options.headers.Authorization = `Bearer ${token}`; token = req.options.token;
} }
return data; if (token) {
req.options.headers.Authorization = `Bearer ${token}`;
}
return req;
} }
}; };
} }

View File

@@ -12,12 +12,12 @@ import {updateUser, logout} from '../actions';
*/ */
export default function refreshTokenMiddleware({dispatch, getState}) { export default function refreshTokenMiddleware({dispatch, getState}) {
return { return {
before(data) { before(req) {
const {refreshToken, token} = getState().user; const {refreshToken, token} = getState().user;
const isRefreshTokenRequest = data.url.includes('refresh-token'); const isRefreshTokenRequest = req.url.includes('refresh-token');
if (!token || isRefreshTokenRequest) { if (!token || isRefreshTokenRequest || req.options.autoRefreshToken === false) {
return data; return req;
} }
try { try {
@@ -25,33 +25,17 @@ export default function refreshTokenMiddleware({dispatch, getState}) {
const jwt = getJWTPayload(token); const jwt = getJWTPayload(token);
if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) { if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) {
return requestAccessToken(refreshToken, dispatch).then(() => data); return requestAccessToken(refreshToken, dispatch).then(() => req);
} }
} catch (err) { } catch (err) {
dispatch(logout()); dispatch(logout());
} }
return data; return req;
}, },
catch(resp, restart) { catch(resp, req, restart) {
/* if (resp && resp.status === 401 && req.options.autoRefreshToken !== false) {
{
"name": "Unauthorized",
"message": "You are requesting with an invalid credential.",
"code": 0,
"status": 401,
"type": "yii\\web\\UnauthorizedHttpException"
}
{
"name": "Unauthorized",
"message": "Token expired",
"code": 0,
"status": 401,
"type": "yii\\web\\UnauthorizedHttpException"
}
*/
if (resp && resp.status === 401) {
const {refreshToken} = getState().user; const {refreshToken} = getState().user;
if (resp.message === 'Token expired' && refreshToken) { if (resp.message === 'Token expired' && refreshToken) {
// request token and retry // request token and retry

View File

@@ -1,8 +1,18 @@
import request from 'services/request'; import request from 'services/request';
export default { export default {
current() { /**
return request.get('/api/accounts/current'); * @param {object} options
* @param {object} [options.token] - an optional token to overwrite headers in middleware
* @param {bool} [options.autoRefreshToken=true] - disable token auto refresh during request
*
* @return {Promise<User>}
*/
current(options = {}) {
return request.get('/api/accounts/current', {}, {
token: options.token,
autoRefreshToken: options.autoRefreshToken
});
}, },
changePassword({ changePassword({

View File

@@ -1,4 +1,5 @@
import request from 'services/request'; import request from 'services/request';
import accounts from 'services/api/accounts';
export default { export default {
login({ login({
@@ -36,6 +37,23 @@ export default {
); );
}, },
/**
* Resolves if token is valid
*
* @param {object} options
* @param {string} options.token
* @param {string} options.refreshToken
*
* @return {Promise} - resolves with options.token or with a new token
* 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}));
},
/** /**
* Request new access token using a refreshToken * Request new access token using a refreshToken
* *

View File

@@ -5,33 +5,36 @@ const middlewareLayer = new PromiseMiddlewareLayer();
export default { export default {
/** /**
* @param {string} url * @param {string} url
* @param {object} data * @param {object} data - request data
* @param {object} options - additional options for fetch or middlewares
* *
* @return {Promise} * @return {Promise}
*/ */
post(url, data) { post(url, data, options = {}) {
return doFetch(url, { return doFetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}, },
body: buildQuery(data) body: buildQuery(data),
...options
}); });
}, },
/** /**
* @param {string} url * @param {string} url
* @param {object} data * @param {object} data - request data
* @param {object} options - additional options for fetch or middlewares
* *
* @return {Promise} * @return {Promise}
*/ */
get(url, data) { get(url, data, options = {}) {
if (typeof data === 'object') { if (typeof data === 'object' && Object.keys(data).length) {
const separator = url.indexOf('?') === -1 ? '?' : '&'; const separator = url.indexOf('?') === -1 ? '?' : '&';
url += separator + buildQuery(data); url += separator + buildQuery(data);
} }
return doFetch(url); return doFetch(url, options);
}, },
/** /**
@@ -82,8 +85,8 @@ function doFetch(url, options = {}) {
.then(checkStatus) .then(checkStatus)
.then(toJSON, rejectWithJSON) .then(toJSON, rejectWithJSON)
.then(handleResponseSuccess) .then(handleResponseSuccess)
.then((resp) => middlewareLayer.run('then', resp)) .then((resp) => middlewareLayer.run('then', resp, {url, options}))
.catch((resp) => middlewareLayer.run('catch', resp, () => doFetch(url, options))) .catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options)))
; ;
} }

View File

@@ -0,0 +1,134 @@
import expect from 'unexpected';
import accounts from 'services/api/accounts';
import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions';
import { updateUser, logout } from 'components/user/actions';
const account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
refreshToken: 'foo'
};
const user = {
id: 1,
username: 'username',
email: 'email@test.com',
};
describe('Accounts actions', () => {
let dispatch;
let getState;
beforeEach(() => {
dispatch = sinon.spy(function dispatch(arg) {
return typeof arg === 'function' ? arg(dispatch, getState) : arg;
}).named('dispatch');
getState = sinon.stub().named('getState');
getState.returns({
accounts: [],
user: {}
});
sinon.stub(accounts, 'current').named('accounts.current');
accounts.current.returns(Promise.resolve(user));
});
afterEach(() => {
accounts.current.restore();
});
describe('#authenticate()', () => {
it('should request user state using token', () => {
authenticate(account)(dispatch);
expect(accounts.current, 'to have a call satisfying', [
{token: account.token}
]);
});
it(`dispatches ${ADD} action`, () =>
authenticate(account)(dispatch).then(() =>
expect(dispatch, 'to have a call satisfying', [
add(account)
])
)
);
it(`dispatches ${ACTIVATE} action`, () =>
authenticate(account)(dispatch).then(() =>
expect(dispatch, 'to have a call satisfying', [
activate(account)
])
)
);
it('should update user state', () =>
authenticate(account)(dispatch).then(() =>
expect(dispatch, 'to have a call satisfying', [
updateUser(user)
])
)
);
it('resolves with account', () =>
authenticate(account)(dispatch).then((resp) =>
expect(resp, 'to equal', account)
)
);
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();
});
});
});
describe('#revoke()', () => {
it(`should dispatch ${REMOVE} action`, () => {
revoke(account)(dispatch, getState);
expect(dispatch, 'to have a call satisfying', [
remove(account)
]);
});
it('should switch next account if available', () => {
const account2 = {...account, id: 2};
getState.returns({
accounts: [account2]
});
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?
])
);
});
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?
])
);
});
});
});

View File

@@ -0,0 +1,87 @@
import expect from 'unexpected';
import accounts from 'components/accounts/reducer';
import { ADD, REMOVE, ACTIVATE } from 'components/accounts/actions';
const account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
refreshToken: 'foo'
};
describe('Accounts reducer', () => {
let initial;
beforeEach(() => {
initial = accounts(null, {});
});
it('should be empty', () => expect(accounts(null, {}), 'to equal', {
active: null,
available: []
}));
describe(ACTIVATE, () => {
it('sets active account', () => {
expect(accounts(initial, {
type: ACTIVATE,
payload: account
}), 'to satisfy', {
active: account
});
});
});
describe(ADD, () => {
it('adds an account', () =>
expect(accounts(initial, {
type: ADD,
payload: account
}), 'to satisfy', {
available: [account]
})
);
it('should not add the same account twice', () =>
expect(accounts({...initial, available: [account]}, {
type: ADD,
payload: 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');
});
});
describe(REMOVE, () => {
it('should remove an account', () =>
expect(accounts({...initial, available: [account]}, {
type: REMOVE,
payload: 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, {
type: REMOVE,
payload: {}
}), 'to throw', 'Invalid or empty payload passed for accounts.remove');
});
});
});

View File

@@ -3,7 +3,7 @@ import expect from 'unexpected';
import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware';
describe('bearerHeaderMiddleware', () => { describe('bearerHeaderMiddleware', () => {
it('should set Authorization header', () => { describe('when token available', () => {
const token = 'foo'; const token = 'foo';
const middleware = bearerHeaderMiddleware({ const middleware = bearerHeaderMiddleware({
getState: () => ({ getState: () => ({
@@ -11,16 +11,34 @@ describe('bearerHeaderMiddleware', () => {
}) })
}); });
const data = { it('should set Authorization header', () => {
options: { const data = {
headers: {} options: {
} headers: {}
}; }
};
middleware.before(data); middleware.before(data);
expect(data.options.headers, 'to satisfy', { expect(data.options.headers, 'to satisfy', {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
});
});
it('overrides user.token with options.token if available', () => {
const tokenOverride = 'tokenOverride';
const data = {
options: {
headers: {},
token: tokenOverride
}
};
middleware.before(data);
expect(data.options.headers, 'to satisfy', {
Authorization: `Bearer ${tokenOverride}`
});
}); });
}); });

View File

@@ -28,29 +28,64 @@ describe('refreshTokenMiddleware', () => {
}); });
describe('#before', () => { describe('#before', () => {
it('should request new token', () => { describe('when token expired', () => {
getState.returns({ beforeEach(() => {
user: { getState.returns({
token: expiredToken, user: {
refreshToken token: expiredToken,
} refreshToken
}
});
}); });
const data = { it('should request new token', () => {
url: 'foo', const data = {
options: { url: 'foo',
headers: {} options: {
} headers: {}
}; }
};
authentication.requestToken.returns(Promise.resolve({token: validToken})); 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 apply to refresh-token request', () => {
const data = {url: '/refresh-token'};
const resp = middleware.before(data);
return middleware.before(data).then((resp) => {
expect(resp, 'to satisfy', data); expect(resp, 'to satisfy', data);
expect(authentication.requestToken, 'to have a call satisfying', [ expect(authentication.requestToken, 'was not called');
refreshToken });
]);
it('should not apply if options.autoRefreshToken === false', () => {
const data = {
url: 'foo',
options: {autoRefreshToken: false}
};
middleware.before(data);
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
xit('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);
});
}); });
}); });
@@ -66,74 +101,78 @@ describe('refreshTokenMiddleware', () => {
expect(authentication.requestToken, 'was not called'); expect(authentication.requestToken, 'was not called');
}); });
it('should not apply to refresh-token request', () => {
getState.returns({
user: {}
});
const data = {url: '/refresh-token'};
const resp = middleware.before(data);
expect(resp, 'to satisfy', data);
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
xit('should logout if token request failed', () => {
getState.returns({
user: {
token: expiredToken,
refreshToken
}
});
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);
});
});
}); });
describe('#catch', () => { describe('#catch', () => {
it('should request new token', () => { const expiredResponse = {
name: 'Unauthorized',
message: 'Token expired',
code: 0,
status: 401,
type: 'yii\\web\\UnauthorizedHttpException'
};
const badTokenReponse = {
name: 'Unauthorized',
message: 'Token expired',
code: 0,
status: 401,
type: 'yii\\web\\UnauthorizedHttpException'
};
let restart;
beforeEach(() => {
getState.returns({ getState.returns({
user: { user: {
refreshToken refreshToken
} }
}); });
const restart = sinon.stub().named('restart'); restart = sinon.stub().named('restart');
authentication.requestToken.returns(Promise.resolve({token: validToken})); authentication.requestToken.returns(Promise.resolve({token: validToken}));
});
return middleware.catch({ it('should request new token if expired', () =>
status: 401, middleware.catch(expiredResponse, {options: {}}, restart).then(() => {
message: 'Token expired'
}, restart).then(() => {
expect(authentication.requestToken, 'to have a call satisfying', [ expect(authentication.requestToken, 'to have a call satisfying', [
refreshToken refreshToken
]); ]);
expect(restart, 'was called'); expect(restart, 'was called');
})
);
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
}); });
}); });
xit('should logout user if token cannot be refreshed'); // TODO: need a way to test, that action was called it('should pass the request through if options.autoRefreshToken === false', () => {
const promise = middleware.catch(expiredResponse, {
options: {
autoRefreshToken: false
}
}, restart);
return expect(promise, 'to be rejected with', expiredResponse).then(() => {
expect(restart, 'was not called');
expect(authentication.requestToken, 'was not called');
});
});
it('should pass the rest of failed requests through', () => { it('should pass the rest of failed requests through', () => {
const resp = {}; const resp = {};
const promise = middleware.catch(resp); const promise = middleware.catch(resp, {
options: {}
}, restart);
expect(promise, 'to be rejected'); return expect(promise, 'to be rejected with', resp).then(() => {
expect(restart, 'was not called');
return promise.catch((actual) => { expect(authentication.requestToken, 'was not called');
expect(actual, 'to be', resp);
}); });
}); });
}); });