From 0efb9e8587c3a5b8e6e934f0631930022ce6808f Mon Sep 17 00:00:00 2001 From: Henry Ing-Simmons Date: Fri, 9 Nov 2018 16:44:30 +0000 Subject: [PATCH] Implement handling errors from api (#10) --- README.md | 26 +++-- src/authMiddleware.test.ts | 207 +++++++++++++++++++++++++++++++++++-- src/authMiddleware.ts | 44 +++++++- 3 files changed, 259 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index fcee18d..5de9bfc 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,15 @@ Its configured with the following options: ```ts export interface AuthMiddlewareOptions { - actionType: string; // name of action to monitor - getUser: (state: TState) => User; // method to get the current user from the state - getAuthPayload: (action: TAction) => AuthPayload; // method to get the payload from the action - unauthorizedAction: any; // action to dispatch if unauthorized - unauthenticatedAction?: any; // action to dispatch if unauthenticated (unauthorized will be used if not provided) - unauthorizedError?: string; // error message to throw when unauthorized - unauthenticatedError?: string; // error message to throw when authenticated + actionType: string; // name of action to monitor + getUser: (state: TState) => User; // method to get the current user from the state + getAuthPayload: (action: TAction) => AuthPayload; // method to get the payload from the action + unauthorizedAction: any; // action to dispatch if unauthorized + unauthenticatedAction?: any; // action to dispatch if unauthenticated (unauthorized will be used if undefined) + unauthorizedError?: string; // error message to throw when unauthorized + unauthenticatedError?: string; // error message to throw when authenticated + handleUnauthenticatedApiErrors?: boolean | ShouldHandleError; // handle unauthenticated errors from api requests (see below) + handleUnauthorizedApiErrors?: boolean | ShouldHandleError; // handle unauthorized errors from api requests (see below) } ``` @@ -76,6 +78,16 @@ const routes = { }; ``` +### Handle unauthenticated/unauthorized API responses + +If enabled and the next middleware in the chain calls an API and throws an error then the appropriate actions will be dispatched. + +If `handleUnauthenticatedApiErrors` is true then the reauthorize middleware will look for `error.response.status === 401` and dispatch the `unauthenticatedAction` and throw the `unauthenticatedError`. + +If `unauthorizedAction` is true then the reauthorize middleware will look for `error.response.status === 401` and dispatch the `unauthenticatedAction` and throw the `unauthorizedError`. + +Alternatively you can provide a `ShouldHandleError` function for either which takes the form `(error: any) => boolean` to determine whether we want to treat the error as unauthenticated/unauthorized. + ## `Authorize` component This is a component you can use to hide parts of a component based on authorization. diff --git a/src/authMiddleware.test.ts b/src/authMiddleware.test.ts index a47912e..8fec480 100644 --- a/src/authMiddleware.test.ts +++ b/src/authMiddleware.test.ts @@ -1,6 +1,13 @@ import { Expect, Test, TestCase, TestFixture, SpyOn, Setup, createFunctionSpy } from "alsatian"; -import { configureAuthMiddleware, UNAUTHENTICATED_ERROR, UNAUTHORIZED_ERROR, AuthPayload } from "./authMiddleware"; +import { configureAuthMiddleware, UNAUTHENTICATED_ERROR, UNAUTHORIZED_ERROR, AuthPayload, AuthMiddlewareOptions } from "./authMiddleware"; import { AuthState } from "./model"; +import { ISpiedFunction } from "alsatian/core/spying/spied-function.i"; + +interface TestAction { + payload: { + result: AuthPayload + }; +} @TestFixture("authMiddleware") export class AuthMiddlewareTests { @@ -10,15 +17,14 @@ export class AuthMiddlewareTests { getState: () => any; }; - private next: () => any; + private next: ISpiedFunction; private invoke: (action: any) => any; private error: Error; private unauthorizedAction = { type: "LOCATION_CHANGED", path: "/forbidden" }; private unauthenticatedAction = { type: "LOCATION_CHANGED", path: "/login" }; - @Setup - public setup() { + public setup(options: Partial> = {}) { this.store = { dispatch: () => ({}), getState: () => ({}) @@ -26,13 +32,15 @@ export class AuthMiddlewareTests { SpyOn(this.store, "dispatch"); - const authMiddleware = configureAuthMiddleware({ + const defaultOptions = { actionType: "LOCATION_CHANGED", getAuthPayload: action => action.payload.result, getUser: state => state.currentUser, unauthorizedAction: this.unauthorizedAction, unauthenticatedAction: this.unauthenticatedAction - }); + }; + + const authMiddleware = configureAuthMiddleware({ ...defaultOptions, ...options }); this.next = createFunctionSpy(); this.invoke = (action: any) => { @@ -49,6 +57,7 @@ export class AuthMiddlewareTests { @TestCase({ type: "SOME_ACTION" }) @TestCase({ type: "SOME_OTHER_ACTION" }) public shouldPassThroughActionsItCannotHandle(action: any) { + this.setup(); this.invoke(action); Expect(this.store.dispatch).not.toHaveBeenCalled(); Expect(this.next).toHaveBeenCalledWith(action); @@ -56,6 +65,7 @@ export class AuthMiddlewareTests { @Test("should allow authorized route") public shouldAllowAuthorisedRoute() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -75,6 +85,7 @@ export class AuthMiddlewareTests { @Test("should allow authorized route for string") public shouldAllowAuthorisedRouteForString() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -94,6 +105,7 @@ export class AuthMiddlewareTests { @Test("should not allow unauthorized route") public shouldNotAllowUnauthorizedRoute() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -113,6 +125,7 @@ export class AuthMiddlewareTests { @Test("should not allow route without authorize") public shouldNotAllowRouteWithoutAuthorise() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -131,6 +144,7 @@ export class AuthMiddlewareTests { @Test("should allow route with no authorize if set on parent") public shouldAllowRouteWithoutAuthoriseIfOnParent() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -152,6 +166,7 @@ export class AuthMiddlewareTests { @Test("should not allow route with authorize on that do not match even if parent does") public shouldNotAllowRouteIfAuthoriseDontMatchButParentDoes() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); const action = { @@ -174,6 +189,7 @@ export class AuthMiddlewareTests { @Test("should not allow unauthenticated users") public shouldNotAllowUnauthenticatedUsers() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: false, roles: [] }}); const action = { @@ -193,6 +209,7 @@ export class AuthMiddlewareTests { @Test("should not allow unauthenticated users with authorize undefined") public shouldNotAllowUnauthenticatedUsersWithoutAuthorise() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: false, roles: ["SOMETHING"] }}); const action = { @@ -211,6 +228,7 @@ export class AuthMiddlewareTests { @Test("should allow unauthenticated users with authorize false") public shouldAllowUnauthenticatedUsersWithAuthoriseFalse() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: false, roles: [] }}); const action = { @@ -230,6 +248,7 @@ export class AuthMiddlewareTests { @Test("should allow authenticated users for any role for authorize true") public shouldAllowAuthenticatedUsersWithAnyRoleForAuthoriseTrue() { + this.setup(); SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["SOMETHING"] }}); const action = { @@ -246,4 +265,180 @@ export class AuthMiddlewareTests { Expect(this.next).toHaveBeenCalledWith(action); Expect(this.error).toBeNull(); } + + @Test("should dispatch unauthenticated action if api responds with a 401 and configured") + public shouldDispatchUnauthenticatedFor401() { + this.setup({ + handleUnauthenticatedApiErrors: true + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "Request failed with status code 401", + response: { + status: 401, + statusText: "Unauthorized" + } + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe(UNAUTHENTICATED_ERROR); + Expect(this.store.dispatch).toHaveBeenCalledWith(this.unauthenticatedAction); + } + + @Test("should dispatch unauthenticated action if error matches configured function") + public shouldDispatchUnauthenticatedForCustomError() { + this.setup({ + handleUnauthenticatedApiErrors: error => error.message === "bad error" + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "bad error" + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe(UNAUTHENTICATED_ERROR); + Expect(this.store.dispatch).toHaveBeenCalledWith(this.unauthenticatedAction); + } + + @Test("should not dispatch unauthenticated action if error matches configured function") + public shouldNotDispatchUnauthenticatedForCustomError() { + this.setup({ + handleUnauthenticatedApiErrors: error => error.message === "bad error" + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "another error" + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe("another error"); + Expect(this.store.dispatch).not.toHaveBeenCalled(); + } + + @Test("should dispatch unauthorized action if api responds with a 403 and configured") + public shouldDispatchUnauthorizedFor403() { + this.setup({ + handleUnauthorizedApiErrors: true + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "Request failed with status code 401", + response: { + status: 403, + statusText: "Unauthorized" + } + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe(UNAUTHORIZED_ERROR); + Expect(this.store.dispatch).toHaveBeenCalledWith(this.unauthorizedAction); + } + + @Test("should dispatch unauthorized action if error matches configured function") + public shouldDispatchUnauthorizedForCustomError() { + this.setup({ + handleUnauthorizedApiErrors: error => error.message === "bad error" + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "bad error" + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe(UNAUTHORIZED_ERROR); + Expect(this.store.dispatch).toHaveBeenCalledWith(this.unauthorizedAction); + } + + @Test("should not dispatch unauthorized action if error matches configured function") + public shouldNotDispatchUnauthorizedForCustomError() { + this.setup({ + handleUnauthorizedApiErrors: error => error.message === "bad error" + }); + SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }}); + + const action = { + type: "LOCATION_CHANGED", + payload: { + result: { + authorize: ["ADMIN"] + } + } + }; + + this.next.andCall(() => { + throw { + message: "another error" + }; + }); + + this.invoke(action); + Expect(this.next).toHaveBeenCalledWith(action); + Expect(this.error.message).toBe("another error"); + Expect(this.store.dispatch).not.toHaveBeenCalled(); + } } diff --git a/src/authMiddleware.ts b/src/authMiddleware.ts index bbc17c6..1578593 100644 --- a/src/authMiddleware.ts +++ b/src/authMiddleware.ts @@ -10,6 +10,8 @@ export type AuthPayload = { parent?: AuthPayload }; +export type ShouldHandleError = (error: any) => boolean; + export interface AuthMiddlewareOptions { actionType: string; getUser: (state: TState) => User; @@ -18,6 +20,8 @@ export interface AuthMiddlewareOptions { unauthenticatedAction?: any; unauthorizedError?: string; unauthenticatedError?: string; + handleUnauthenticatedApiErrors?: boolean | ShouldHandleError; + handleUnauthorizedApiErrors?: boolean | ShouldHandleError; } export const configureAuthMiddleware = (options: AuthMiddlewareOptions): Middleware => { @@ -28,11 +32,31 @@ export const configureAuthMiddleware = (options: AuthMiddleware getUser, unauthenticatedAction, unauthorizedAction, + handleUnauthenticatedApiErrors, + handleUnauthorizedApiErrors } = options; const unauthorizedError = options.unauthorizedError || UNAUTHORIZED_ERROR; const unauthenticatedError = options.unauthenticatedError || UNAUTHENTICATED_ERROR; + const isUnauthenticatedError = typeof handleUnauthenticatedApiErrors === "function" + ? handleUnauthenticatedApiErrors + : handleUnauthenticatedApiErrors && ((error: any) => error.response.status === 401); + + const isUnauthorizedError = typeof handleUnauthorizedApiErrors === "function" + ? handleUnauthorizedApiErrors + : handleUnauthorizedApiErrors && ((error: any) => error.response.status === 403); + + const handleUnauthenticated = (api: MiddlewareAPI) => { + api.dispatch(unauthenticatedAction || unauthorizedAction); + throw new Error(unauthenticatedError); + }; + + const handleUnauthorized = (api: MiddlewareAPI) => { + api.dispatch(unauthorizedAction); + throw new Error(unauthorizedError); + }; + return (api: MiddlewareAPI) => (next: Dispatch) => (action: any) => { if (action.type === actionType) { @@ -51,16 +75,26 @@ export const configureAuthMiddleware = (options: AuthMiddleware const authResult = isAuthorized(user, authorize); if (authResult === "Unauthenticated") { - api.dispatch(unauthenticatedAction || unauthorizedAction); - throw new Error(unauthenticatedError); + handleUnauthenticated(api); } if (authResult === "Unauthorized") { - api.dispatch(unauthorizedAction); - throw new Error(unauthorizedError); + handleUnauthorized(api); } } - return next(action); + try { + return next(action); + } catch (e) { + if (isUnauthenticatedError && isUnauthenticatedError(e)) { + handleUnauthenticated(api); + } + + if (isUnauthorizedError && isUnauthorizedError(e)) { + handleUnauthorized(api); + } + + throw e; + } }; };