diff --git a/src/frontend/index.ts b/src/frontend/index.ts index 1ef1e3a93..28f5532b7 100644 --- a/src/frontend/index.ts +++ b/src/frontend/index.ts @@ -1,5 +1,12 @@ export { default as ConfigProvider, ConfigProviderProps, useConfig } from './use-config'; -export { default as UserProvider, UserProviderProps, UserProfile, UserContext, useUser } from './use-user'; +export { + default as UserProvider, + UserProviderProps, + UserProfile, + UserContext, + RequestError, + useUser +} from './use-user'; export { default as withPageAuthRequired, WithPageAuthRequired, diff --git a/src/frontend/use-user.tsx b/src/frontend/use-user.tsx index b1926055a..5a03f0bfa 100644 --- a/src/frontend/use-user.tsx +++ b/src/frontend/use-user.tsx @@ -31,6 +31,28 @@ export type UserContext = { checkSession: () => Promise; }; +/** + * The error thrown by the user fetcher. + * + * The `status` property contains the status code of the response. It is `0` when the request fails, e.g. due to being + * offline. + * + * This error is not thrown when the status code of the response is `401`, because that means the user is not + * authenticated. + * + * @category Client + */ +export class RequestError extends Error { + public status: number; + + /* istanbul ignore next */ + constructor(status: number) { + super(); + this.status = status; + Object.setPrototypeOf(this, RequestError.prototype); + } +} + /** * @ignore */ @@ -142,8 +164,18 @@ type UserProviderState = { * @ignore */ const userFetcher: UserFetcher = async (url) => { - const response = await fetch(url); - return response.ok ? response.json() : undefined; + let response; + try { + response = await fetch(url); + } catch { + throw new RequestError(0); // Network error + } + if (response.ok) { + return response.json(); + } else if (response.status === 401) { + return undefined; + } + throw new RequestError(response.status); }; export default ({ @@ -159,9 +191,8 @@ export default ({ try { const user = await fetcher(profileUrl); setState((previous) => ({ ...previous, user, error: undefined })); - } catch (_e) { - const error = new Error(`The request to ${profileUrl} failed`); - setState((previous) => ({ ...previous, user: undefined, error })); + } catch (error) { + setState((previous) => ({ ...previous, error: error as Error })); } }, [profileUrl]); diff --git a/src/index.browser.ts b/src/index.browser.ts index 7fbd1fd1b..b5be28679 100644 --- a/src/index.browser.ts +++ b/src/index.browser.ts @@ -7,6 +7,7 @@ export { UserProviderProps, UserProfile, UserContext, + RequestError, useUser, withPageAuthRequired, WithPageAuthRequired diff --git a/src/index.ts b/src/index.ts index 22e4405f0..c5ec78091 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,7 @@ export { UserProviderProps, UserProfile, UserContext, + RequestError, useUser, WithPageAuthRequiredProps } from './frontend'; diff --git a/tests/fixtures/frontend.tsx b/tests/fixtures/frontend.tsx index bb9599ffc..222964b5a 100644 --- a/tests/fixtures/frontend.tsx +++ b/tests/fixtures/frontend.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { UserProvider, UserProviderProps, UserProfile } from '../../src'; -import { ConfigProvider, ConfigProviderProps } from '../../src/frontend'; +import { ConfigProvider, ConfigProviderProps, RequestError } from '../../src/frontend'; type FetchUserMock = { ok: boolean; - json?: () => Promise; + status: number; + json?: () => Promise; }; export const user: UserProfile = { @@ -32,17 +33,30 @@ export const withUserProvider = ({ export const fetchUserMock = (): Promise => { return Promise.resolve({ ok: true, + status: 200, json: () => Promise.resolve(user) }); }; -export const fetchUserUnsuccessfulMock = (): Promise => { +export const fetchUserUnauthorizedMock = (): Promise => { return Promise.resolve({ - ok: false + ok: false, + status: 401, + json: () => Promise.resolve(undefined) }); }; -export const fetchUserErrorMock = (): Promise => Promise.reject(new Error('Error')); +export const fetchUserErrorMock = (): Promise => { + return Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve(undefined) + }); +}; + +export const fetchUserNetworkErrorMock = (): Promise => { + return Promise.reject(new RequestError(0)); +}; export const withConfigProvider = ({ loginUrl }: ConfigProviderProps = {}): React.ComponentType => { return (props: any): React.ReactElement => ; diff --git a/tests/fixtures/test-app/next-env.d.ts b/tests/fixtures/test-app/next-env.d.ts index 4f11a03dc..9bc3dd46b 100644 --- a/tests/fixtures/test-app/next-env.d.ts +++ b/tests/fixtures/test-app/next-env.d.ts @@ -1,4 +1,5 @@ /// +/// /// // NOTE: This file should not be edited diff --git a/tests/frontend/use-user.test.tsx b/tests/frontend/use-user.test.tsx index 1ac9ad89b..2219b357d 100644 --- a/tests/frontend/use-user.test.tsx +++ b/tests/frontend/use-user.test.tsx @@ -2,19 +2,105 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { fetchUserMock, - fetchUserUnsuccessfulMock, fetchUserErrorMock, + fetchUserNetworkErrorMock, + fetchUserUnauthorizedMock, withUserProvider, user } from '../fixtures/frontend'; -import { useConfig } from '../../src/frontend'; +import { RequestError, useConfig } from '../../src/frontend'; import { useUser, UserContext } from '../../src'; import React from 'react'; describe('context wrapper', () => { afterEach(() => delete (global as any).fetch); - test('should fetch the user', async () => { + test('should use the default profile url', async () => { + const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); + (global as any).fetch = fetchSpy; + const { result, waitForValueToChange } = renderHook(() => useUser(), { + wrapper: withUserProvider() + }); + + await waitForValueToChange(() => result.current.isLoading); + expect(fetchSpy).toHaveBeenCalledWith('/api/auth/me'); + }); + + test('should accept a custom profile url', async () => { + const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); + (global as any).fetch = fetchSpy; + const { result, waitForValueToChange } = renderHook(() => useUser(), { + wrapper: withUserProvider({ profileUrl: '/api/custom-url' }) + }); + + await waitForValueToChange(() => result.current.isLoading); + expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url'); + }); + + test('should use a custom profile url from an environment variable', async () => { + process.env.NEXT_PUBLIC_AUTH0_PROFILE = '/api/custom-url'; + const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); + (global as any).fetch = fetchSpy; + const { result, waitForValueToChange } = renderHook(() => useUser(), { + wrapper: withUserProvider() + }); + + await waitForValueToChange(() => result.current.isLoading); + expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url'); + delete process.env.NEXT_PUBLIC_AUTH0_PROFILE; + }); + + test('should accept a custom login url', async () => { + const { result } = renderHook(() => useConfig(), { + wrapper: withUserProvider({ user, loginUrl: '/api/custom-url' }) + }); + + expect(result.current.loginUrl).toEqual('/api/custom-url'); + }); + + test('should accept a custom fetcher', async () => { + const fetchSpy = jest.fn(); + (global as any).fetch = fetchSpy; + + const returnValue = 'foo'; + const customFetcher = jest.fn().mockResolvedValue(returnValue); + + const { result, waitForValueToChange } = renderHook(() => useUser(), { + wrapper: withUserProvider({ fetcher: customFetcher }) + }); + + await waitForValueToChange(() => result.current.isLoading); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(customFetcher).toHaveBeenCalledWith('/api/auth/me'); + expect(result.current.user).toBe(returnValue); + }); +}); + +describe('user provider', () => { + test('should throw an error when the app is not wrapped in UserProvider', async () => { + const expectedError = 'You forgot to wrap your app in '; + const { result } = renderHook(() => useUser()); + + expect(() => result.current.user).toThrowError(expectedError); + expect(() => result.current.error).toThrowError(expectedError); + expect(() => result.current.isLoading).toThrowError(expectedError); + expect(result.current.checkSession).toThrowError(expectedError); + }); + + test('should be able to stub UserProvider with UserContext.Provider', async () => { + const { result } = renderHook(() => useUser(), { + wrapper: (props: any): React.ReactElement => + }); + + expect(result.current.user).toEqual({ foo: 'bar' }); + }); +}); + +describe('hook', () => { + afterEach(() => delete (global as any).fetch); + + test('should provide the fetched user', async () => { (global as any).fetch = fetchUserMock; const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); @@ -29,8 +115,16 @@ describe('context wrapper', () => { expect(result.current.isLoading).toEqual(false); }); - test('should discard the response when the status code is not successful', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + test('should provide the existing user', async () => { + const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) }); + + expect(result.current.user).toEqual(user); + expect(result.current.error).toBeUndefined(); + expect(result.current.isLoading).toEqual(false); + }); + + test('should provide no user when the status code is 401', async () => { + (global as any).fetch = fetchUserUnauthorizedMock; const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); expect(result.current.user).toBeUndefined(); @@ -44,8 +138,8 @@ describe('context wrapper', () => { expect(result.current.isLoading).toEqual(false); }); - test('should fail to fetch the user', async () => { - (global as any).fetch = fetchUserErrorMock; + test('should provide an error when the request fails', async () => { + (global as any).fetch = fetchUserNetworkErrorMock; const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); expect(result.current.user).toBeUndefined(); @@ -55,63 +149,52 @@ describe('context wrapper', () => { await waitForValueToChange(() => result.current.isLoading); expect(result.current.user).toBeUndefined(); - expect(result.current.error).toEqual(new Error('The request to /api/auth/me failed')); + expect(result.current.error).toBeInstanceOf(RequestError); + expect((result.current.error as RequestError).status).toEqual(0); expect(result.current.isLoading).toEqual(false); }); - test('should provide the existing user', async () => { - const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) }); + test('should provide an error when the status code is not successful', async () => { + const status = 400; + (global as any).fetch = () => Promise.resolve({ ok: false, status }); - expect(result.current.user).toEqual(user); - expect(result.current.error).toBeUndefined(); - expect(result.current.isLoading).toEqual(false); - }); + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); - test('should use the default profile url', async () => { - const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); - (global as any).fetch = fetchSpy; - const { result, waitForValueToChange } = renderHook(() => useUser(), { - wrapper: withUserProvider() - }); + expect(result.current.user).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.isLoading).toEqual(true); await waitForValueToChange(() => result.current.isLoading); - expect(fetchSpy).toHaveBeenCalledWith('/api/auth/me'); + + expect(result.current.user).toBeUndefined(); + expect(result.current.error).toBeInstanceOf(RequestError); + expect((result.current.error as RequestError).status).toEqual(status); + expect(result.current.isLoading).toEqual(false); }); - test('should accept a custom profile url', async () => { - const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); - (global as any).fetch = fetchSpy; - const { result, waitForValueToChange } = renderHook(() => useUser(), { - wrapper: withUserProvider({ profileUrl: '/api/custom-url' }) - }); + test('should provide an error when a custom fetcher throws an error', async () => { + const error = new Error(); + const fetcher = jest.fn().mockRejectedValue(error); - await waitForValueToChange(() => result.current.isLoading); - expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url'); - }); + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider({ fetcher }) }); - test('should use a custom profile url from an environment variable', async () => { - process.env.NEXT_PUBLIC_AUTH0_PROFILE = '/api/custom-url'; - const fetchSpy = jest.fn().mockReturnValue(Promise.resolve()); - (global as any).fetch = fetchSpy; - const { result, waitForValueToChange } = renderHook(() => useUser(), { - wrapper: withUserProvider() - }); + expect(result.current.user).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.isLoading).toEqual(true); await waitForValueToChange(() => result.current.isLoading); - expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url'); - delete process.env.NEXT_PUBLIC_AUTH0_PROFILE; - }); - - test('should accept a custom login url', async () => { - const { result } = renderHook(() => useConfig(), { - wrapper: withUserProvider({ user, loginUrl: '/api/custom-url' }) - }); - expect(result.current.loginUrl).toEqual('/api/custom-url'); + expect(result.current.user).toBeUndefined(); + expect(result.current.error).toEqual(error); + expect(result.current.isLoading).toEqual(false); }); +}); - test('should check the session when logged in', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; +describe('check session', () => { + afterEach(() => delete (global as any).fetch); + + test('should set the user after logging in', async () => { + (global as any).fetch = fetchUserErrorMock; const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); await waitForValueToChange(() => result.current.isLoading); @@ -125,54 +208,65 @@ describe('context wrapper', () => { expect(result.current.isLoading).toEqual(false); }); - test('should check the session when logged out', async () => { + test('should not unset the user due to a network error while logged in', async () => { (global as any).fetch = fetchUserMock; const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); await waitForValueToChange(() => result.current.isLoading); expect(result.current.user).toEqual(user); - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserNetworkErrorMock; await act(async () => await result.current.checkSession()); - expect(result.current.user).toBeUndefined(); - expect(result.current.error).toBeUndefined(); + expect(result.current.user).toEqual(user); + expect(result.current.error).toBeDefined(); expect(result.current.isLoading).toEqual(false); }); - test('should throw an error when not wrapped in UserProvider', async () => { - const expectedError = 'You forgot to wrap your app in '; - const { result } = renderHook(() => useUser()); + test('should not unset the user due to an error response while logged in', async () => { + (global as any).fetch = fetchUserMock; + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); - expect(() => result.current.user).toThrowError(expectedError); - expect(() => result.current.error).toThrowError(expectedError); - expect(() => result.current.isLoading).toThrowError(expectedError); - expect(result.current.checkSession).toThrowError(expectedError); - }); + await waitForValueToChange(() => result.current.isLoading); + expect(result.current.user).toEqual(user); - test('should be able to stub UserProvider with UserContext.Provider', async () => { - const { result } = renderHook(() => useUser(), { - wrapper: (props: any): React.ReactElement => - }); + (global as any).fetch = fetchUserErrorMock; - expect(result.current.user).toEqual({ foo: 'bar' }); + await act(async () => await result.current.checkSession()); + expect(result.current.user).toEqual(user); + expect(result.current.error).toBeDefined(); + expect(result.current.isLoading).toEqual(false); }); - test('should use the override fetch behaviour', async () => { - const fetchSpy = jest.fn(); - (global as any).fetch = fetchSpy; + test('should not unset the user due to the custom fetcher throwing an error while logged in', async () => { + (global as any).fetch = fetchUserMock; + const fetcher = jest.fn().mockResolvedValueOnce(user).mockRejectedValueOnce(new Error()); - const returnValue = 'foo'; - const customFetcher = jest.fn().mockReturnValue(Promise.resolve(returnValue)); + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider({ fetcher }) }); - const { result, waitForValueToChange } = renderHook(() => useUser(), { - wrapper: withUserProvider({ fetcher: customFetcher }) - }); + await waitForValueToChange(() => result.current.isLoading); + expect(result.current.user).toEqual(user); + + (global as any).fetch = fetchUserErrorMock; + + await act(async () => await result.current.checkSession()); + expect(result.current.user).toEqual(user); + expect(result.current.error).toBeDefined(); + expect(result.current.isLoading).toEqual(false); + }); + + test('should unset the user after logging out', async () => { + (global as any).fetch = fetchUserMock; + const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() }); await waitForValueToChange(() => result.current.isLoading); + expect(result.current.user).toEqual(user); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(customFetcher).toHaveBeenCalledWith('/api/auth/me'); - expect(result.current.user).toBe(returnValue); + (global as any).fetch = fetchUserUnauthorizedMock; + + await act(async () => await result.current.checkSession()); + expect(result.current.user).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + expect(result.current.isLoading).toEqual(false); }); }); diff --git a/tests/frontend/with-page-auth-required.test.tsx b/tests/frontend/with-page-auth-required.test.tsx index 7539404b3..0a6e045d6 100644 --- a/tests/frontend/with-page-auth-required.test.tsx +++ b/tests/frontend/with-page-auth-required.test.tsx @@ -5,7 +5,7 @@ import '@testing-library/jest-dom/extend-expect'; import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import { fetchUserUnsuccessfulMock, fetchUserErrorMock, withUserProvider, user } from '../fixtures/frontend'; +import { fetchUserErrorMock, withUserProvider, user } from '../fixtures/frontend'; import { withPageAuthRequired } from '../../src/frontend'; const windowLocation = window.location; @@ -25,7 +25,7 @@ describe('with-page-auth-required csr', () => { afterAll(() => (window.location = windowLocation)); it('should deny access to a CSR page when not authenticated', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); @@ -43,7 +43,7 @@ describe('with-page-auth-required csr', () => { }); it('should show an empty element when redirecting', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); @@ -52,7 +52,7 @@ describe('with-page-auth-required csr', () => { }); it('should show a custom element when redirecting', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const OnRedirecting = (): JSX.Element => <>Redirecting; const ProtectedPage = withPageAuthRequired(MyPage, { onRedirecting: OnRedirecting }); @@ -82,7 +82,7 @@ describe('with-page-auth-required csr', () => { it('should use a custom login URL', async () => { process.env.NEXT_PUBLIC_AUTH0_LOGIN = '/api/foo'; - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); @@ -93,7 +93,7 @@ describe('with-page-auth-required csr', () => { it('should return to the root path', async () => { window.location.toString = jest.fn(() => 'https://example.net'); - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); @@ -107,7 +107,7 @@ describe('with-page-auth-required csr', () => { it('should return to the current path', async () => { window.location.toString = jest.fn(() => 'https://example.net/foo'); - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage); @@ -120,7 +120,7 @@ describe('with-page-auth-required csr', () => { }); it('should accept a custom returnTo URL', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage, { returnTo: '/foo' }); @@ -133,7 +133,7 @@ describe('with-page-auth-required csr', () => { }); it('should preserve multiple query params in the returnTo URL', async () => { - (global as any).fetch = fetchUserUnsuccessfulMock; + (global as any).fetch = fetchUserErrorMock; const MyPage = (): JSX.Element => <>Private; const ProtectedPage = withPageAuthRequired(MyPage, { returnTo: '/foo?bar=baz&qux=quux' });