Add role checks (#21)

* Add role checks

* Remove moved Roles enum
This commit is contained in:
John Wu
2021-03-22 11:29:35 -07:00
committed by GitHub
parent 03de4f5826
commit aea873e920
19 changed files with 1305 additions and 893 deletions

View File

@@ -4,9 +4,9 @@ jest.mock("../Contexts/VehicleContext");
import { render, screen, cleanup, waitForElementToBeRemoved } from "@testing-library/react";
import { setToken } from "../Contexts/UserContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing"
import App from ".";
const TEST_TOKEN = { idToken: { jwtToken: "TEST" } };
const LOADING_STATUS = "Loading...";
const renderRoute = async (route) => {
@@ -37,6 +37,12 @@ describe("App", () => {
expect(container).toMatchSnapshot();
});
it("Route /package-upload unauthenticated", async () => {
const container = await renderRoute("/package-upload");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add unauthenticated", async () => {
const container = await renderRoute("/vehicle-add");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
@@ -44,21 +50,28 @@ describe("App", () => {
});
it("Route / authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container.querySelector("h1").innerHTML).toEqual("Welcome John!");
expect(container).toMatchSnapshot();
});
it("Route /home authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/home");
expect(container.querySelector("h1").innerHTML).toEqual("Welcome John!");
expect(container).toMatchSnapshot();
});
it("Route /package-upload authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/package-upload");
expect(container.querySelector("h1").innerHTML).toEqual("Upload Update Package");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/vehicle-add");
expect(container.querySelector("h1").innerHTML).toEqual("Add Vehicle");
expect(container).toMatchSnapshot();
@@ -71,7 +84,7 @@ describe("App", () => {
});
it("Route /page-not-found authenticated", async () => {
setToken(TEST_TOKEN);
setToken(TEST_AUTH_OBJECT);
const container = await renderRoute("/page-not-found");
expect(container.querySelector("h1").innerHTML).toEqual("Page Not Found");
expect(container).toMatchSnapshot();

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
import React, { useContext, useEffect, useState } from "react";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
import { parsePayload } from "../../utils/jwt";
import { getGroups } from "../../utils/roles";
const UserContext = React.createContext();
export const UserProvider = ({ children }) => {
const [fetching, setFetching] = useState(false);
const [token, setToken] = useState(null);
const [groups, setGroups] = useState(null);
const [error, setError] = useState(null);
let timer;
@@ -15,13 +18,7 @@ export const UserProvider = ({ children }) => {
if (!localStorage) return;
const t = JSON.parse(localStorage.getItem("token"));
if (!t) return;
if (
!t.idToken ||
!t.idToken.jwtToken ||
!t.idToken.payload ||
!t.idToken.payload.exp
)
throw new Error("Invalid token");
if (!t.idToken || !t.idToken.jwtToken) throw new Error("Invalid token");
setToken(t);
} catch (e) {
document.location = signOut();
@@ -45,7 +42,12 @@ export const UserProvider = ({ children }) => {
};
const startSessionTimer = () => {
const duration = 1000 * token.idToken.payload.exp - new Date().getTime();
if (!token || !token.idToken || !token.idToken.jwtToken) {
throw new Error("No id token");
}
const payload = parsePayload(token.idToken.jwtToken);
if (!payload || !payload.exp) throw new Error("Bad id token payload");
const duration = 1000 * payload.exp - new Date().getTime();
if (!timer) {
timer = getTimerWorker();
timer.onMessage(async (e) => {
@@ -71,6 +73,7 @@ export const UserProvider = ({ children }) => {
if (!t || t.error) throw new Error("Unable to refresh token");
}
setGroups(getGroups(idToken));
startSessionTimer();
} catch (e) {
setError(`Verify error. ${e.message}`);
@@ -103,6 +106,7 @@ export const UserProvider = ({ children }) => {
};
const signOut = () => {
setGroups(null);
setToken(null);
if (localStorage) {
localStorage.removeItem("token");
@@ -149,6 +153,7 @@ export const UserProvider = ({ children }) => {
value={{
fetching,
token,
groups,
error,
setError,
signIn,

View File

@@ -11,15 +11,7 @@ import {
import { UserProvider, useUserContext } from "../Contexts/UserContext";
import auth from "../../services/auth";
import getTimerWorker from "../../services/timer";
const TEST_TOKEN = {
idToken: {
jwtToken: "TEST",
payload: {
exp: new Date().getTime() / 1000,
},
},
};
import { TEST_AUTH_OBJECT, TEST_EXPECTED_GROUPS } from "../../utils/testing";
const INVALID_TOKEN_RESPONSE = {
error: "Bad Request Error",
@@ -36,10 +28,11 @@ const setupSignInEnv = (refreshResponse, valid) => {
auth.setVerifyResponse({ valid });
};
const checkBaseResults = (error, fetching, token) => {
const checkBaseResults = (error, fetching, token, groups) => {
expect(screen.getByTestId("error").innerHTML).toEqual(error);
expect(screen.getByTestId("fetching").innerHTML).toEqual(fetching);
expect(screen.getByTestId("token").innerHTML).toEqual(token);
expect(screen.getByTestId("groups").innerHTML).toEqual(groups);
};
const checkTokenResults = (timer, token) => {
@@ -57,13 +50,14 @@ describe("UseContext", () => {
describe("Signin", () => {
beforeEach(() => {
const TestComp = () => {
const { signIn, error, token, fetching } = useUserContext();
const { signIn, error, token, groups, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="signInNoCode" onClick={() => signIn("")} />
<button
data-testid="signInInvalidCode"
@@ -85,13 +79,13 @@ describe("UseContext", () => {
});
it("Initial state", () => {
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("No auth code", () => {
fireEvent.click(screen.getByTestId("signInNoCode"));
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("Invalid auth code", async () => {
@@ -103,14 +97,19 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Sign in error. Bad Request Message", "false", "null");
checkBaseResults(
"Sign in error. Bad Request Message",
"false",
"null",
""
);
});
it("Sign in form", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
const TOKEN_STRING = JSON.stringify(TEST_AUTH_OBJECT);
const timer = getTimerWorker();
setupSignInEnv(TEST_TOKEN, true);
setupSignInEnv(TEST_AUTH_OBJECT, true);
fireEvent.click(screen.getByTestId("signIn"));
@@ -118,7 +117,7 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkBaseResults("", "false", TOKEN_STRING, TEST_EXPECTED_GROUPS);
checkTokenResults(timer, TOKEN_STRING);
});
});
@@ -126,12 +125,20 @@ describe("UseContext", () => {
describe("Signout", () => {
beforeEach(async () => {
const TestComp = () => {
const { signIn, signOut, error, token, fetching } = useUserContext();
const {
signIn,
signOut,
error,
token,
groups,
fetching,
} = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="signIn" onClick={() => signIn("TEST_CODE")} />
<button data-testid="signOut" onClick={() => signOut()} />
</>
@@ -142,7 +149,7 @@ describe("UseContext", () => {
<TestComp />
</UserProvider>
);
auth.setSignInResponse(TEST_TOKEN);
auth.setSignInResponse(TEST_AUTH_OBJECT);
fireEvent.click(screen.getByTestId("signIn"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
@@ -154,10 +161,9 @@ describe("UseContext", () => {
cleanup();
});
it("Token cleared", () => {
it("Token cleared", async () => {
fireEvent.click(screen.getByTestId("signOut"));
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
if (!localStorage) return;
expect(localStorage.getItem("token")).toBeNull();
});
@@ -166,13 +172,14 @@ describe("UseContext", () => {
describe("Refresh", () => {
beforeEach(() => {
const TestComp = () => {
const { refresh, error, token, fetching } = useUserContext();
const { refresh, error, token, groups, fetching } = useUserContext();
return (
<>
<div data-testid="error">{error}</div>
<div data-testid="fetching">{fetching.toString()}</div>
<div data-testid="token">{JSON.stringify(token)}</div>
<div data-testid="groups">{groups}</div>
<button data-testid="refreshNoToken" onClick={() => refresh("")} />
<button
data-testid="refreshInvalidToken"
@@ -197,12 +204,12 @@ describe("UseContext", () => {
});
it("Initial state", () => {
checkBaseResults("", "false", "null");
checkBaseResults("", "false", "null", "");
});
it("No refresh token", () => {
fireEvent.click(screen.getByTestId("refreshNoToken"));
checkBaseResults("Refresh error. Token required", "false", "null");
checkBaseResults("Refresh error. Token required", "false", "null", "");
});
it("Invalid refresh token", async () => {
@@ -213,21 +220,26 @@ describe("UseContext", () => {
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("Refresh error. Bad Request Message", "false", "null");
checkBaseResults(
"Refresh error. Bad Request Message",
"false",
"null",
""
);
});
it("Valid refresh token", async () => {
const TOKEN_STRING = JSON.stringify(TEST_TOKEN);
const TOKEN_STRING = JSON.stringify(TEST_AUTH_OBJECT);
const timer = getTimerWorker();
setupRefreshEnv(TEST_TOKEN, true);
setupRefreshEnv(TEST_AUTH_OBJECT, true);
fireEvent.click(screen.getByTestId("refreshValidToken"));
await waitFor(() =>
expect(screen.getByTestId("fetching").innerHTML).toEqual("true")
);
checkBaseResults("", "false", TOKEN_STRING);
checkBaseResults("", "false", TOKEN_STRING, TEST_EXPECTED_GROUPS);
checkTokenResults(timer, TOKEN_STRING);
});
});

View File

@@ -1,11 +1,16 @@
import React from "react";
import { getGroups } from "../../../utils/roles";
let token = null;
let groups = null;
let fetching = false;
let error = null;
let signInResp = {};
let authorizeURL = "https://cognito.com/authorize?redirect=https://example.com/callback";
let logoutURL = "https://cognito.com/logout?redirect=https://example.com/callback";
let authorizeURL =
"https://cognito.com/authorize?redirect=https://example.com/callback";
let logoutURL =
"https://cognito.com/logout?redirect=https://example.com/callback";
export const UserProvider = ({ children }) => {
return <div data-testid="mocked-userprovider">{children}</div>;
@@ -15,6 +20,7 @@ export const useUserContext = () => ({
token,
fetching,
error,
groups,
signIn: jest.fn(() => signInResp),
signOut: jest.fn(),
getAuthorizeURL: jest.fn(() => authorizeURL),
@@ -26,6 +32,11 @@ export const useUserContext = () => ({
export const setToken = (val) => {
token = val;
if (!val || !val.idToken || !val.idToken.jwtToken) {
groups = null;
} else {
groups = getGroups(val.idToken.jwtToken);
}
};
export const setFetching = (val) => {

View File

@@ -7,10 +7,11 @@ import { render, cleanup, waitFor } from "@testing-library/react";
import FileUploadForm from "./index";
import { setToken } from "../Contexts/UserContext";
import { StatusProvider } from "../Contexts/StatusContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing"
describe("File Upload Form", () => {
it("Should render", async () => {
setToken({ idToken: { jwtToken: "TEST" } });
setToken(TEST_AUTH_OBJECT);
const { container } = render(<StatusProvider><BrowserRouter><FileUploadForm /></BrowserRouter></StatusProvider>);
await waitFor(() => {});
expect(container).toMatchSnapshot();

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import {
Button,
Chip,
@@ -86,19 +86,17 @@ const MainForm = () => {
}
};
useEffect(() => {
const handleCarOpen = async () => {
const {
idToken: { jwtToken: authToken },
} = token;
(async () => {
try {
await getVehicles(null, authToken);
} catch (e) {
setMessage(e.message);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
try {
await getVehicles(null, authToken);
} catch (e) {
setMessage(e.message);
}
};
return (
<div className={classes.paper}>
@@ -173,6 +171,7 @@ const MainForm = () => {
name="vehicles"
multiple
className={classes.menuProps}
onOpen={handleCarOpen}
onChange={handleVehiclesChange}
value={selectedVehicles}
input={<Input id="select-multiple-chip" />}

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Typography } from "@material-ui/core";
import useStyles from "../useStyles";
import { useUserContext } from "../Contexts/UserContext";
import { parsePayload } from "../../utils/jwt";
const DEFAULT_GREETING = "Welcome";
const getGreeting = (token) => {
if (!token || !token.idToken || !token.idToken.jwtToken)
return DEFAULT_GREETING;
const payload = parsePayload(token.idToken.jwtToken);
if (!payload || !payload.given_name) return DEFAULT_GREETING;
return `Welcome ${payload.given_name}!`;
};
const Home = () => {
const classes = useStyles();
const { token } = useUserContext();
const greeting = getGreeting(token);
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
{greeting}
</Typography>
</div>
);
};
export default Home;

View File

@@ -20,7 +20,7 @@ export default function MenuDrawer({ children }) {
const classes = useStyles();
const theme = useTheme();
const { signOut, token } = useUserContext();
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(true);
const handleDrawerOpen = () => {
setOpen(true);

View File

@@ -1,16 +1,40 @@
import React from "react";
import { List } from "@material-ui/core";
import ListItemLink from "../ListItemLink";
import { useUserContext } from "../Contexts/UserContext";
import { Roles, hasRole } from "../../utils/roles";
const menuData = [
{
label: "Home",
to: "/home",
roles: [],
},
{
label: "Upload Update Package",
to: "/package-upload",
roles: [Roles.CREATE],
},
{
label: "Add Vehicles",
to: "/vehicle-add",
roles: [Roles.CREATE],
},
];
export default function SideMenu() {
const menuData = [
{ label: "Upload Update Package", to: "/home" },
{ label: "Add Vehicles", to: "/vehicle-add" },
];
const { groups } = useUserContext();
const menu = menuData.reduce((result, item) => {
if (hasRole(item.roles, groups)) {
result.push(item);
}
return result;
}, []);
return (
<List>
{menuData.map((item, index) => (
{menu.map((item, index) => (
<ListItemLink key={index} primary={item.label} to={item.to} />
))}
</List>

View File

@@ -0,0 +1,33 @@
jest.mock("../Contexts/UserContext");
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { UserProvider, setToken } from "../Contexts/UserContext";
import { TEST_AUTH_OBJECT } from "../../utils/testing";
import SideMenu from "./SideMenu";
const renderMenu = async () => {
const { container } = render(
<UserProvider>
<BrowserRouter>
<SideMenu />
</BrowserRouter>
</UserProvider>
);
await waitFor(() => {});
return container;
};
describe("SideMenu", () => {
it("Unauthenticated", async () => {
setToken(null);
const container = await renderMenu(null);
expect(container).toMatchSnapshot();
});
it("Authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
const container = await renderMenu(TEST_AUTH_OBJECT);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,115 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SideMenu Authenticated 1`] = `
<div>
<div
data-testid="mocked-userprovider"
>
<ul
class="MuiList-root MuiList-padding"
>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/home"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Home
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/package-upload"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Upload Update Package
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/vehicle-add"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Add Vehicles
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
</ul>
</div>
</div>
`;
exports[`SideMenu Unauthenticated 1`] = `
<div>
<div
data-testid="mocked-userprovider"
>
<ul
class="MuiList-root MuiList-padding"
>
<li>
<a
aria-disabled="false"
class="MuiButtonBase-root MuiListItem-root MuiListItem-gutters MuiListItem-button"
href="/home"
role="button"
tabindex="0"
>
<div
class="MuiListItemText-root"
>
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Home
</span>
</div>
<span
class="MuiTouchRipple-root"
/>
</a>
</li>
</ul>
</div>
</div>
`;

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Redirect, Route } from "react-router-dom";
import { hasRole } from "../../utils/roles";
export const TYPES = {
PUBLIC: 0,
@@ -7,11 +8,19 @@ export const TYPES = {
PROTECTED: 2,
};
export const AuthRoute = ({ token, type, ...others }) => {
if (!token && type === TYPES.PROTECTED) {
export const AuthRoute = ({ token, type, roles, groups, ...others }) => {
if (type === TYPES.PROTECTED && !token) {
return <Redirect to="/" />;
} else if (token && type === TYPES.GUEST) {
} else if (type === TYPES.GUEST && token) {
return <Redirect to="/home" />;
} else if (
type === TYPES.PROTECTED &&
token &&
roles &&
!hasRole(roles, groups)
) {
return <Redirect to="/home" />;
}
return <Route render {...others} />;
};

View File

@@ -4,14 +4,16 @@ import { Switch } from "react-router-dom";
import { AuthRoute, TYPES } from "../Routes/AuthRoute";
import { MessageBar } from "../MessageBar";
import { useUserContext } from "../Contexts/UserContext";
import { Roles } from "../../utils/roles";
const SSOForm = React.lazy(() => import("../SSOForm"));
const Home = React.lazy(() => import("../Home"));
const FileUploadForm = React.lazy(() => import("../FileUploadForm"));
const VehicleAddForm = React.lazy(() => import("../VehicleAddForm"));
const PageNotFound = React.lazy(() => import("../404"));
const SiteRoutes = () => {
const { token } = useUserContext();
const { token, groups } = useUserContext();
return (
<Suspense fallback={"Loading..."}>
<MessageBar />
@@ -25,15 +27,25 @@ const SiteRoutes = () => {
/>
<AuthRoute
path="/home"
render={() => <Home />}
type={TYPES.PROTECTED}
token={token}
/>
<AuthRoute
path="/package-upload"
render={() => <FileUploadForm />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<AuthRoute
path="/vehicle-add"
render={() => <VehicleAddForm />}
type={TYPES.PROTECTED}
token={token}
groups={groups}
roles={[Roles.CREATE]}
/>
<PageNotFound />
</Switch>