Skip to content

Commit

Permalink
chore: add integration tests for cookie handling (#36)
Browse files Browse the repository at this point in the history
* chore: add tests for cookie handling

* chore: simplify tests

* chore: improve test performance
  • Loading branch information
valendres authored Nov 5, 2022
1 parent 0333071 commit c5f33ee
Show file tree
Hide file tree
Showing 17 changed files with 364 additions and 119 deletions.
18 changes: 18 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test:report": "yarn playwright show-report tests/playwright/report"
},
"dependencies": {
"buffer": "^6.0.3",
"msw": "0.47.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand All @@ -18,6 +19,7 @@
},
"devDependencies": {
"@playwright/test": "^1.27.1",
"@types/node": "^18.11.9",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.1.0",
Expand Down
126 changes: 96 additions & 30 deletions packages/example/src/components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { FC, FormEvent, useCallback } from "react";
import { useMutation } from "react-query";
import { LoginApiResponse, LoginApiRequestBody } from "../types/api";
import { useMutation, useQuery } from "react-query";
import {
GetSessionResponse,
PostSessionResponse,
PostSessionRequestBody,
} from "../types/session";

const getLoginFormValues = ({ elements }: HTMLFormElement) => {
const usernameElement = elements.namedItem("username") as HTMLInputElement;
Expand All @@ -20,15 +24,29 @@ const getLoginErrorMessage = ({ status }: Response) => {
}
};

const useSessionQuery = () => {
return useQuery<{ status: number; session: GetSessionResponse | null }>(
["session"],
async () => {
const response = await fetch("/api/session");
return {
status: response.status,
session: response.status === 200 ? await response.json() : null,
};
},
{ retry: false, refetchOnWindowFocus: false, refetchOnMount: false }
);
};

const useLoginMutation = () => {
return useMutation<
LoginApiResponse,
PostSessionResponse,
{ message: string },
LoginApiRequestBody
PostSessionRequestBody
>(
["login"],
async (credentials: { username: string; password: string }) => {
const response = await fetch("/api/login", {
const response = await fetch("/api/session", {
method: "POST",
body: JSON.stringify(credentials),
});
Expand All @@ -43,46 +61,94 @@ const useLoginMutation = () => {
);
};

const useLogoutMutation = () => {
return useMutation(
["login"],
async () => {
const response = await fetch("/api/session", {
method: "DELETE",
});

if (response.status !== 200) {
throw new Error("Failed to logout");
}
},
{ retry: false }
);
};

export const LoginForm: FC = () => {
const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation();

const logoutMutation = useLogoutMutation();
const handleFormSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
loginMutation.mutate(getLoginFormValues(event.target as HTMLFormElement));
loginMutation.mutate(
getLoginFormValues(event.target as HTMLFormElement),
{
onSuccess: () => {
sessionQuery.refetch();
},
}
);
},
[loginMutation.mutate]
);

if (loginMutation.isSuccess) {
return <div role="alert">Successfully signed in!</div>;
const handleLogoutButtonPress = useCallback(() => {
if (loginMutation.isIdle) {
logoutMutation.mutate(undefined, {
onSuccess: () => {
sessionQuery.refetch();
},
});
}
}, [logoutMutation.mutate]);

if (sessionQuery.isLoading) {
return <div>Loading...</div>;
}

return (
<div>
<form onSubmit={handleFormSubmit}>
{loginMutation.isError && (
<div role="alert">
<b>Failed to login</b>
<div>{loginMutation.error?.message}</div>
</div>
)}
<div className="username">
<label htmlFor="username">Username</label>
<div>
<input type="text" name="username" id="username" required />
</div>
{sessionQuery.data?.session ? (
<div>
<div role="alert">Successfully signed in!</div>
<button onClick={handleLogoutButtonPress}>Logout</button>
</div>
<div className="password">
<label htmlFor="password">Password</label>
<div>
<input type="password" name="password" id="password" required />
) : (
<form onSubmit={handleFormSubmit}>
{loginMutation.isError && (
<div role="alert">
<b>Failed to login</b>
<div>{loginMutation.error?.message}</div>
</div>
)}
<div className="username">
<label htmlFor="username">Username</label>
<div>
<input type="text" name="username" id="username" required />
</div>
</div>
</div>
<button type="submit">
{loginMutation.isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
<div className="password">
<label htmlFor="password">Password</label>
<div>
<input type="password" name="password" id="password" required />
</div>
</div>
<button type="submit">
{loginMutation.isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
)}
<br />
<div>
Session status:{" "}
<span data-testid="session-status">
{sessionQuery.isLoading ? "loading" : sessionQuery.data?.status}
</span>
</div>
</div>
);
};
4 changes: 2 additions & 2 deletions packages/example/src/components/users-list.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { FC } from "react";
import { useQuery } from "react-query";
import { UsersApiResponse } from "../types/api";
import { GetUsersResponse } from "../types/users";

export type UsersListProps = unknown;

export const UsersList: FC<UsersListProps> = () => {
const { data: users, isError } = useQuery<UsersApiResponse>(
const { data: users, isError } = useQuery<GetUsersResponse>(
"users",
async () => {
const response = await fetch("/api/users");
Expand Down
4 changes: 4 additions & 0 deletions packages/example/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Buffer as BufferPolyfill } from "buffer";
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { setupWorker } from "msw";
import handlers from "./mocks/handlers";

// Polyfill buffer so we can base64 encode/decode within mocks in browser (via dev server) & node
globalThis.Buffer = BufferPolyfill;

const worker = setupWorker(...handlers);

async function prepare() {
Expand Down
55 changes: 3 additions & 52 deletions packages/example/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,4 @@
import { rest } from "msw";
import {
UsersApiParams,
UsersApiResponse,
LoginApiRequestBody,
LoginApiParams,
LoginApiResponse,
} from "../types/api";
import sessionHandlers from "./handlers/session";
import usersHandlers from "./handlers/users";

export default [
rest.get<null, UsersApiParams, UsersApiResponse>(
"/api/users",
(_, response, context) =>
response(
context.delay(500),
context.status(200),
context.json([
{
id: "bcff5c0e-10b6-407b-94d1-90d741363885",
firstName: "Rhydian",
lastName: "Greig",
},
{
id: "b44e89e4-3254-415e-b14a-441166616b20",
firstName: "Alessandro",
lastName: "Metcalfe",
},
{
id: "6e369942-6b5d-4159-9b39-729646549183",
firstName: "Erika",
lastName: "Richards",
},
])
)
),
rest.post<LoginApiRequestBody, LoginApiParams, LoginApiResponse>(
"/api/login",
async (request, response, context) => {
const { username, password } = await request.json<LoginApiRequestBody>();
if (username === "peter" && password === "secret") {
return response(
context.delay(500),
context.status(200),
context.json({
userId: "9138123",
})
);
}

return response(context.delay(500), context.status(401));
}
),
];
export default [...sessionHandlers, ...usersHandlers];
83 changes: 83 additions & 0 deletions packages/example/src/mocks/handlers/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
SessionData,
GetSessionParams,
GetSessionResponse,
PostSessionRequestBody,
PostSessionParams,
PostSessionResponse,
} from "../../types/session";
import { rest } from "msw";

const VALID_USERNAME = "peter";
const VALID_PASSWORD = "secret";
const SESSION_COOKIE_KEY = "x-session";

const sessionData: SessionData = {
userId: "9138123",
};

const encodeSessionCookie = (username: string, password: string) =>
// This isn't secure, please don't do this in production code 😇
Buffer.from(`${username}:${password}`).toString("base64");

const decodeSessionCookie = (
cookie: string
): { username: string; password: string } => {
const [username, password] = Buffer.from(cookie ?? "", "base64")
.toString()
.split(":");
return {
username: username ?? null,
password: password ?? null,
};
};

const isValidCredentials = (username: string, password: string): boolean =>
username === VALID_USERNAME && password === VALID_PASSWORD;

const isValidSession = (cookie: string): boolean => {
const { username, password } = decodeSessionCookie(cookie);
return isValidCredentials(username, password);
};

export default [
rest.get<null, GetSessionParams, GetSessionResponse>(
"/api/session",
async (request, response, context) => {
const sessionCookie = request.cookies[SESSION_COOKIE_KEY];
return isValidSession(sessionCookie)
? response(
context.delay(150),
context.status(200),
context.json(sessionData)
)
: response(context.delay(150), context.status(401));
}
),
rest.post<PostSessionRequestBody, PostSessionParams, PostSessionResponse>(
"/api/session",
async (request, response, context) => {
const { username, password } =
await request.json<PostSessionRequestBody>();
if (isValidCredentials(username, password)) {
return response(
context.delay(500),
context.status(200),
context.cookie(
SESSION_COOKIE_KEY,
encodeSessionCookie(username, password)
),
context.json(sessionData)
);
}

return response(context.delay(500), context.status(401));
}
),
rest.delete("/api/session", async (request, response, context) => {
const sessionCookie = request.cookies[SESSION_COOKIE_KEY];
return isValidSession(sessionCookie)
? response(context.status(200), context.cookie(SESSION_COOKIE_KEY, ""))
: response(context.status(401));
}),
];
Loading

0 comments on commit c5f33ee

Please sign in to comment.