Make auth reducer side effects free
authorDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 2 Aug 2018 06:38:24 +0000 (08:38 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Thu, 2 Aug 2018 06:38:24 +0000 (08:38 +0200)
Feature #13901

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

13 files changed:
src/common/api/server-api.ts
src/index.tsx
src/services/auth-service/auth-service.ts
src/services/services.ts
src/store/auth/auth-action.ts
src/store/auth/auth-actions.test.ts [new file with mode: 0644]
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/details-panel/details-panel-action.ts
src/store/store.ts
src/views-components/api-token/api-token.tsx
src/views/workbench/workbench.test.tsx
src/views/workbench/workbench.tsx

index 75eabd9af58ef506b392314ce0005520b2270526..a700f274312ab4525a909878c796b2190aa25dc8 100644 (file)
@@ -2,23 +2,18 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import Axios, { AxiosInstance } from "axios";
+import { AxiosInstance } from "axios";
 
-export const API_HOST = process.env.REACT_APP_ARVADOS_API_HOST;
-
-export const authClient: AxiosInstance = Axios.create();
-export const apiClient: AxiosInstance = Axios.create();
-
-export function setServerApiAuthorizationHeader(token: string) {
-    [authClient, apiClient].forEach(client => {
+export function setServerApiAuthorizationHeader(clients: AxiosInstance[], token: string) {
+    clients.forEach(client => {
         client.defaults.headers.common = {
             Authorization: `OAuth2 ${token}`
         };
     });
 }
 
-export function removeServerApiAuthorizationHeader() {
-    [authClient, apiClient].forEach(client => {
+export function removeServerApiAuthorizationHeader(clients: AxiosInstance[]) {
+    clients.forEach(client => {
         delete client.defaults.headers.common.Authorization;
     });
 }
index bf2816dd770de0bc79541470ddd909290ca98d06..49c421382fb835ccf7916eff3aaf7f821735ea87 100644 (file)
@@ -12,7 +12,7 @@ import createBrowserHistory from "history/createBrowserHistory";
 import { configureStore } from "./store/store";
 import { ConnectedRouter } from "react-router-redux";
 import { ApiToken } from "./views-components/api-token/api-token";
-import { authActions } from "./store/auth/auth-action";
+import { initAuth } from "./store/auth/auth-action";
 import { createServices } from "./services/services";
 import { getProjectList } from "./store/project/project-action";
 import { MuiThemeProvider } from '@material-ui/core/styles';
@@ -31,13 +31,12 @@ addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 
 fetchConfig()
     .then(config => {
-
         const history = createBrowserHistory();
         const services = createServices(config.API_HOST);
         const store = configureStore(history, services);
 
-        store.dispatch(authActions.INIT());
-        store.dispatch<any>(getProjectList(services.authService.getUuid()));
+        store.dispatch(initAuth());
+        store.dispatch(getProjectList(services.authService.getUuid()));
 
         const Token = (props: any) => <ApiToken authService={services.authService} {...props}/>;
 
index 551d435f25d208d07a835270b4602a97b228460a..273208cd3c6f3f3b53b94cb9841c8c4841d1ae32 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { API_HOST } from "../../common/api/server-api";
 import { User } from "../../models/user";
 import { AxiosInstance } from "../../../node_modules/axios";
 
index 350128fb8606af535fd8cd2fbcd0450279b6bb37..0d19cee55344c51cc565f32d260786a7f1342d06 100644 (file)
@@ -4,34 +4,49 @@
 
 import { AuthService } from "./auth-service/auth-service";
 import { GroupsService } from "./groups-service/groups-service";
-import { authClient, apiClient } from "../common/api/server-api";
 import { ProjectService } from "./project-service/project-service";
 import { LinkService } from "./link-service/link-service";
 import { FavoriteService } from "./favorite-service/favorite-service";
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "../common/api/common-resource-service";
+import { CollectionResource } from "../models/collection";
+import { Resource } from "../models/resource";
+import Axios from "axios";
 
 export interface ServiceRepository {
+    apiClient: AxiosInstance;
+    authClient: AxiosInstance;
+
     authService: AuthService;
     groupsService: GroupsService;
     projectService: ProjectService;
     linkService: LinkService;
     favoriteService: FavoriteService;
+    collectionService: CommonResourceService<Resource>;
 }
 
 export const createServices = (baseUrl: string): ServiceRepository => {
+    const authClient = Axios.create();
+    const apiClient = Axios.create();
+
     authClient.defaults.baseURL = baseUrl;
-    apiClient.defaults.baseURL = baseUrl + "/arvados/v1";
+    apiClient.defaults.baseURL = `${baseUrl}/arvados/v1`;
 
     const authService = new AuthService(authClient, apiClient);
     const groupsService = new GroupsService(apiClient);
     const projectService = new ProjectService(apiClient);
     const linkService = new LinkService(apiClient);
     const favoriteService = new FavoriteService(linkService, groupsService);
+    const collectionService = new CommonResourceService<CollectionResource>(apiClient, "collections");
 
     return {
+        apiClient,
+        authClient,
         authService,
         groupsService,
         projectService,
         linkService,
-        favoriteService
+        favoriteService,
+        collectionService
     };
 };
index 8b268cce5d5cb83b68f7a73e9a4b66936f4dd8c3..839134cf95ad73d039bac73a3a7f6d49ac8f153e 100644 (file)
@@ -7,12 +7,13 @@ import { Dispatch } from "redux";
 import { User } from "../../models/user";
 import { RootState } from "../store";
 import { ServiceRepository } from "../../services/services";
+import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
     LOGIN: {},
     LOGOUT: {},
-    INIT: {},
+    INIT: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>()
 }, {
@@ -20,11 +21,42 @@ export const authActions = unionize({
     value: 'payload'
 });
 
+export const initAuth = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const user = services.authService.getUser();
+    const token = services.authService.getApiToken();
+    if (token) {
+        setServerApiAuthorizationHeader([services.authClient, services.apiClient], token);
+    }
+    if (token && user) {
+        dispatch(authActions.INIT({ user, token }));
+    }
+};
+
+export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.saveApiToken(token);
+    setServerApiAuthorizationHeader([services.authClient, services.apiClient], token);
+    dispatch(authActions.SAVE_API_TOKEN(token));
+};
+
+export const login = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.login();
+    dispatch(authActions.LOGIN());
+};
+
+export const logout = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.removeApiToken();
+    services.authService.removeUser();
+    removeServerApiAuthorizationHeader([services.authClient, services.apiClient]);
+    services.authService.logout();
+    dispatch(authActions.LOGOUT());
+};
+
 export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<User> => {
     dispatch(authActions.USER_DETAILS_REQUEST());
-    return services.authService.getUserDetails().then(details => {
-        dispatch(authActions.USER_DETAILS_SUCCESS(details));
-        return details;
+    return services.authService.getUserDetails().then(user => {
+        services.authService.saveUser(user);
+        dispatch(authActions.USER_DETAILS_SUCCESS(user));
+        return user;
     });
 };
 
diff --git a/src/store/auth/auth-actions.test.ts b/src/store/auth/auth-actions.test.ts
new file mode 100644 (file)
index 0000000..dc399cf
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { authReducer, AuthState } from "./auth-reducer";
+import { AuthAction, authActions, initAuth } from "./auth-action";
+import {
+    API_TOKEN_KEY,
+    USER_EMAIL_KEY,
+    USER_FIRST_NAME_KEY,
+    USER_LAST_NAME_KEY,
+    USER_OWNER_UUID_KEY,
+    USER_UUID_KEY
+} from "../../services/auth-service/auth-service";
+
+import 'jest-localstorage-mock';
+import { createServices } from "../../services/services";
+import { configureStore, RootStore } from "../store";
+import createBrowserHistory from "../../../node_modules/@types/history/createBrowserHistory";
+
+describe('auth-actions', () => {
+    let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+    let store: RootStore;
+
+    beforeEach(() => {
+        store = configureStore(createBrowserHistory(), createServices("/arvados/v1"));
+        localStorage.clear();
+        reducer = authReducer(createServices("/arvados/v1"));
+    });
+
+    it('should initialise state with user and api token from local storage', () => {
+
+        localStorage.setItem(API_TOKEN_KEY, "token");
+        localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
+        localStorage.setItem(USER_FIRST_NAME_KEY, "John");
+        localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
+        localStorage.setItem(USER_UUID_KEY, "uuid");
+        localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
+
+        store.dispatch(initAuth());
+
+        expect(store.getState().auth).toEqual({
+            apiToken: "token",
+            user: {
+                email: "test@test.com",
+                firstName: "John",
+                lastName: "Doe",
+                uuid: "uuid",
+                ownerUuid: "ownerUuid"
+            }
+        });
+    });
+
+    /*
+    it('should fire external url to login', () => {
+        const initialState = undefined;
+        window.location.assign = jest.fn();
+        reducer(initialState, authActions.LOGIN());
+        expect(window.location.assign).toBeCalledWith(
+            `/login?return_to=${window.location.protocol}//${window.location.host}/token`
+        );
+    });
+
+    it('should fire external url to logout', () => {
+        const initialState = undefined;
+        window.location.assign = jest.fn();
+        reducer(initialState, authActions.LOGOUT());
+        expect(window.location.assign).toBeCalledWith(
+            `/logout?return_to=${location.protocol}//${location.host}`
+        );
+    });
+    */
+});
index a7419286a2d1aea4ebd258f8e052663bd9626ab4..0e05263d4301a7b3d94f0408670da9a9cc019fd5 100644 (file)
@@ -4,14 +4,6 @@
 
 import { authReducer, AuthState } from "./auth-reducer";
 import { AuthAction, authActions } from "./auth-action";
-import {
-    API_TOKEN_KEY,
-    USER_EMAIL_KEY,
-    USER_FIRST_NAME_KEY,
-    USER_LAST_NAME_KEY,
-    USER_OWNER_UUID_KEY,
-    USER_UUID_KEY
-} from "../../services/auth-service/auth-service";
 
 import 'jest-localstorage-mock';
 import { createServices } from "../../services/services";
@@ -24,39 +16,23 @@ describe('auth-reducer', () => {
         reducer = authReducer(createServices("/arvados/v1"));
     });
 
-    it('should return default state on initialisation', () => {
+    it('should correctly initialise state', () => {
         const initialState = undefined;
-        const state = reducer(initialState, authActions.INIT());
-        expect(state).toEqual({
-            apiToken: undefined,
-            user: undefined
-        });
-    });
-
-    it('should read user and api token from local storage on init if they are there', () => {
-        const initialState = undefined;
-
-        localStorage.setItem(API_TOKEN_KEY, "token");
-        localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
-        localStorage.setItem(USER_FIRST_NAME_KEY, "John");
-        localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
-        localStorage.setItem(USER_UUID_KEY, "uuid");
-        localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
-
-        const state = reducer(initialState, authActions.INIT());
+        const user = {
+            email: "test@test.com",
+            firstName: "John",
+            lastName: "Doe",
+            uuid: "uuid",
+            ownerUuid: "ownerUuid"
+        };
+        const state = reducer(initialState, authActions.INIT({user, token: "token"}));
         expect(state).toEqual({
             apiToken: "token",
-            user: {
-                email: "test@test.com",
-                firstName: "John",
-                lastName: "Doe",
-                uuid: "uuid",
-                ownerUuid: "ownerUuid"
-            }
+            user
         });
     });
 
-    it('should store token in local storage', () => {
+    it('should save api token', () => {
         const initialState = undefined;
 
         const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
@@ -64,8 +40,6 @@ describe('auth-reducer', () => {
             apiToken: "token",
             user: undefined
         });
-
-        expect(localStorage.getItem(API_TOKEN_KEY)).toBe("token");
     });
 
     it('should set user details on success fetch', () => {
@@ -90,25 +64,5 @@ describe('auth-reducer', () => {
                 ownerUuid: "ownerUuid",
             }
         });
-
-        expect(localStorage.getItem(API_TOKEN_KEY)).toBe("token");
-    });
-
-    it('should fire external url to login', () => {
-        const initialState = undefined;
-        window.location.assign = jest.fn();
-        reducer(initialState, authActions.LOGIN());
-        expect(window.location.assign).toBeCalledWith(
-            `/login?return_to=${window.location.protocol}//${window.location.host}/token`
-        );
-    });
-
-    it('should fire external url to logout', () => {
-        const initialState = undefined;
-        window.location.assign = jest.fn();
-        reducer(initialState, authActions.LOGOUT());
-        expect(window.location.assign).toBeCalledWith(
-            `/logout?return_to=${location.protocol}//${location.host}`
-        );
     });
 });
index e3f968a8d4bbe91f781a080c231a3cea8180d2d2..ac9ecbe588c2c4aeac36c5cf55494112175c042b 100644 (file)
@@ -15,31 +15,18 @@ export interface AuthState {
 export const authReducer = (services: ServiceRepository) => (state: AuthState = {}, action: AuthAction) => {
     return authActions.match(action, {
         SAVE_API_TOKEN: (token: string) => {
-            services.authService.saveApiToken(token);
-            setServerApiAuthorizationHeader(token);
             return {...state, apiToken: token};
         },
-        INIT: () => {
-            const user = services.authService.getUser();
-            const token = services.authService.getApiToken();
-            if (token) {
-                setServerApiAuthorizationHeader(token);
-            }
-            return {user, apiToken: token};
+        INIT: ({ user, token }) => {
+            return { user, apiToken: token };
         },
         LOGIN: () => {
-            services.authService.login();
             return state;
         },
         LOGOUT: () => {
-            services.authService.removeApiToken();
-            services.authService.removeUser();
-            removeServerApiAuthorizationHeader();
-            services.authService.logout();
             return {...state, apiToken: undefined};
         },
         USER_DETAILS_SUCCESS: (user: User) => {
-            services.authService.saveUser(user);
             return {...state, user};
         },
         default: () => state
index 03212b9fc915bacfbc08f22d367d393b1f26b14b..cb5a709e3ffe861c36656b84f726b25cc367cf0e 100644 (file)
@@ -5,8 +5,9 @@
 import { unionize, ofType, UnionOf } from "unionize";
 import { CommonResourceService } from "../../common/api/common-resource-service";
 import { Dispatch } from "redux";
-import { apiClient } from "../../common/api/server-api";
 import { Resource, ResourceKind } from "../../models/resource";
+import { RootState } from "../store";
+import { ServiceRepository } from "../../services/services";
 
 export const detailsPanelActions = unionize({
     TOGGLE_DETAILS_PANEL: ofType<{}>(),
@@ -17,23 +18,23 @@ export const detailsPanelActions = unionize({
 export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
 
 export const loadDetails = (uuid: string, kind: ResourceKind) =>
-    (dispatch: Dispatch) => {
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(detailsPanelActions.LOAD_DETAILS({ uuid, kind }));
-        getService(kind)
+        getService(services, kind)
             .get(uuid)
             .then(project => {
                 dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item: project }));
             });
     };
 
-const getService = (kind: ResourceKind) => {
+const getService = (services: ServiceRepository, kind: ResourceKind) => {
     switch (kind) {
         case ResourceKind.PROJECT:
-            return new CommonResourceService(apiClient, "groups");
+            return services.projectService;
         case ResourceKind.COLLECTION:
-            return new CommonResourceService(apiClient, "collections");
+            return services.collectionService;
         default:
-            return new CommonResourceService(apiClient, "");
+            return services.projectService;
     }
 };
 
index cf07d6df89c74eb2ab73e6e44d15a5a511199483..2154b3b0d327c3b21d9db300494e53674ab45860 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createStore, applyMiddleware, compose, Middleware, combineReducers } from 'redux';
+import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux';
 import { routerMiddleware, routerReducer, RouterState } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
@@ -37,7 +37,9 @@ export interface RootState {
     snackbar: SnackbarState;
 }
 
-export function configureStore(history: History, services: ServiceRepository) {
+export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
+
+export function configureStore(history: History, services: ServiceRepository): RootStore {
     const rootReducer = combineReducers({
         auth: authReducer(services),
         projects: projectsReducer,
index 51e3ad1fbc052bb980619057e2ffa84f514bc8b4..0ae41c657e34b925acb909084b848b9bb4cce31c 100644 (file)
@@ -5,7 +5,7 @@
 import { Redirect, RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
-import { authActions, getUserDetails } from "../../store/auth/auth-action";
+import { getUserDetails, saveApiToken } from "../../store/auth/auth-action";
 import { getProjectList } from "../../store/project/project-action";
 import { getUrlParameter } from "../../common/url";
 import { AuthService } from "../../services/auth-service/auth-service";
@@ -19,7 +19,7 @@ export const ApiToken = connect()(
         componentDidMount() {
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
-            this.props.dispatch(authActions.SAVE_API_TOKEN(apiToken));
+            this.props.dispatch(saveApiToken(apiToken));
             this.props.dispatch<any>(getUserDetails()).then(() => {
                 const rootUuid = this.props.authService.getRootUuid();
                 this.props.dispatch(getProjectList(rootUuid));
index 48ea2de9e046e726bacd742484ba58dc4cb80cf1..8e0b353f6558f26200de817d8e9edb3586361a0d 100644 (file)
@@ -17,7 +17,7 @@ const history = createBrowserHistory();
 
 it('renders without crashing', () => {
     const div = document.createElement('div');
-    const store = configureStore(createBrowserHistory(), createServices());
+    const store = configureStore(createBrowserHistory(), createServices("/arvados/v1"));
     ReactDOM.render(
         <MuiThemeProvider theme={CustomTheme}>
             <Provider store={store}>
index b0fe4b3e7668485e9854bea2fd57192d77e33510..aa27adbe1eb5c2408e33948abd5ff1e6d9874eeb 100644 (file)
@@ -7,7 +7,7 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import Drawer from '@material-ui/core/Drawer';
 import { connect, DispatchProp } from "react-redux";
 import { Route, Switch, RouteComponentProps } from "react-router";
-import { authActions } from "../../store/auth/auth-action";
+import { authActions, login, logout } from "../../store/auth/auth-action";
 import { User } from "../../models/user";
 import { RootState } from "../../store/store";
 import { MainAppBar, MainAppBarActionProps, MainAppBarMenuItem } from '../../views-components/main-app-bar/main-app-bar';
@@ -139,7 +139,7 @@ export const Workbench = withStyles(styles)(
                         },
                         {
                             label: "Logout",
-                            action: () => this.props.dispatch(authActions.LOGOUT())
+                            action: () => this.props.dispatch<any>(logout())
                         },
                         {
                             label: "My account",
@@ -155,7 +155,7 @@ export const Workbench = withStyles(styles)(
                     anonymousMenu: [
                         {
                             label: "Sign in",
-                            action: () => this.props.dispatch(authActions.LOGIN())
+                            action: () => this.props.dispatch<any>(login())
                         }
                     ]
                 }