Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK-1577] [SDK-1578] Add login functionality #5

Merged
merged 8 commits into from
May 7, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@ module.exports = {
extends: [
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/camelcase': 'off',
},
overrides: [
{
files: ['*.test.tsx'],
rules: {
'@typescript-eslint/ban-ts-ignore': 'off',
adamjmcgrath marked this conversation as resolved.
Show resolved Hide resolved
},
},
],
};
19 changes: 19 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const handleRedirectCallback = jest
.fn()
.mockResolvedValue({ appState: {} });
export const getTokenSilently = jest.fn();
export const getUser = jest.fn();
export const isAuthenticated = jest.fn().mockResolvedValue(false);
export const loginWithRedirect = jest.fn();
export const logout = jest.fn();

export const Auth0Client = jest.fn().mockImplementation(() => {
return {
handleRedirectCallback,
getTokenSilently,
getUser,
isAuthenticated,
loginWithRedirect,
logout,
};
});
188 changes: 188 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { useContext } from 'react';
import Auth0Context from '../src/auth-context';
import { renderHook } from '@testing-library/react-hooks';
import {
Auth0Client,
// @ts-ignore
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to use manual mocks to mock the Auth0Client class https://jestjs.io/docs/en/es6-class-mocks#manual-mock

This is so that I can export the methods as individual mocked methods, so I can do:

expect(getTokenSilently).toHaveBeenCalled()

Rather than use automatic mocks and do this everywhere:

expect((Auth0Client as any as jest.Mock).mock.instances[0].getTokenSilently).toHaveBeenCalled()

Since TS doesn't understand mocking in Jest, we do have to ts-ignore the additional imports

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something to change in this PR, but I think we need to think about why we're mocking the SPA SDK and whether that's the right thing, instead of mocking the resulting HTTP calls (or handling them with some kind of test proxy).

For the same reason we don't test or mock private methods, the fact we're using the SDK internally should just be that - an internal detail. HTTP calls out to the auth service are where the 'seams' are and it's the seams that should ideally be mocked. If we go down the path of mocking dependencies like these, we can lose sight of what happens when those dependencies change if we're always mocking out the ideal scenario.

getTokenSilently,
// @ts-ignore
isAuthenticated,
// @ts-ignore
getUser,
// @ts-ignore
handleRedirectCallback,
// @ts-ignore
loginWithRedirect,
// @ts-ignore
logout,
} from '@auth0/auth0-spa-js';
import { createWrapper } from './helpers';

describe('AuthProvider', () => {
it('should provide the AuthProvider result', async () => {
const wrapper = createWrapper();
const { result, waitForNextUpdate } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current).toBeDefined();
await waitForNextUpdate();
});

it('should configure an instance of the Auth0Client', async () => {
const opts = {
client_id: 'foo',
domain: 'bar',
};
const wrapper = createWrapper(opts);
const { waitForNextUpdate } = renderHook(() => useContext(Auth0Context), {
wrapper,
});
expect(Auth0Client).toHaveBeenCalledWith(opts);
await waitForNextUpdate();
});

it('should get token silently when logged out', async () => {
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
adamjmcgrath marked this conversation as resolved.
Show resolved Hide resolved
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(getTokenSilently).toHaveBeenCalled();
expect(result.current.isAuthenticated).toBe(false);
});

it('should get token silently when logged in', async () => {
isAuthenticated.mockResolvedValue(true);
getUser.mockResolvedValue('__test_user__');
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(getTokenSilently).toHaveBeenCalled();
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBe('__test_user__');
});

it('should handle login_required errors when getting token', async () => {
getTokenSilently.mockRejectedValue({ error: 'login_required' });
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(getTokenSilently).toHaveBeenCalled();
expect(result.current.error).toBeUndefined();
expect(result.current.isAuthenticated).toBe(false);
});

it('should handle other errors when getting token', async () => {
getTokenSilently.mockRejectedValue({ error: '__test_error__' });
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(getTokenSilently).toHaveBeenCalled();
expect(result.current.error).toStrictEqual({ error: '__test_error__' });
expect(result.current.isAuthenticated).toBe(false);
});

it('should handle redirect callback success and clear the url', async () => {
window.history.pushState(
{},
document.title,
'/?code=__test_code__&state=__test_state__'
);
expect(window.location.href).toBe(
'https://www.example.com/?code=__test_code__&state=__test_state__'
);
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(handleRedirectCallback).toHaveBeenCalled();
expect(window.location.href).toBe('https://www.example.com/');
});

it('should handle redirect callback errors', async () => {
window.history.pushState({}, document.title, '/?error=__test_error__');
handleRedirectCallback.mockRejectedValue('__test_error__');
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(handleRedirectCallback).toHaveBeenCalled();
expect(result.current.error).toStrictEqual('__test_error__');
});

it('should handle redirect and call a custom handler', async () => {
window.history.pushState(
{},
document.title,
'/?code=__test_code__&state=__test_state__'
);
handleRedirectCallback.mockResolvedValue({ appState: { foo: 'bar' } });
const onRedirectCallback = jest.fn();
const wrapper = createWrapper({
onRedirectCallback,
});
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar' });
});

it('should provide a login method', async () => {
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitForNextUpdate();
expect(result.current.login).toBeInstanceOf(Function);
await result.current.login({ redirect_uri: '__redirect_uri__' });
expect(loginWithRedirect).toHaveBeenCalledWith({
redirect_uri: '__redirect_uri__',
});
});

it('should provide a logout method', async () => {
const wrapper = createWrapper();
const { waitForNextUpdate, result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitForNextUpdate();
expect(result.current.logout).toBeInstanceOf(Function);
await result.current.logout({ returnTo: '__return_to__' });
expect(logout).toHaveBeenCalledWith({
returnTo: '__return_to__',
});
});
});
42 changes: 42 additions & 0 deletions __tests__/auth-reducer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { reducer } from '../src/reducer';
import { initialAuthState } from '../src/auth-state';

describe('reducer', () => {
it('should initialise when authenticated', async () => {
const payload = {
isAuthenticated: true,
user: 'Bob',
};
expect(
reducer(initialAuthState, { type: 'INITIALISED', ...payload })
).toEqual({
...initialAuthState,
isLoading: false,
...payload,
});
});

it('should initialise when not authenticated', async () => {
const payload = {
isAuthenticated: false,
};
expect(
reducer(initialAuthState, { type: 'INITIALISED', ...payload })
).toEqual({
...initialAuthState,
isLoading: false,
...payload,
});
});

it('should handle error state', async () => {
const payload = {
error: new Error('__test_error__'),
};
expect(reducer(initialAuthState, { type: 'ERROR', ...payload })).toEqual({
...initialAuthState,
isLoading: false,
...payload,
});
});
});
29 changes: 0 additions & 29 deletions __tests__/auth0-provider.test.tsx

This file was deleted.

7 changes: 4 additions & 3 deletions __tests__/helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Auth0ClientOptions } from '@auth0/auth0-spa-js';
import React, { PropsWithChildren } from 'react';
import Auth0Provider from '../src/auth0-provider';
import AuthProvider from '../src/auth-provider';
adamjmcgrath marked this conversation as resolved.
Show resolved Hide resolved

export const createWrapper = ({
client_id = '__test_client_id__',
domain = '__test_domain__',
...opts
}: Partial<Auth0ClientOptions> = {}) => ({
children,
}: PropsWithChildren<{}>): JSX.Element => (
<Auth0Provider domain={domain} client_id={client_id}>
<AuthProvider domain={domain} client_id={client_id} {...opts}>
{children}
</Auth0Provider>
</AuthProvider>
);
24 changes: 24 additions & 0 deletions __tests__/use-auth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import useAuth from '../src/use-auth';
import { renderHook } from '@testing-library/react-hooks';
import { createWrapper } from './helpers';

describe('useAuth', () => {
it('should provide the auth context', async () => {
const wrapper = createWrapper();
const {
result: { current },
waitForNextUpdate,
} = renderHook(useAuth, { wrapper });
await waitForNextUpdate();
expect(current).toBeDefined();
});

it('should throw with no provider', () => {
const {
result: { current },
} = renderHook(useAuth);
expect(current.login).toThrowError(
'You forgot to wrap your component in <AuthProvider>.'
);
});
});
16 changes: 0 additions & 16 deletions __tests__/use-auth0.test.tsx

This file was deleted.

Loading