Π·Π΅Ρ€ΠΊΠ°Π»ΠΎ ΠΈΠ·
1
0
Π€ΠΎΡ€ΠΊΠ½ΡƒΡ‚ΡŒ 0
* Retrieve user profile on SET_AUTH_TOKEN
* Require cheerio (deps)
* Use user state to determine whether a user is logged in
* add isAuthenticated selector
This commit is contained in:
William Durand 2017-08-31 12:31:01 +02:00 ΠΊΠΎΠΌΠΌΠΈΡ‚ ΠΏΡ€ΠΎΠΈΠ·Π²Ρ‘Π» GitHub
Π ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒ 5ba96901db
ΠšΠΎΠΌΠΌΠΈΡ‚ 071db107cb
26 ΠΈΠ·ΠΌΠ΅Π½Ρ‘Π½Π½Ρ‹Ρ… Ρ„Π°ΠΉΠ»ΠΎΠ²: 631 Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠΉ ΠΈ 195 ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΉ

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -224,6 +224,7 @@
"bundle-loader": "^0.5.5",
"bundlesize": "^0.14.0",
"chalk": "^2.0.1",
"cheerio": "^1.0.0-rc.2",
"chokidar-cli": "^1.2.0",
"concurrently": "^3.4.0",
"content-security-policy-parser": "^0.1.0",

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -197,7 +197,7 @@ export class RatingManagerBase extends React.Component {
export const mapStateToProps = (
state: Object, ownProps: RatingManagerProps
) => {
const userId = state.api && state.api.userId;
const userId = state.user.id;
let userReview;
// Look for the latest saved review by this user for this add-on.

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -11,6 +11,7 @@ import reviews from 'amo/sagas/reviews';
import addons from 'core/sagas/addons';
import search from 'core/sagas/search';
import autocomplete from 'core/sagas/autocomplete';
import user from 'core/sagas/user';
// Export all sagas for this app so runSaga can consume them.
@ -23,5 +24,6 @@ export default function* rootSaga() {
fork(landing),
fork(reviews),
fork(search),
fork(user),
]);
}

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -16,6 +16,7 @@ import errorPage from 'core/reducers/errorPage';
import infoDialog from 'core/reducers/infoDialog';
import installations from 'core/reducers/installations';
import search from 'core/reducers/search';
import user from 'core/reducers/user';
import { middleware } from 'core/store';
@ -38,6 +39,7 @@ export default function createStore(initialState = {}) {
reduxAsyncConnect,
reviews,
search,
user,
viewContext,
}),
initialState,

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -22,7 +22,6 @@ const Entity = normalizrSchema.Entity;
export const addon = new Entity('addons', {}, { idAttribute: 'slug' });
export const category = new Entity('categories', {}, { idAttribute: 'slug' });
export const user = new Entity('users', {}, { idAttribute: 'username' });
export function makeQueryString(query: { [key: string]: * }) {
const resolvedQuery = { ...query };
@ -214,15 +213,6 @@ export function startLoginUrl(
return `${API_BASE}/accounts/login/start/${query}`;
}
export function fetchProfile({ api }: {| api: ApiStateType |}) {
return callApi({
endpoint: 'accounts/profile',
schema: user,
auth: true,
state: api,
});
}
type FeaturedParams = {|
api: ApiStateType,
filters: Object,

16
src/core/api/user.js Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,16 @@
/* @flow */
import { callApi } from 'core/api';
import type { ApiStateType } from 'core/reducers/api';
export function userProfile({ api }: {| api: ApiStateType |}) {
if (!api) {
throw new Error('The api state is required.');
}
return callApi({
auth: true,
endpoint: 'accounts/profile',
state: api,
});
}

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -7,13 +7,16 @@ import { compose } from 'redux';
import { logOutUser } from 'core/actions';
import { logOutFromServer, startLoginUrl } from 'core/api';
import { isAuthenticated as isUserAuthenticated } from 'core/reducers/user';
import translate from 'core/i18n/translate';
import Button from 'ui/components/Button';
import Icon from 'ui/components/Icon';
import type { ApiStateType } from 'core/reducers/api';
import type { UserStateType } from 'core/reducers/user';
import type { DispatchFunc } from 'core/types/redux';
import type { ReactRouterLocation } from 'core/types/router';
type HandleLogInFunc = (
location: ReactRouterLocation, options?: {| _window: typeof window |}
) => void;
@ -82,10 +85,13 @@ type StateMappedProps = {|
|};
export const mapStateToProps = (
state: {| api: ApiStateType |}
state: {|
api: ApiStateType,
user: UserStateType,
|}
): StateMappedProps => ({
api: state.api,
isAuthenticated: !!state.api.token,
isAuthenticated: isUserAuthenticated(state),
handleLogIn(location, { _window = window } = {}) {
// eslint-disable-next-line no-param-reassign
_window.location = startLoginUrl({ location });

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -4,12 +4,8 @@ import { connect } from 'react-redux';
import { compose } from 'redux';
import LoginPage from 'core/components/LoginPage';
import { isAuthenticated } from 'core/reducers/user';
export function mapStateToProps(state) {
return {
authenticated: !!state.api.token,
};
}
// This class is exported for testing outside of redux.
export class LoginRequiredBase extends React.Component {
@ -28,6 +24,12 @@ export class LoginRequiredBase extends React.Component {
}
}
export function mapStateToProps(state) {
return {
authenticated: isAuthenticated(state),
};
}
export default compose(
connect(mapStateToProps),
)(LoginRequiredBase);

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -1,5 +1,4 @@
/* @flow */
import base64url from 'base64url';
import UAParser from 'ua-parser-js';
import {
@ -9,7 +8,6 @@ import {
SET_CLIENT_APP,
SET_USER_AGENT,
} from 'core/constants';
import log from 'core/logger';
import type {
SetAuthTokenAction,
LogOutUserAction,
@ -36,7 +34,6 @@ export type ApiStateType = {
token: ?string,
userAgent: ?string,
userAgentInfo: UserAgentInfoType,
userId: ?number,
};
export const initialApiState: ApiStateType = {
@ -45,27 +42,8 @@ export const initialApiState: ApiStateType = {
token: null,
userAgent: null,
userAgentInfo: { browser: {}, os: {} },
userId: null,
};
function getUserIdFromAuthToken(token) {
let data;
try {
const parts = token.split(':');
if (parts.length < 3) {
throw new Error('not enough auth token segments');
}
data = JSON.parse(base64url.decode(parts[0]));
log.info('decoded auth token data:', data);
if (!data.user_id) {
throw new Error('user_id is missing from decoded data');
}
return data.user_id;
} catch (error) {
throw new Error(`Error parsing auth token "${token}": ${error}`);
}
}
export default function api(
state: Exact<ApiStateType> = initialApiState,
action: SetAuthTokenAction
@ -79,10 +57,6 @@ export default function api(
return {
...state,
token: action.payload.token,
// Extract user data from the auth token (which is loaded from a cookie
// on each request). This doesn't check the token's signature
// because the server is responsible for that.
userId: getUserIdFromAuthToken(action.payload.token),
};
case SET_LANG:
return { ...state, lang: action.payload.lang };
@ -99,7 +73,7 @@ export default function api(
};
}
case LOG_OUT_USER:
return { ...state, token: null, userId: null };
return { ...state, token: null };
default:
return state;
}

51
src/core/reducers/user.js Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,51 @@
/* @flow */
import { LOG_OUT_USER } from 'core/constants';
const LOAD_USER_PROFILE = 'LOAD_USER_PROFILE';
type Action = Object;
export type UserStateType = {
id: ?number,
username: ?string,
};
export const initialState: UserStateType = {
id: null,
username: null,
};
export const loadUserProfile = ({ profile }: Object) => {
if (!profile) {
throw new Error('The profile parameter is required.');
}
return {
type: LOAD_USER_PROFILE,
payload: { profile },
};
};
export const isAuthenticated = (state: { user: UserStateType }) => {
return !!state.user.id;
};
export default function reducer(
state: UserStateType = initialState,
action: Action = {}
): UserStateType {
const { payload } = action;
switch (action.type) {
case LOAD_USER_PROFILE:
return {
...state,
id: payload.profile.id,
username: payload.profile.username,
};
case LOG_OUT_USER:
return initialState;
default:
return state;
}
}

32
src/core/sagas/user.js Normal file
ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,32 @@
// Disabled because of
// https://github.com/benmosher/eslint-plugin-import/issues/793
/* eslint-disable import/order */
import { call, put, select, takeLatest } from 'redux-saga/effects';
/* eslint-enable import/order */
import { SET_AUTH_TOKEN } from 'core/constants';
import { getState } from 'core/sagas/utils';
import { loadUserProfile } from 'core/reducers/user';
import { userProfile as userProfileApi } from 'core/api/user';
// This saga is not triggered by the UI but on the server side, hence do not
// have a `errorHandler`. We do not want to miss any error because it would
// mean no ways for the users to log in, so we let the errors bubble up.
export function* fetchUserProfile({ payload }) {
const { token } = payload;
const state = yield select(getState);
const response = yield call(userProfileApi, {
api: {
...state.api,
token,
},
});
yield put(loadUserProfile({ profile: response }));
}
export default function* userSaga() {
yield takeLatest(SET_AUTH_TOKEN, fetchUserProfile);
}

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -3,7 +3,7 @@ import path from 'path';
import 'babel-polyfill';
import { oneLine } from 'common-tags';
import config from 'config';
import defaultConfig from 'config';
import Express from 'express';
import helmet from 'helmet';
import Raven from 'raven';
@ -36,16 +36,10 @@ import I18nProvider from 'core/i18n/Provider';
import WebpackIsomorphicToolsConfig from './webpack-isomorphic-tools-config';
const env = config.util.getEnv('NODE_ENV');
// This is a magic file that gets written by deployment scripts.
const version = path.join(config.get('basePath'), 'version.json');
const isDeployed = config.get('isDeployed');
const isDevelopment = config.get('isDevelopment');
export function getPageProps({ noScriptStyles = '', store, req, res, config }) {
const appName = config.get('appName');
const isDeployed = config.get('isDeployed');
const appName = config.get('appName');
export function getPageProps({ noScriptStyles = '', store, req, res }) {
// Get SRI for deployed services only.
const sriData = (isDeployed) ? JSON.parse(
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json'))
@ -86,9 +80,9 @@ export function getPageProps({ noScriptStyles = '', store, req, res }) {
};
}
function showErrorPage({ createStore, error = {}, req, res, status }) {
function showErrorPage({ createStore, error = {}, req, res, status, config }) {
const { store } = createStore();
const pageProps = getPageProps({ store, req, res });
const pageProps = getPageProps({ store, req, res, config });
const componentDeclaredStatus = NestedStatus.rewind();
let adjustedStatus = status || componentDeclaredStatus || 500;
@ -116,7 +110,13 @@ function hydrateOnClient({ res, props = {}, pageProps }) {
.end();
}
function baseServer(routes, createStore, { appSagas, appInstanceName = appName } = {}) {
function baseServer(routes, createStore, {
appSagas,
appInstanceName = null,
config = defaultConfig,
} = {}) {
const appName = appInstanceName !== null ? appInstanceName : config.get('appName');
const app = new Express();
app.disable('x-powered-by');
@ -127,8 +127,7 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
log.info(`Sentry reporting configured with DSN ${sentryDsn}`);
// The error handler is defined below.
} else {
log.warn(
'Sentry reporting is disabled; Set config.sentryDsn to enable it.');
log.warn('Sentry reporting is disabled; Set config.sentryDsn to enable it.');
}
app.use(middleware.logRequests);
@ -156,6 +155,9 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
// Show version/commit information as JSON.
function viewVersion(req, res) {
// This is a magic file that gets written by deployment scripts.
const version = path.join(config.get('basePath'), 'version.json');
fs.stat(version, (error) => {
if (error) {
log.error(`Could not stat version file ${version}: ${error}`);
@ -175,7 +177,8 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
// Return 200 for csp reports - this will need to be overridden when deployed.
app.post('/__cspreport__', (req, res) => res.status(200).end('ok'));
if (appInstanceName === 'disco' && isDevelopment) {
const isDevelopment = config.get('isDevelopment');
if (appName === 'disco' && isDevelopment) {
app.get('/', (req, res) =>
res.redirect(302, '/en-US/firefox/discovery/pane/48.0/Darwin/normal'));
}
@ -224,12 +227,13 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
}
if (!renderProps) {
log.info(`match() did not return renderProps for ${req.url}`);
return showErrorPage({ createStore, status: 404, req, res });
return showErrorPage({ createStore, status: 404, req, res, config });
}
let htmlLang;
let locale;
let pageProps;
let runningSagas;
let sagaMiddleware;
let store;
@ -239,23 +243,34 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
const storeAndSagas = createStore();
sagaMiddleware = storeAndSagas.sagaMiddleware;
store = storeAndSagas.store;
let sagas = appSagas;
if (!sagas) {
// eslint-disable-next-line global-require, import/no-dynamic-require
sagas = require(`${appName}/sagas`).default;
}
runningSagas = sagaMiddleware.run(sagas);
const token = cookie.load(config.get('cookieName'));
if (token) {
store.dispatch(setAuthToken(token));
}
pageProps = getPageProps({ noScriptStyles, store, req, res });
pageProps = getPageProps({ noScriptStyles, store, req, res, config });
if (config.get('disableSSR') === true) {
log.warn(
'Server side rendering disabled; responding without loading');
return hydrateOnClient({ res, pageProps });
// This stops all running sagas.
store.dispatch(END);
return runningSagas.done.then(() => {
log.warn('Server side rendering is disabled.');
return hydrateOnClient({ res, pageProps });
});
}
htmlLang = pageProps.htmlLang;
locale = langToLocale(htmlLang);
} catch (preLoadError) {
log.info(
`Caught an error in match() before loadOnServer(): ${preLoadError}`);
log.info(`Caught an error in match() before loadOnServer(): ${preLoadError}`);
return next(preLoadError);
}
@ -266,14 +281,11 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
try {
if (locale !== langToLocale(config.get('defaultLang'))) {
// eslint-disable-next-line global-require, import/no-dynamic-require
i18nData = require(
`../../locale/${locale}/${appInstanceName}.js`);
i18nData = require(`../../locale/${locale}/${appName}.js`);
}
} catch (e) {
log.info(
`Locale JSON not found or required for locale: "${locale}"`);
log.info(
`Falling back to default lang: "${config.get('defaultLang')}".`);
log.info(`Locale JSON not found or required for locale: "${locale}"`);
log.info(`Falling back to default lang: "${config.get('defaultLang')}".`);
}
const i18n = makeI18n(i18nData, htmlLang);
@ -292,12 +304,6 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
}
const props = { component: InitialComponent };
let sagas = appSagas;
if (!sagas) {
// eslint-disable-next-line global-require, import/no-dynamic-require
sagas = require(`${appName}/sagas`).default;
}
const runningSagas = sagaMiddleware.run(sagas);
// We need to render once because it will force components to
// dispatch data loading actions which get processed by sagas.
@ -333,27 +339,29 @@ function baseServer(routes, createStore, { appSagas, appInstanceName = appName }
because a response was already sent; error: ${error}`);
return next(error);
}
log.error(`Showing 500 page for error: ${error}`);
log.error({ err: error }); // log the stack trace too.
return showErrorPage({ createStore, error, status: 500, req, res });
return showErrorPage({ createStore, error, status: 500, req, res, config });
});
return app;
}
export function runServer({
listen = true, app = appName, exitProcess = true,
listen = true,
exitProcess = true,
config = defaultConfig,
} = {}) {
const port = config.get('serverPort');
const host = config.get('serverHost');
const appName = config.get('appName');
const isoMorphicServer = new WebpackIsomorphicTools(
WebpackIsomorphicToolsConfig);
const isoMorphicServer = new WebpackIsomorphicTools(WebpackIsomorphicToolsConfig);
return new Promise((resolve) => {
if (!app) {
throw new Error(
`Please specify a valid appName from ${config.get('validAppNames')}`);
if (!appName) {
throw new Error(`Please specify a valid appName from ${config.get('validAppNames')}`);
}
resolve();
})
@ -364,11 +372,11 @@ export function runServer({
// now fire up the actual server.
return new Promise((resolve, reject) => {
/* eslint-disable global-require, import/no-dynamic-require */
const routes = require(`${app}/routes`).default;
const createStore = require(`${app}/store`).default;
const routes = require(`${appName}/routes`).default;
const createStore = require(`${appName}/store`).default;
/* eslint-enable global-require, import/no-dynamic-require */
const server = baseServer(
routes, createStore, { appInstanceName: app });
routes, createStore, { appInstanceName: appName });
if (listen === true) {
server.listen(port, host, (err) => {
if (err) {
@ -377,9 +385,13 @@ export function runServer({
const proxyEnabled = convertBoolean(config.get('proxyEnabled'));
// Not using oneLine here since it seems to change ' ' to ' '.
log.info([
`πŸ”₯ Addons-frontend server is running [ENV:${env}] [APP:${app}]`,
`[isDevelopment:${isDevelopment}] [isDeployed:${isDeployed}]`,
`[apiHost:${config.get('apiHost')}] [apiPath:${config.get('apiPath')}]`,
`πŸ”₯ Addons-frontend server is running`,
`[ENV:${config.util.getEnv('NODE_ENV')}]`,
`[APP:${appName}]`,
`[isDevelopment:${config.get('isDevelopment')}]`,
`[isDeployed:${config.get('isDeployed')}]`,
`[apiHost:${config.get('apiHost')}]`,
`[apiPath:${config.get('apiPath')}]`,
].join(' '));
if (proxyEnabled) {
const proxyPort = config.get('proxyPort');

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -6,6 +6,7 @@ import {
import translate from 'core/i18n/translate';
import { setAuthToken } from 'core/actions';
import { loadUserProfile } from 'core/reducers/user';
import {
ADDON_TYPE_DICT,
ADDON_TYPE_EXTENSION,
@ -24,7 +25,12 @@ import {
import {
fakeAddon, fakeReview, signedInApiState,
} from 'tests/unit/amo/helpers';
import { getFakeI18nInst, userAuthToken } from 'tests/unit/helpers';
import {
createUserProfileResponse,
getFakeI18nInst,
userAuthToken,
} from 'tests/unit/helpers';
function render(customProps = {}) {
const props = {
@ -384,9 +390,10 @@ describe('RatingManager', () => {
}
function signIn({ userId = 98765 } = {}) {
store.dispatch(setAuthToken(userAuthToken({
user_id: userId,
})));
store.dispatch(setAuthToken(userAuthToken()));
store.dispatch(loadUserProfile({
profile: createUserProfileResponse({ id: userId }),
}));
}
it('copies api state to props', () => {

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -9,9 +9,11 @@ import { addon as addonSchema } from 'core/api';
import { ADDON_TYPE_THEME, CLIENT_APP_FIREFOX } from 'core/constants';
import { searchLoad, searchStart } from 'core/actions/search';
import { autocompleteLoad, autocompleteStart } from 'core/reducers/autocomplete';
import { loadUserProfile } from 'core/reducers/user';
import {
createStubErrorHandler,
createUserProfileResponse,
userAuthToken,
sampleUserAgent,
signedInApiState as coreSignedInApiState,
@ -108,11 +110,15 @@ export function dispatchClientMetadata({
export function dispatchSignInActions({
authToken = userAuthToken(),
userId = 12345,
...otherArgs
} = {}) {
const { store } = dispatchClientMetadata(otherArgs);
store.dispatch(setAuthToken(authToken));
store.dispatch(loadUserProfile({
profile: createUserProfileResponse({ id: userId }),
}));
return {
store,

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -18,6 +18,7 @@ describe('amo createStore', () => {
'reduxAsyncConnect',
'reviews',
'search',
'user',
'viewContext',
]);
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -378,31 +378,6 @@ describe(__filename, () => {
});
});
describe('fetchProfile', () => {
it("requests the user's profile", () => {
const token = userAuthToken();
const user = { username: 'foo', email: 'foo@example.com' };
mockWindow
.expects('fetch')
.withArgs(`${apiHost}/api/v3/accounts/profile/?lang=en-US`, {
body: undefined,
credentials: undefined,
headers: { authorization: `Bearer ${token}` },
method: 'GET',
})
.once()
.returns(createApiResponse({ jsonData: user }));
return api.fetchProfile({ api: { lang: 'en-US', token } })
.then((apiResponse) => {
expect(apiResponse).toEqual({
entities: { users: { foo: user } },
result: 'foo',
});
mockWindow.verify();
});
});
});
describe('startLoginUrl', () => {
const getStartLoginQs = (location) =>
querystring.parse(api.startLoginUrl({ location }).split('?')[1]);

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,43 @@
import * as api from 'core/api';
import { userProfile } from 'core/api/user';
import {
createApiResponse,
createUserProfileResponse,
} from 'tests/unit/helpers';
import { dispatchClientMetadata } from 'tests/unit/amo/helpers';
describe(__filename, () => {
let mockApi;
beforeEach(() => {
mockApi = sinon.mock(api);
});
describe('userProfile', () => {
const mockResponse = () => createApiResponse({
jsonData: createUserProfileResponse(),
});
it('fetches the current user profile', () => {
const state = dispatchClientMetadata().store.getState();
mockApi.expects('callApi')
.withArgs({
auth: true,
endpoint: 'accounts/profile',
state: state.api,
})
.returns(mockResponse());
return userProfile({ api: state.api })
.then(() => mockApi.verify());
});
it('throws an error if api state is missing', () => {
expect(() => {
userProfile({});
}).toThrowError(/api state is required/);
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -7,6 +7,7 @@ import { findDOMNode } from 'react-dom';
import { Provider } from 'react-redux';
import { setAuthToken } from 'core/actions';
import { loadUserProfile } from 'core/reducers/user';
import * as api from 'core/api';
import {
AuthenticateButtonBase,
@ -17,7 +18,11 @@ import {
dispatchClientMetadata,
dispatchSignInActions,
} from 'tests/unit/amo/helpers';
import { getFakeI18nInst, userAuthToken } from 'tests/unit/helpers';
import {
createUserProfileResponse,
getFakeI18nInst,
userAuthToken,
} from 'tests/unit/helpers';
import Icon from 'ui/components/Icon';
@ -65,31 +70,32 @@ describe('<AuthenticateButton />', () => {
const handleLogIn = sinon.spy();
const location = sinon.stub();
const root = render({ isAuthenticated: false, handleLogIn, location });
expect(root.textContent).toEqual('Log in/Sign up');
Simulate.click(root);
expect(handleLogIn.calledWith(location)).toBeTruthy();
sinon.assert.calledWith(handleLogIn, location);
});
it('shows a log out button when authenticated', () => {
const handleLogOut = sinon.spy();
const root = render({ handleLogOut, isAuthenticated: true });
expect(root.textContent).toEqual('Log out');
Simulate.click(root);
expect(handleLogOut.called).toBeTruthy();
sinon.assert.called(handleLogOut);
});
it('updates the location on handleLogIn', () => {
const { store } = dispatchSignInActions();
const _window = { location: '/foo' };
const location = { pathname: '/bar', query: { q: 'wat' } };
const startLoginUrlStub =
sinon.stub(api, 'startLoginUrl').returns('https://a.m.org/login');
const { handleLogIn } = mapStateToProps({
auth: {},
api: { lang: 'en-GB' },
});
const startLoginUrlStub = sinon.stub(api, 'startLoginUrl').returns('https://a.m.org/login');
const { handleLogIn } = mapStateToProps(store.getState());
handleLogIn(location, { _window });
expect(_window.location).toEqual('https://a.m.org/login');
expect(startLoginUrlStub.calledWith({ location })).toBeTruthy();
sinon.assert.calledWith(startLoginUrlStub, { location });
});
it('gets the server to clear cookie and auth token in handleLogOut', () => {
@ -101,7 +107,8 @@ describe('<AuthenticateButton />', () => {
sinon.stub(config, 'get').callsFake((key) => _config[key]);
const { store } = dispatchSignInActions();
store.dispatch(setAuthToken(userAuthToken({ user_id: 99 })));
store.dispatch(setAuthToken(userAuthToken()));
const apiConfig = { token: store.getState().api.token };
expect(apiConfig.token).toBeTruthy();
@ -113,10 +120,14 @@ describe('<AuthenticateButton />', () => {
});
});
it('pulls isAuthenticated from state', () => {
it('retrieves `isAuthenticated` from state', () => {
const { store } = dispatchClientMetadata();
expect(mapStateToProps(store.getState()).isAuthenticated).toEqual(false);
store.dispatch(setAuthToken(userAuthToken({ user_id: 123 })));
store.dispatch(setAuthToken(userAuthToken()));
store.dispatch(loadUserProfile({
profile: createUserProfileResponse(),
}));
expect(mapStateToProps(store.getState()).isAuthenticated).toEqual(true);
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -9,7 +9,8 @@ import LoginPage from 'core/components/LoginPage';
import { logOutUser } from 'core/actions';
import { dispatchSignInActions } from 'tests/unit/amo/helpers';
describe('<LoginRequired />', () => {
describe(__filename, () => {
class MyComponent extends React.Component {
render() {
return <p>Authenticated content.</p>;
@ -41,15 +42,13 @@ describe('<LoginRequired />', () => {
store = dispatchSignInActions().store;
});
it('sets authenticated to true when there is a token', () => {
expect(mapStateToProps(store.getState()))
.toEqual({ authenticated: true });
it('sets authenticated to true', () => {
expect(mapStateToProps(store.getState())).toEqual({ authenticated: true });
});
it('sets authenticated to false when there is not a token', () => {
it('sets authenticated to false', () => {
store.dispatch(logOutUser());
expect(mapStateToProps(store.getState()))
.toEqual({ authenticated: false });
expect(mapStateToProps(store.getState())).toEqual({ authenticated: false });
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -12,14 +12,14 @@ import FakeApp, {
} from 'tests/unit/core/server/fakeApp';
describe('<ServerHtml />', () => {
const _helmentCanUseDOM = Helmet.canUseDOM;
const _helmetCanUseDOM = Helmet.canUseDOM;
beforeEach(() => {
Helmet.canUseDOM = false;
});
afterEach(() => {
Helmet.canUseDOM = _helmentCanUseDOM;
Helmet.canUseDOM = _helmetCanUseDOM;
});
const fakeStore = {

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -1,5 +1,4 @@
import UAParser from 'ua-parser-js';
import base64url from 'base64url';
import * as actions from 'core/actions';
import api, { initialApiState } from 'core/reducers/api';
@ -18,14 +17,13 @@ describe('api reducer', () => {
expect(api({ foo: 'bar' }, actions.setAuthToken(token))).toEqual({
foo: 'bar',
token,
userId: 102345,
});
});
it('clears the auth token on log out', () => {
const state = { ...signedInApiState };
expect(state.token).toBeTruthy();
const expectedState = { ...state, token: null, userId: null };
const expectedState = { ...state, token: null };
expect(api(signedInApiState, actions.logOutUser())).toEqual(expectedState);
});
@ -84,34 +82,6 @@ describe('api reducer', () => {
it('sets auth state based on the token', () => {
const token = userAuthToken({ user_id: 91234 });
expect(setAndReduceToken(token)).toHaveProperty('token', token);
expect(setAndReduceToken(token)).toHaveProperty('userId', 91234);
});
it('throws a parse error for malformed token data', () => {
const token = userAuthToken({}, {
tokenData: '{"malformed JSON"}',
});
expect(() => setAndReduceToken(token))
.toThrowError(/Error parsing auth token "{"malformed JSON"}/);
});
it('throws an error for a token without a data segment', () => {
expect(() => setAndReduceToken('fake-token-without-enough-segments'))
.toThrowError(/Error parsing auth token .* not enough auth token segments/);
});
it('throws an error for an incorrectly encoded data segment', () => {
expect(() => setAndReduceToken('incorrectly-encoded-data-segment:authId:sig'))
.toThrowError(/Error parsing auth token "incorrectly-encoded-data-segment/);
});
it('throws an error for a missing user_id', () => {
// Simulate a token without any user_id data.
const encodedData = base64url.encode('{}');
const tokenData = `${encodedData}:authId:signature`;
const token = userAuthToken({}, { tokenData });
expect(() => setAndReduceToken(token))
.toThrowError(/Error parsing auth token .* user_id is missing/);
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,66 @@
import { logOutUser } from 'core/actions';
import reducer, {
isAuthenticated,
loadUserProfile,
} from 'core/reducers/user';
import { createUserProfileResponse } from 'tests/unit/helpers';
import {
dispatchClientMetadata,
dispatchSignInActions,
} from 'tests/unit/amo/helpers';
describe(__filename, () => {
describe('reducer', () => {
it('initializes properly', () => {
const { id, username } = reducer(undefined);
expect(id).toEqual(null);
expect(username).toEqual(null);
});
it('ignores unrelated actions', () => {
const state = reducer(undefined, loadUserProfile({
profile: createUserProfileResponse({ id: 12345, username: 'john' }),
}));
const newState = reducer(state, { type: 'UNRELATED' });
expect(newState).toEqual(state);
});
it('handles LOAD_USER_PROFILE', () => {
const { id, username } = reducer(undefined, loadUserProfile({
profile: createUserProfileResponse({ id: 1234, username: 'user-test' }),
}));
expect(id).toEqual(1234);
expect(username).toEqual('user-test');
});
it('throws an error when no profile is passed to LOAD_USER_PROFILE', () => {
expect(() => {
reducer(undefined, loadUserProfile({}));
}).toThrowError('The profile parameter is required.');
});
it('handles LOG_OUT_USER', () => {
const state = reducer(undefined, loadUserProfile({
profile: createUserProfileResponse({ id: 12345, username: 'john' }),
}));
const { id, username } = reducer(state, logOutUser());
expect(id).toEqual(null);
expect(username).toEqual(null);
});
});
describe('isAuthenticated selector', () => {
it('returns true when user is authenticated', () => {
const { state } = dispatchSignInActions();
expect(isAuthenticated(state)).toEqual(true);
});
it('returns false when user is not authenticated', () => {
const { state } = dispatchClientMetadata();
expect(isAuthenticated(state)).toEqual(false);
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -0,0 +1,66 @@
import SagaTester from 'redux-saga-tester';
import userSaga from 'core/sagas/user';
import userReducer, { loadUserProfile } from 'core/reducers/user';
import * as api from 'core/api/user';
import apiReducer from 'core/reducers/api';
import { setAuthToken } from 'core/actions';
import { dispatchClientMetadata } from 'tests/unit/amo/helpers';
import { createUserProfileResponse, userAuthToken } from 'tests/unit/helpers';
describe(__filename, () => {
let mockApi;
let sagaTester;
let rootTask;
beforeEach(() => {
mockApi = sinon.mock(api);
const initialState = dispatchClientMetadata().state;
sagaTester = new SagaTester({
initialState,
reducers: {
api: apiReducer,
user: userReducer,
},
});
rootTask = sagaTester.start(userSaga);
});
it('calls the API to fetch user profile after setAuthToken()', async () => {
const profile = createUserProfileResponse();
mockApi
.expects('userProfile')
.once()
.returns(Promise.resolve(profile));
sagaTester.dispatch(setAuthToken(userAuthToken()));
const expectedCalledAction = loadUserProfile({ profile });
await sagaTester.waitFor(expectedCalledAction.type);
mockApi.verify();
const calledAction = sagaTester.getCalledActions()[1];
expect(calledAction).toEqual(expectedCalledAction);
});
it('lets exceptions to be thrown', () => {
const expectedError = new Error('some API error maybe');
mockApi
.expects('userProfile')
.returns(Promise.reject(expectedError));
sagaTester.dispatch(setAuthToken(userAuthToken()));
return rootTask.done
.then(() => {
throw new Error('unexpected success');
})
.catch((error) => {
mockApi.verify();
expect(error).toBe(expectedError);
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -6,12 +6,22 @@ import { reducer as reduxAsyncConnect } from 'redux-connect';
import createSagaMiddleware from 'redux-saga';
import NestedStatus from 'react-nested-status';
import supertest from 'supertest';
import defaultConfig, { util as configUtil } from 'config';
import cheerio from 'cheerio';
import baseServer from 'core/server/base';
import apiReducer from 'core/reducers/api';
import userReducer from 'core/reducers/user';
import userSaga from 'core/sagas/user';
import * as userApi from 'core/api/user';
import FakeApp, { fakeAssets } from 'tests/unit/core/server/fakeApp';
import { createUserProfileResponse, userAuthToken } from 'tests/unit/helpers';
describe('core/server/base', () => {
const _helmentCanUseDOM = Helmet.canUseDOM;
describe(__filename, () => {
let mockUserApi;
const _helmetCanUseDOM = Helmet.canUseDOM;
const defaultStubRoutes = (
<Router>
<Route path="*" component={FakeApp} />
@ -23,33 +33,50 @@ describe('core/server/base', () => {
global.webpackIsomorphicTools = {
assets: () => fakeAssets,
};
mockUserApi = sinon.mock(userApi);
});
afterEach(() => {
Helmet.canUseDOM = _helmentCanUseDOM;
Helmet.canUseDOM = _helmetCanUseDOM;
delete global.webpackIsomorphicTools;
});
function testClient({ stubRoutes = defaultStubRoutes } = {}) {
function createStoreAndSagas() {
const sagaMiddleware = createSagaMiddleware();
return {
store: createStore(
combineReducers({ reduxAsyncConnect }),
// Do not define an initial state.
undefined,
applyMiddleware(sagaMiddleware),
),
sagaMiddleware,
};
function createStoreAndSagas({
reducers = { reduxAsyncConnect, api: apiReducer, user: userReducer },
} = {}) {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
combineReducers(reducers),
// Do not define an initial state.
undefined,
applyMiddleware(sagaMiddleware),
);
return { store, sagaMiddleware };
}
function testClient({
stubRoutes = defaultStubRoutes,
store = null,
sagaMiddleware = null,
appSagas = null,
config = defaultConfig,
} = {}) {
function _createStoreAndSagas() {
if (store === null) {
return createStoreAndSagas();
}
return { store, sagaMiddleware };
}
// eslint-disable-next-line no-empty-function
function* fakeSaga() {}
const app = baseServer(stubRoutes, createStoreAndSagas, {
appSagas: fakeSaga,
const app = baseServer(stubRoutes, _createStoreAndSagas, {
appSagas: appSagas || fakeSaga,
appInstanceName: 'testapp',
config,
});
return supertest(app);
}
@ -83,9 +110,155 @@ describe('core/server/base', () => {
);
const response = await testClient({ stubRoutes })
.get('/en-US/firefox/simulation-of-a-non-existent-page').end();
.get('/en-US/firefox/simulation-of-a-non-existent-page')
.end();
expect(response.statusCode).toEqual(404);
});
it('does not dispatch setAuthToken() if cookie is not found', async () => {
const { store, sagaMiddleware } = createStoreAndSagas();
const response = await testClient({ store, sagaMiddleware })
.get('/en-US/firefox/')
.end();
const { api } = store.getState();
expect(response.statusCode).toEqual(200);
expect(api.token).toBe(null);
});
it('dispatches setAuthToken() if cookie is present', async () => {
const token = userAuthToken();
const { store, sagaMiddleware } = createStoreAndSagas();
const response = await testClient({ store, sagaMiddleware })
.get('/en-US/firefox/')
.set('cookie', `${defaultConfig.get('cookieName')}="${token}"`)
.end();
const { api } = store.getState();
expect(response.statusCode).toEqual(200);
expect(api.token).toEqual(token);
});
it('fetches the user profile when given a token', async () => {
const profile = createUserProfileResponse({ id: 42, username: 'babar' });
mockUserApi
.expects('userProfile')
.once()
.returns(Promise.resolve(profile));
const token = userAuthToken();
const { store, sagaMiddleware } = createStoreAndSagas();
const response = await testClient({ store, sagaMiddleware, appSagas: userSaga })
.get('/en-US/firefox/')
.set('cookie', `${defaultConfig.get('cookieName')}="${token}"`)
.end();
const { api, user } = store.getState();
expect(response.statusCode).toEqual(200);
expect(api.token).toEqual(token);
expect(user.id).toEqual(42);
expect(user.username).toEqual('babar');
mockUserApi.verify();
});
it('returns a 500 error page when retrieving the user profile fails', async () => {
mockUserApi
.expects('userProfile')
.once()
.returns(Promise.reject(new Error('example of an API error')));
const token = userAuthToken();
const { store, sagaMiddleware } = createStoreAndSagas();
const response = await testClient({ store, sagaMiddleware, appSagas: userSaga })
.get('/en-US/firefox/')
.set('cookie', `${defaultConfig.get('cookieName')}="${token}"`)
.end();
expect(response.statusCode).toEqual(500);
});
it('fetches the user profile even when SSR is disabled', async () => {
const profile = createUserProfileResponse({ id: 42, username: 'babar' });
mockUserApi
.expects('userProfile')
.once()
.returns(Promise.resolve(profile));
const token = userAuthToken();
const { store, sagaMiddleware } = createStoreAndSagas();
// We use `cloneDeep()` to allow modifications on the `config` object,
// since a call to `get()` makes it immutable. This is the case in the
// previous test cases (on `defaultConfig`).
const config = configUtil.cloneDeep(defaultConfig);
config.disableSSR = true;
const client = testClient({
store,
sagaMiddleware,
appSagas: userSaga,
config,
});
const response = await client
.get('/en-US/firefox/')
.set('cookie', `${defaultConfig.get('cookieName')}="${token}"`)
.end();
const { api, user } = store.getState();
expect(response.statusCode).toEqual(200);
expect(api.token).toEqual(token);
expect(user.id).toEqual(42);
expect(user.username).toEqual('babar');
mockUserApi.verify();
// Parse the HTML response to retrieve the serialized redux state.
// We do this here to make sure the sagas are actually run, because the
// API token is retrieved from the cookie on the server, therefore the
// user profile too.
const $ = cheerio.load(response.res.text);
const reduxStoreState = JSON.parse($('#redux-store-state').html());
expect(reduxStoreState.api).toEqual(api);
expect(reduxStoreState.user).toEqual(user);
});
it('it serializes the redux state in html', async () => {
const profile = createUserProfileResponse({ id: 42, username: 'babar' });
mockUserApi
.expects('userProfile')
.once()
.returns(Promise.resolve(profile));
const token = userAuthToken();
const { store, sagaMiddleware } = createStoreAndSagas();
const client = testClient({
store,
sagaMiddleware,
appSagas: userSaga,
});
const response = await client
.get('/en-US/firefox/')
.set('cookie', `${defaultConfig.get('cookieName')}="${token}"`)
.end();
const { api, user } = store.getState();
// Parse the HTML response to retrieve the serialized redux state.
const $ = cheerio.load(response.res.text);
const reduxStoreState = JSON.parse($('#redux-store-state').html());
expect(reduxStoreState.api).toEqual(api);
expect(reduxStoreState.user).toEqual(user);
});
});
});

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -276,3 +276,23 @@ export function createApiResponse({
};
return Promise.resolve(response);
}
export function createUserProfileResponse({ id = 123456, username = 'user-1234' } = {}) {
return {
average_addon_rating: null,
biography: '',
created: '2017-08-15T12:01:13Z',
homepage: '',
id,
is_addon_developer: false,
is_artist: false,
location: '',
name: '',
num_addons_listed: 0,
occupation: '',
picture_type: '',
picture_url: `${config.get('amoCDN')}/static/img/zamboni/anon_user.png`,
url: null,
username,
};
}

ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Ρ„Π°ΠΉΠ»

@ -1466,6 +1466,17 @@ cheerio@^0.22.0:
lodash.reject "^4.4.0"
lodash.some "^4.4.0"
cheerio@^1.0.0-rc.2:
version "1.0.0-rc.2"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
dependencies:
css-select "~1.2.0"
dom-serializer "~0.1.0"
entities "~1.1.1"
htmlparser2 "^3.9.1"
lodash "^4.15.0"
parse5 "^3.0.1"
chokidar-cli@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/chokidar-cli/-/chokidar-cli-1.2.0.tgz#8e7f58442273182018be1868e53c22af65a21948"
@ -4921,7 +4932,7 @@ lodash@^3.7.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
lodash@^4.0.0, lodash@^4.1.0, lodash@^4.12.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.17.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.5.1, lodash@^4.6.1, lodash@~4.17.4:
lodash@^4.0.0, lodash@^4.1.0, lodash@^4.12.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.5.1, lodash@^4.6.1, lodash@~4.17.4:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
@ -5704,7 +5715,7 @@ parse5@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
parse5@^3.0.2:
parse5@^3.0.1, parse5@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.2.tgz#05eff57f0ef4577fb144a79f8b9a967a6cc44510"
dependencies: