Skip to content

Commit

Permalink
Transition auth state to MobX (#248)
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian authored Dec 18, 2020
1 parent d33418b commit 4b32c00
Show file tree
Hide file tree
Showing 19 changed files with 1,907 additions and 1,266 deletions.
2 changes: 2 additions & 0 deletions spotlight-client/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
{
"files": ["**.ts", "**.tsx"],
"rules": {
// with static typing this rule is not so useful
"consistent-return": "off",
// these bare ESLint rules are superseded by TS equivalents
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": ["error"],
Expand Down
13 changes: 9 additions & 4 deletions spotlight-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,38 @@
"dev": "react-scripts start",
"eject": "react-scripts eject",
"lint": "tsc && eslint '**/*.{js,ts,tsx}'",
"test": "react-scripts test"
"test": "react-scripts test --env=jest-environment-jsdom-sixteen"
},
"dependencies": {
"@auth0/auth0-react": "^1.1.0",
"@auth0/auth0-spa-js": "^1.13.1",
"@types/qs": "^6.9.5",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"assert-never": "^1.2.1",
"jest-fetch-mock": "^3.0.3",
"mobx": "^6.0.4",
"mobx-react-lite": "^3.0.1",
"mobx-utils": "^6.0.1",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-app-polyfill": "^1.0.6",
"react-dom": "^16.13.1",
"react-error-boundary": "^3.0.2",
"react-scripts": "3.4.3",
"typescript": "^4.0.0",
"utility-types": "^3.10.0",
"wait-for-localhost": "^3.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/jest-dom": "^5.11.1",
"@testing-library/react": "^11.1.1",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^4.4.0",
"@typescript-eslint/parser": "^4.4.0",
"eslint-import-resolver-typescript": "^2.3.0",
"jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": ">=10"
},
"browserslist": {
Expand Down
228 changes: 131 additions & 97 deletions spotlight-client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,118 +15,152 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import { Auth0Provider, useAuth0 } from "@auth0/auth0-react";
import { render } from "@testing-library/react";
import React from "react";
import App from "./App";
import { getAuthSettings, isAuthEnabled } from "./AuthWall/utils";

test("does not explode", () => {
const { getByRole } = render(<App />);
// we have to import everything dynamically to manipulate process.env,
// which is weird and Typescript doesn't like it, so silence these warnings
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let waitFor: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cleanup: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let render: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let screen: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let React: any;

// this doesn't do anything but convince TypeScript this file is a module,
// since we don't have any top-level imports
export {};

// mocking the Auth0 library because JSDOM doesn't support all the APIs it needs
const mockGetUser = jest.fn();
const mockIsAuthenticated = jest.fn();
const mockLoginWithRedirect = jest.fn();
jest.mock("@auth0/auth0-spa-js", () =>
jest.fn().mockResolvedValue({
getUser: mockGetUser,
isAuthenticated: mockIsAuthenticated,
loginWithRedirect: mockLoginWithRedirect,
})
);

/**
* Convenience method for importing test module after updating environment
*/
async function getApp() {
return (await import("./App")).default;
}

// mocking the node env is esoteric, see https://stackoverflow.com/a/48042799
const ORIGINAL_ENV = process.env;

beforeEach(async () => {
// make a copy that we can modify
process.env = { ...ORIGINAL_ENV };

jest.resetModules();
// reimport all modules except the test module
React = (await import("react")).default;
const reactTestingLibrary = await import("@testing-library/react");
render = reactTestingLibrary.render;
screen = reactTestingLibrary.screen;
cleanup = reactTestingLibrary.cleanup;
waitFor = reactTestingLibrary.waitFor;
});

afterEach(() => {
process.env = ORIGINAL_ENV;
// dynamic imports break auto cleanup so we have to do it manually
cleanup();
});

test("no auth required", async () => {
const App = await getApp();
render(<App />);
// seems like a pretty safe bet this word will always be there somewhere!
const websiteName = getByRole("heading", { name: /spotlight/i });
const websiteName = screen.getByRole("heading", { name: /spotlight/i });
expect(websiteName).toBeInTheDocument();
});

jest.mock("./AuthWall/utils", () => ({
getAuthSettings: jest.fn(),
isAuthEnabled: jest.fn(),
}));
const getAuthSettingsMock = getAuthSettings as jest.MockedFunction<
typeof getAuthSettings
>;

const isAuthEnabledMock = isAuthEnabled as jest.MockedFunction<
typeof isAuthEnabled
>;

// Although mocking the Auth0 library is not necessarily a great practice,
// it has a number of side effects (e.g. issuing XHRs, navigating to new URLs)
// that are challenging to handle or even simulate in this test environment,
// so this seemed like the better solution here
jest.mock("@auth0/auth0-react", () => {
return {
Auth0Provider: jest.fn(),
useAuth0: jest.fn(),
};
});
test("requires authentication", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
process.env.REACT_APP_AUTH_ENV = "development";

describe("with auth required", () => {
const MOCK_DOMAIN = "test.local";
const MOCK_CLIENT_ID = "abcdef";

beforeEach(() => {
// mock the environment configuration to enable auth
getAuthSettingsMock.mockReturnValue({
domain: MOCK_DOMAIN,
clientId: MOCK_CLIENT_ID,
});
isAuthEnabledMock.mockReturnValue(true);
});
// user is not currently authenticated
mockIsAuthenticated.mockResolvedValue(false);

afterEach(() => {
jest.restoreAllMocks();
});
const App = await getApp();
render(<App />);

test("require auth for the entire site", async () => {
// mock the auth0 provider
const PROVIDER_TEST_ID = "Auth0Provider";
(Auth0Provider as jest.Mock).mockImplementation(({ children }) => {
// here we mock just enough to verify that the context provider is being included;
// we can use this as a proxy for the relationship between provider and hook.
return <div data-testid={PROVIDER_TEST_ID}>{children}</div>;
});

// mock the auth0 hook
const mockLoginWithRedirect = jest.fn();
(useAuth0 as jest.Mock).mockReturnValue({
isAuthenticated: false,
loginWithRedirect: mockLoginWithRedirect,
});

const { queryByRole, getByRole, getByTestId } = render(<App />);

// verify that we have included an Auth0Provider
expect(getByTestId(PROVIDER_TEST_ID)).toBeInTheDocument();

// verify that we supplied that provider with
// the settings designated by our mock environment
expect((Auth0Provider as jest.Mock).mock.calls[0][0]).toEqual(
expect.objectContaining({ domain: MOCK_DOMAIN, clientId: MOCK_CLIENT_ID })
);

// verify that we have initiated an Auth0 login
expect(
screen.queryByRole("heading", { name: /spotlight/i })
).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
await waitFor(() => {
expect(mockLoginWithRedirect.mock.calls.length).toBe(1);

// application contents should not have been rendered unauthed
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
expect(getByRole("status", { name: /loading/i })).toBeInTheDocument();
// this should ... continue not being in the document
expect(
screen.queryByRole("heading", { name: /spotlight/i })
).not.toBeInTheDocument();
});
});

test("require email verification for authed users", () => {
(useAuth0 as jest.Mock).mockReturnValue({
isAuthenticated: true,
user: {
email_verified: false,
},
});
test("requires email verification", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
process.env.REACT_APP_AUTH_ENV = "development";

const { getByRole, queryByRole } = render(<App />);
// user is authenticated but not verified
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: false });

const App = await getApp();
render(<App />);
await waitFor(() => {
// application contents should not have been rendered without verification
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
expect(
screen.queryByRole("heading", { name: /spotlight/i })
).not.toBeInTheDocument();
// there should be a message about the verification requirement
expect(getByRole("heading", { name: /verification/i })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /verification/i })
).toBeInTheDocument();
});
});

test("loading state", () => {
(useAuth0 as jest.Mock).mockReturnValue({
isLoading: true,
});

const { queryByRole, getByRole } = render(<App />);
test("renders when authenticated", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
process.env.REACT_APP_AUTH_ENV = "development";

// user is authenticated and verified
mockIsAuthenticated.mockResolvedValue(true);
mockGetUser.mockResolvedValue({ email_verified: true });
const App = await getApp();
render(<App />);
await waitFor(() => {
const websiteName = screen.getByRole("heading", { name: /spotlight/i });
expect(websiteName).toBeInTheDocument();
});
});

// application contents should not have been rendered while auth is pending
expect(queryByRole("heading", { name: /spotlight/i })).toBeNull();
expect(getByRole("status", { name: /loading/i })).toBeInTheDocument();
test("handles an Auth0 configuration error", async () => {
// configure environment for valid authentication
process.env.REACT_APP_AUTH_ENABLED = "true";
// no config exists for this environment
process.env.REACT_APP_AUTH_ENV = "production";
mockIsAuthenticated.mockResolvedValue(false);

const App = await getApp();
render(<App />);

await waitFor(() => {
expect(
screen.getByRole("heading", /an error occurred/i)
).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: /spotlight/i })
).not.toBeInTheDocument();
});
});
17 changes: 10 additions & 7 deletions spotlight-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,19 @@

import React from "react";
import AuthWall from "./AuthWall";
import StoreProvider from "./StoreProvider";

const App: React.FC = () => {
return (
<AuthWall>
<div>
<header>
<h1>Spotlight</h1>
</header>
</div>
</AuthWall>
<StoreProvider>
<AuthWall>
<div>
<header>
<h1>Spotlight</h1>
</header>
</div>
</AuthWall>
</StoreProvider>
);
};

Expand Down
Loading

0 comments on commit 4b32c00

Please sign in to comment.