Skip to content

Commit

Permalink
Implement handling errors from api (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
hisuwh authored Nov 9, 2018
1 parent 14d44ab commit 0efb9e8
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 18 deletions.
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ Its configured with the following options:

```ts
export interface AuthMiddlewareOptions<TState, TAction> {
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)
}
```

Expand Down Expand Up @@ -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.
Expand Down
207 changes: 201 additions & 6 deletions src/authMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,29 +17,30 @@ export class AuthMiddlewareTests {
getState: () => any;
};

private next: () => any;
private next: ISpiedFunction<any, any>;
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<AuthMiddlewareOptions<AuthState, TestAction>> = {}) {
this.store = {
dispatch: () => ({}),
getState: () => ({})
};

SpyOn(this.store, "dispatch");

const authMiddleware = configureAuthMiddleware<AuthState, { payload: { result: AuthPayload } }>({
const defaultOptions = {
actionType: "LOCATION_CHANGED",
getAuthPayload: action => action.payload.result,
getUser: state => state.currentUser,
unauthorizedAction: this.unauthorizedAction,
unauthenticatedAction: this.unauthenticatedAction
});
};

const authMiddleware = configureAuthMiddleware<AuthState, TestAction>({ ...defaultOptions, ...options });

this.next = createFunctionSpy();
this.invoke = (action: any) => {
Expand All @@ -49,13 +57,15 @@ 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);
}

@Test("should allow authorized route")
public shouldAllowAuthorisedRoute() {
this.setup();
SpyOn(this.store, "getState").andReturn({ currentUser: { authenticated: true, roles: ["ADMIN"] }});

const action = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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();
}
}
Loading

0 comments on commit 0efb9e8

Please sign in to comment.