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

Feat/jimmy test cookies #9

Merged
merged 26 commits into from
Sep 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
95f4901
Add todos from code review with Monarch
jpqy Sep 11, 2020
8569bb3
Make TestManager.logResponse() chainable
jpqy Sep 13, 2020
6a3d4b2
Turn userId into getUserId fn in ServerContext
jpqy Sep 13, 2020
237b45b
Move logout logic from UserService to userResolver
jpqy Sep 13, 2020
d39e22d
Add explicit typing to userResolver functions
jpqy Sep 13, 2020
9471e6e
Move 'me' query logic from service to resolver
jpqy Sep 13, 2020
002a9e1
Split login into UserService.checkPassword() and resolver
jpqy Sep 13, 2020
d3a0b86
Remove context param from userService
jpqy Sep 13, 2020
e77d9e9
Move buildServerContext into own file
jpqy Sep 13, 2020
df597d4
Rename set/clearCookie to set/clearJwt
jpqy Sep 13, 2020
f009950
Move buildExpressServer to its own file
jpqy Sep 14, 2020
1385d35
Install supertest
jpqy Sep 14, 2020
785ffdc
Reimplement TestManager's `query` method using supertest
jpqy Sep 14, 2020
50ae533
Remove unneeded mocks from test folder
jpqy Sep 14, 2020
e55128c
Implement getRawResponse to set up cookies
jpqy Sep 14, 2020
be4e3d0
Add typing to TestManager methods
jpqy Sep 14, 2020
259462b
Install set-cookie-parser
jpqy Sep 14, 2020
c49fbef
Add first cookie test
jpqy Sep 14, 2020
507290b
Add more cookie assertions
jpqy Sep 14, 2020
e3cf472
Extract user.test query constants to separate file
jpqy Sep 14, 2020
3d839cc
Retrieve cookie from login then test 'me' query
jpqy Sep 14, 2020
6b2b331
Add logout test failure case
jpqy Sep 14, 2020
3cda2d4
Add logout test success case
jpqy Sep 14, 2020
dc30b3a
Test that cookies are being cleared during logout
jpqy Sep 14, 2020
38ce16b
Clear wording, add cookie name assertion
jpqy Sep 14, 2020
aa55b7b
Merge remote-tracking branch 'origin/master' into feat/jimmy-test-coo…
jpqy Sep 14, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@
"@types/express": "4.17.7",
"@types/graphql-type-uuid": "0.2.3",
"@types/node": "14.6.0",
"@types/set-cookie-parser": "0.0.6",
"@types/supertest": "2.0.10",
"apollo-server-testing": "2.17.0",
"apollo-server-types": "0.5.1",
"concurrently": "5.3.0",
"cross-env": "7.0.2",
"graphql-type-uuid": "0.2.0",
"jest": "26.4.2",
"nodemon": "2.0.4",
"set-cookie-parser": "2.4.6",
"supertest": "4.0.2",
"ts-jest": "26.3.0",
"ts-node": "9.0.0",
"typescript": "4.0.2"
Expand Down
4 changes: 2 additions & 2 deletions src/buildServer.ts → src/buildApolloServer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ApolloServer } from "apollo-server-express";
import { GraphQLSchema } from "graphql";
import { BuildExpressServerContext } from "./buildContext";
import { BuildExpressServerContext } from "./buildServerContext";

export default function buildServer(schema: GraphQLSchema, context: BuildExpressServerContext): ApolloServer {
export default function buildApolloServer(schema: GraphQLSchema, context: BuildExpressServerContext): ApolloServer {
const apolloServer = new ApolloServer({
schema,
context,
Expand Down
46 changes: 0 additions & 46 deletions src/buildContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ import UserService from "./service/UserService";
import UserDaoKnex from "./dao/UserDaoKnex";
import UserResolverValidator from "./validator/UserResolverValidator";
import UserDao from "./dao/UserDao";
import { Request, Response } from "express";
import { clearCookie, setCookie } from "./util/cookieUtils";
import { parseJwt } from "./util/jwtUtils";
import { AuthenticationError } from "apollo-server-express";

export interface PersistenceContext {
userDao: UserDao;
Expand Down Expand Up @@ -37,45 +33,3 @@ export function buildResolverContext(persistenceContext: PersistenceContext): Re
userService,
};
}

export interface ExpressContext {
req: Request;
res: Response;
}

export interface ServerContext {
setCookie: (token: string) => void;
clearCookie: () => void;
userId?: string;
// TODO: include userId and maybe auth scope, which will be parsed from req cookie
}

export type BuildExpressServerContext = (expressContext: ExpressContext) => ServerContext;

export const buildExpressServerContext: BuildExpressServerContext = function ({
req,
res,
}: {
req: Request;
res: Response;
}) {
let userId;
const jwt: string = req.cookies.jwt;

if (jwt) {
try {
const parsedToken = parseJwt(jwt);
userId = parsedToken.sub;
} catch (e) {
// parseJwt throws an error in case of signature mismatch or jwt is expired
res.clearCookie("jwt"); // We need to do this otherwise it will be an infinite loop
throw new AuthenticationError(e.message);
}
}

return {
userId,
setCookie: setCookie(res),
clearCookie: clearCookie(res),
};
};
10 changes: 10 additions & 0 deletions src/buildExpressServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApolloServer } from "apollo-server-express";
import express, { Application } from "express";
import cookieParser from "cookie-parser";

export default function buildExpressServer(apolloServer: ApolloServer): Application {
const app = express();
app.use(cookieParser());
apolloServer.applyMiddleware({ app });
return app;
}
47 changes: 47 additions & 0 deletions src/buildServerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { AuthenticationError } from "apollo-server-express";
import { Request, Response } from "express";
import { setCookie, clearCookie } from "./util/cookieUtils";
import { parseJwt } from "./util/jwtUtils";

export interface ExpressContext {
req: Request;
res: Response;
}

export interface ServerContext {
getUserId(): string;
setJwt(token: string): void;
clearJwt(): void;
// TODO: include userId and maybe auth scope, which will be parsed from req cookie
}

export type BuildExpressServerContext = (expressContext: ExpressContext) => ServerContext;

export const buildExpressServerContext: BuildExpressServerContext = function ({
req,
res,
}: {
req: Request;
res: Response;
}): ServerContext {
// TODO: Clean up later
let userId: string;
const jwt: string = req.cookies.jwt;

if (jwt) {
try {
const parsedToken = parseJwt(jwt);
userId = parsedToken.sub;
} catch (e) {
// parseJwt throws an error in case of signature mismatch or jwt is expired
res.clearCookie("jwt"); // We need to do this otherwise it will be an infinite loop
throw new AuthenticationError(e.message);
}
}

return {
getUserId: () => userId,
setJwt: setCookie(res),
clearJwt: clearCookie(res),
};
};
51 changes: 39 additions & 12 deletions src/graphql/resolver/userResolver.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,59 @@
import { Resolvers } from "../../types/gqlGeneratedTypes";
import { Resolvers, User } from "../../types/gqlGeneratedTypes";
import UserService from "../../service/UserService";
import UserResolverValidator from "../../validator/UserResolverValidator";
import { ServerContext } from "../../buildContext";
import { ServerContext } from "../../buildServerContext";
import { AuthenticationError } from "apollo-server-express";
import { JWTPayload, generateJwt } from "../../util/jwtUtils";

const userResolver = (userResolverValidator: UserResolverValidator, userService: UserService): Resolvers => {
return {
Query: {
user: (_root, args, context: ServerContext) => {
return userResolverValidator.getOne(args, context).then((args) => userService.getOne(args, context));
user: (_root, args, context: ServerContext): Promise<User> => {
return userResolverValidator.getOne(args, context).then((args) => userService.getOne(args));
},

users: (_root, args, context: ServerContext) => {
users: (_root, args, context: ServerContext): Promise<User[]> => {
// TODO: Add validation once we need to validate params that are used for pagination / sorting etc.
return userService.getMany(args, context);
return userService.getMany(args);
},

me: (_root, _args, context: ServerContext) => {
return userService.me(context);
me: (_root, _args, context: ServerContext): Promise<User> => {
const userId = context.getUserId();
if (!userId) {
throw new AuthenticationError("You are not logged in!");
}

return userService.getOne({ id: userId });
},
},

Mutation: {
login: (_root, args, context: ServerContext) => {
return userResolverValidator.login(args, context).then((args) => userService.login(args, context));
login: (_root, args, context: ServerContext): Promise<User> => {
return userResolverValidator.login(args, context).then(async (args) => {
const isValidPassword = await userService.checkPassword(args);
if (!isValidPassword) {
throw new AuthenticationError("Login failed!");
}
// TODO: Move below into jwt auth service
// Make a JWT and return it in the body as well as the cookie
const user = await userService.getOne({ email: args.email });
const payload: JWTPayload = {
sub: user.id,
};
const token = generateJwt(payload);

context.setJwt(token);
return { ...user, token };
});
},

logout: (_root, _args, context: ServerContext) => {
return userService.logout(context);
logout: (_root, _args, context: ServerContext): boolean => {
const userId = context.getUserId();
if (userId) {
context.clearJwt();
return true;
}
return false;
},
},
};
Expand Down
17 changes: 7 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import express from "express";
import { buildPersistenceContext, buildResolverContext, buildExpressServerContext } from "./buildContext";
import { buildPersistenceContext, buildResolverContext } from "./buildContext";
import { buildExpressServerContext } from "./buildServerContext";
import buildSchema from "./buildSchema";
import buildServer from "./buildServer";
import cookieParser from "cookie-parser";
import buildApolloServer from "./buildApolloServer";
import buildExpressServer from "./buildExpressServer";

const persistenceContext = buildPersistenceContext();
const resolverContext = buildResolverContext(persistenceContext);
const schema = buildSchema(resolverContext);
const server = buildServer(schema, buildExpressServerContext);
const app = express();
app.use(cookieParser());
const apolloServer = buildApolloServer(schema, buildExpressServerContext);
const app = buildExpressServer(apolloServer);

server.applyMiddleware({ app });

app.listen({ port: 4000 }, () => console.log(`Server ready at http://localhost:4000${server.graphqlPath}`));
app.listen({ port: 4000 }, () => console.log(`Server ready at http://localhost:4000${apolloServer.graphqlPath}`));
39 changes: 5 additions & 34 deletions src/service/UserService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { User } from "../types/gqlGeneratedTypes";
import { EntityService } from "./EntityService";
import bcrypt from "bcryptjs";
import { AuthenticationError } from "apollo-server-express";
import UserDao from "../dao/UserDao";
import { ServerContext } from "../buildContext";
import { generateJwt, JWTPayload } from "../util/jwtUtils";

export interface UserServiceGetOneArgs {
id?: string | null;
Expand All @@ -25,46 +22,20 @@ export interface UserServiceLoginArgs {
export default class UserService implements EntityService<User> {
constructor(private userDao: UserDao) {}

async getOne(args: UserServiceGetOneArgs, context: ServerContext): Promise<User> {
async getOne(args: UserServiceGetOneArgs): Promise<User> {
return this.userDao.getOne(args);
}

async getMany(args: UserServiceGetManyArgs, context: ServerContext): Promise<User[]> {
async getMany(args: UserServiceGetManyArgs): Promise<User[]> {
return this.userDao.getMany(args);
}

async login(args: UserServiceLoginArgs, context: ServerContext): Promise<User> {
async checkPassword(args: UserServiceLoginArgs): Promise<boolean> {
const user: User = await this.userDao.getOne({ email: args.email });
const correctPassword = await bcrypt.compare(args.password, user.passwordHash);
if (!correctPassword) {
throw new AuthenticationError("Login failed!");
return false;
}

// Make a JWT and return it in the body as well as the cookie
const payload: JWTPayload = {
sub: user.id,
};
const token = generateJwt(payload);

context.setCookie(token);
return { ...user, token };
}

async me(context: ServerContext): Promise<User> {
const { userId } = context;
if (!userId) {
throw new AuthenticationError("You are not logged in!");
}

return this.userDao.getOne({ id: userId });
}

async logout(context: ServerContext): Promise<boolean> {
const { userId } = context;
if (userId) {
context.clearCookie();
return true;
}
return false;
return true;
}
}
2 changes: 1 addition & 1 deletion src/util/cookieUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const cookieOptions: CookieOptions = {
httpOnly: true,
sameSite: "strict", // May need to change if frontend & backend are hosted on different servers
};

// TODO: Put in decorator
export const setCookie = (res: Response) => (token: string): void => {
res.cookie("jwt", token, cookieOptions);
};
Expand Down
2 changes: 1 addition & 1 deletion src/validator/UserResolverValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { QueryUserArgs, User, MutationLoginArgs } from "../types/gqlGeneratedTyp
import { ensureExists } from "../util/ensureExists";
import { UserServiceGetOneArgs, UserServiceLoginArgs } from "../service/UserService";
import UserDao from "../dao/UserDao";
import { ServerContext } from "../buildContext";
import { ServerContext } from "../buildServerContext";

export default class UserResolverValidator {
constructor(private userDao: UserDao) {}
Expand Down