Retrieve user profile on SET_AUTH_TOKEN π₯ (#2989)
* 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:
Π ΠΎΠ΄ΠΈΡΠ΅Π»Ρ
5ba96901db
ΠΠΎΠΌΠΌΠΈΡ
071db107cb
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -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:
|
||||
|
|
ΠΠ°Π³ΡΡΠ·ΠΊΠ°β¦
Π‘ΡΡΠ»ΠΊΠ° Π² Π½ΠΎΠ²ΠΎΠΉ Π·Π°Π΄Π°ΡΠ΅