From 278261fd332031060b7f1ecef691cf9871b3d5e3 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:19:38 -0700 Subject: [PATCH 1/9] create utils for validating email and password --- server/utils/validateEmail.ts | 6 ++++++ server/utils/validatePassword.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 server/utils/validateEmail.ts create mode 100644 server/utils/validatePassword.ts diff --git a/server/utils/validateEmail.ts b/server/utils/validateEmail.ts new file mode 100644 index 0000000..29c0be6 --- /dev/null +++ b/server/utils/validateEmail.ts @@ -0,0 +1,6 @@ +export const isValidEmail = (email: string) => { + if (!email || typeof email !== 'string') return false; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; diff --git a/server/utils/validatePassword.ts b/server/utils/validatePassword.ts new file mode 100644 index 0000000..652406d --- /dev/null +++ b/server/utils/validatePassword.ts @@ -0,0 +1,13 @@ +import { ValidationError } from '../errors'; + +export const validatePassword = (password?: string) => { + if (!password || typeof password !== 'string') { + return new ValidationError('Please provide a valid password', 'password'); + } + + if (password.length < 7) { + return new ValidationError('Password must be at least 7 characters long', 'password'); + } + + return true; +}; From 02d6076f0f1539553703b866b4f7b5d4ee9853d2 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:19:56 -0700 Subject: [PATCH 2/9] create helper function to attach cookie --- server/utils/attachAuthCookie.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 server/utils/attachAuthCookie.ts diff --git a/server/utils/attachAuthCookie.ts b/server/utils/attachAuthCookie.ts new file mode 100644 index 0000000..b9224ac --- /dev/null +++ b/server/utils/attachAuthCookie.ts @@ -0,0 +1,11 @@ +import { Response } from 'express'; + +export const attachAuthCookie = (res: Response, token: string) => { + // Attach JWT token to cookie + res.cookie('token', token, { + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60), + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + }); +}; From d1496bdd89848608ca5afe2f9a461e32c04c2312 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:20:13 -0700 Subject: [PATCH 3/9] simplify util imports --- server/utils/index.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 server/utils/index.ts diff --git a/server/utils/index.ts b/server/utils/index.ts new file mode 100644 index 0000000..0283043 --- /dev/null +++ b/server/utils/index.ts @@ -0,0 +1,4 @@ +export * from './attachAuthCookie'; +export * from './generateToken'; +export * from './validateEmail'; +export * from './validatePassword'; From 2f5f6944e332f33aa1a4ca62d3d46fe040603fe1 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:20:28 -0700 Subject: [PATCH 4/9] remove replaced registerUser tests --- __tests__/userController.tests.ts | 42 +------------------------------ 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/__tests__/userController.tests.ts b/__tests__/userController.tests.ts index d7b16c1..aaef188 100644 --- a/__tests__/userController.tests.ts +++ b/__tests__/userController.tests.ts @@ -1,10 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { - registerUser, - authUser, - getUserById, - deleteUserByEmail, -} from '../server/controllers/userController'; +import { authUser, getUserById, deleteUserByEmail } from '../server/controllers/userController'; import User from '../server/models/userModel'; jest.mock('../server/models/userModel', () => ({ @@ -30,41 +25,6 @@ xdescribe('User Controller Tests', () => { }; }); - describe('registerUser function', () => { - xit('should handle user registration', async () => { - (User.findOne as jest.Mock).mockResolvedValue(null); - (User.create as jest.Mock).mockResolvedValue({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - password: 'hashedPassword', - }); - - mockRequest.body = { - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - password: 'password', - }; - - await registerUser(mockRequest as Request, mockResponse as Response, mockNext); - - expect(mockResponse.json).toHaveBeenCalledWith({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - }); - - expect(mockResponse.cookie).toHaveBeenCalledWith( - 'token', - 'someFakeToken', - expect.any(Object), - ); - }); - }); - describe('authUser function', () => { it('should handle user authentication', async () => { (User.findOne as jest.Mock).mockResolvedValue({ From 163e886337167f9fbb2fc908dcb3f4a4442c10fc Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:21:15 -0700 Subject: [PATCH 5/9] abstract out registerUser - refactor error handling and test middleware --- server/controllers/userController/index.ts | 3 + .../registerUser/registerUser.test.ts | 188 ++++++++++++++++++ .../registerUser/registerUser.ts | 83 ++++++++ 3 files changed, 274 insertions(+) create mode 100644 server/controllers/userController/index.ts create mode 100644 server/controllers/userController/registerUser/registerUser.test.ts create mode 100644 server/controllers/userController/registerUser/registerUser.ts diff --git a/server/controllers/userController/index.ts b/server/controllers/userController/index.ts new file mode 100644 index 0000000..4e5195e --- /dev/null +++ b/server/controllers/userController/index.ts @@ -0,0 +1,3 @@ +import registerUser from './registerUser/registerUser'; + +export { registerUser }; diff --git a/server/controllers/userController/registerUser/registerUser.test.ts b/server/controllers/userController/registerUser/registerUser.test.ts new file mode 100644 index 0000000..4406cff --- /dev/null +++ b/server/controllers/userController/registerUser/registerUser.test.ts @@ -0,0 +1,188 @@ +import app from '../../../app'; +import request, { Response } from 'supertest'; +import { IGraduateInvitation } from '../../../types/graduateInvitation'; +import GraduateInvitation from '../../../models/graduateInvitationModel'; +import User from '../../../models/userModel'; +import Profile from '../../../models/profileModel'; +import { BadRequestError } from '../../../errors'; +import { IUser } from '../../../types/user'; +import { IProfile } from '../../../types/profile'; + +const testEmail = 'tester@codehammers.com'; +const testPassword = 'ilovetesting'; +const testToken = 'testToken'; + +const createValidInvite = async () => { + const invite = await GraduateInvitation.create({ + email: testEmail, + token: testToken, + tokenExpiry: new Date(Date.now() + 1000 * 60 * 60 * 24), + isRegistered: false, + firstName: 'Homer', + lastName: 'Simpson', + cohort: 'ECRI 44', + lastEmailSent: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), + }); + return invite; +}; + +describe('Tests for userController.registerUser', () => { + let validInvite: IGraduateInvitation; + const baseUrl = '/api/users/register'; + + describe('Request failure tests', () => { + beforeEach(async () => { + validInvite = await createValidInvite(); + }); + + afterEach(async () => { + await GraduateInvitation.deleteMany(); + await User.deleteMany(); + await Profile.deleteMany(); + }); + it('🧪 Fails if no invitation token is provided', async () => { + const response = await request(app).post(baseUrl).send({ + email: testEmail, + password: testPassword, + }); + + expect(response.status).toEqual(400); + expect(response.body).toEqual(new BadRequestError('Invalid token').serializeErrors()); + }); + + it('🧪 Fails if password is invalid', async () => { + const responseOne = await request(app).post(`${baseUrl}?token=${validInvite.token}`).send({ + email: testEmail, + password: 'short', + }); + + expect(responseOne.status).toEqual(400); + expect(responseOne.body[0].message).toEqual('Password must be at least 7 characters long'); + expect(responseOne.body[0].field).toEqual('password'); + + const responseTwo = await request(app).post(`${baseUrl}?token=${validInvite.token}`).send({ + email: testEmail, + }); + + expect(responseTwo.status).toEqual(400); + expect(responseTwo.body[0].message).toEqual('Please provide a valid password'); + expect(responseTwo.body[0].field).toEqual('password'); + }); + + it('🧪 Fails if email is invalid', async () => { + const response = await request(app).post(`${baseUrl}?token=${validInvite.token}`).send({ + email: 'thisaintanemail', + password: testPassword, + }); + + expect(response.status).toEqual(400); + expect(response.body[0].message).toEqual('Invalid Email'); + expect(response.body[0].field).toEqual('email'); + }); + + it('🧪 Fails if no valid invitation found', async () => { + const response = await request(app).post(`${baseUrl}?token=derp`).send({ + email: 'derp@derp.com', + password: testPassword, + }); + + expect(response.status).toEqual(400); + expect(response.body).toEqual(new BadRequestError('Invalid invite').serializeErrors()); + }); + + it('🧪 Fails if invitation is expired', async () => { + const expiredEmail = 'expired@old.com'; + const expiredToken = 'oldAssToken'; + const expiredInvite = await GraduateInvitation.create({ + email: expiredEmail, + token: expiredToken, + tokenExpiry: new Date(Date.now() - 1000 * 60), + isRegistered: false, + firstName: 'Marge', + lastName: 'Simpson', + cohort: 'FTRI 99', + lastEmailSent: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), + }); + + const response = await request(app).post(`${baseUrl}?token=${expiredInvite.token}`).send({ + email: expiredEmail, + password: 'ohDidIJoinTooLate', + }); + + expect(response.status).toEqual(400); + expect(response.body).toEqual( + new BadRequestError( + 'Token is expired, please reach out to TODO to get a new invitation', + ).serializeErrors(), + ); + }); + + it('🧪 Fails if user is already registered', async () => { + const registeredEmail = 'registered@here.com'; + const token = 'token'; + const registeredInvite = await GraduateInvitation.create({ + email: registeredEmail, + token, + tokenExpiry: new Date(Date.now() + 1000 * 60 * 60), + isRegistered: true, + firstName: 'Lisa', + lastName: 'Simpson', + cohort: 'FTRI 99', + lastEmailSent: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), + }); + + const response = await request(app).post(`${baseUrl}?token=${registeredInvite.token}`).send({ + email: registeredEmail, + password: 'ohImAlreadyHere', + }); + + expect(response.status).toEqual(400); + expect(response.body).toEqual( + new BadRequestError('Looks like you already registered bub').serializeErrors(), + ); + }); + }); + + describe('Successful request tests', () => { + let successResponse: Response; + let invitation: IGraduateInvitation | null; + let user: IUser | null; + let profile: IProfile | null; + beforeAll(async () => { + validInvite = await createValidInvite(); + successResponse = await request(app) + .post(`${baseUrl}?token=${validInvite.token}`) + .send({ email: testEmail, password: testPassword }); + invitation = await GraduateInvitation.findOne({ email: testEmail }); + user = await User.findOne({ email: testEmail }); + profile = await Profile.findOne({ user: user?._id }); + }); + + afterAll(async () => { + await GraduateInvitation.deleteMany(); + await User.deleteMany(); + await Profile.deleteMany(); + }); + + it('🧪 Sends back created user with 201', async () => { + expect(successResponse.status).toEqual(201); + expect(successResponse.body.email).toEqual(testEmail); + }); + + it('🧪 Updates the GraduateInvitation Document correctly', async () => { + expect('ggg').toEqual('ggg'); + expect(invitation?.isRegistered).toBe(true); + expect(invitation?.registeredAt).toBeTruthy(); + }); + + it('🧪 Creates the user and profile', async () => { + expect(user?.lastName).toEqual('Simpson'); + expect(profile?.lastName).toEqual('Simpson'); + }); + + it('🧪 Sends back a cookie with a token', async () => { + const cookie = successResponse.get('Set-Cookie') as string[]; + expect(cookie[0].split('=')[0]).toEqual('token'); + }); + }); +}); diff --git a/server/controllers/userController/registerUser/registerUser.ts b/server/controllers/userController/registerUser/registerUser.ts new file mode 100644 index 0000000..b508dd7 --- /dev/null +++ b/server/controllers/userController/registerUser/registerUser.ts @@ -0,0 +1,83 @@ +import { Request, Response } from 'express'; +import GraduateInvitation from '../../../models/graduateInvitationModel'; +import User from '../../../models/userModel'; +import Profile from '../../../models/profileModel'; +import generateToken from '../../../utils/generateToken'; +import { BadRequestError, RequestValidationError, ValidationError } from '../../../errors'; +import { isValidEmail, validatePassword, attachAuthCookie } from '../../../utils'; + +// ENDPOINT POST api/users/register +// PURPOSE Register a new user +// ACCESS Public +const registerUser = async (req: Request, res: Response) => { + const { email, password } = req.body; + const { token } = req.query; + + // Check token exists + if (!token) { + throw new BadRequestError('Invalid token'); + } + // Validations + const validationErrors: ValidationError[] = []; + // Check password exists + const passwordValidation = validatePassword(password); + if (passwordValidation instanceof ValidationError) { + validationErrors.push(passwordValidation); + } + // Validate email + if (!isValidEmail(email)) { + validationErrors.push(new ValidationError('Invalid Email', 'email')); + } + if (validationErrors.length) { + throw new RequestValidationError(validationErrors); + } + + // Find users invitation + const invitation = await GraduateInvitation.findOne({ + email, + token, + }); + + // Check if valid invitation is found + if (!invitation) { + throw new BadRequestError('Invalid invite'); + } + // Check if token is expired + if (invitation.tokenExpiry.getTime() < Date.now()) { + throw new BadRequestError('Token is expired, please reach out to TODO to get a new invitation'); + } + if (invitation.isRegistered) { + // Check if user is already registered + throw new BadRequestError('Looks like you already registered bub'); + } + + // Create new user from valid invite data + const user = await User.create({ + firstName: invitation.firstName, + lastName: invitation.lastName, + email, + password, + }); + + // Update invitation + invitation.isRegistered = true; + invitation.registeredAt = new Date(); + await invitation.save(); + + // Create base profile for user + await Profile.create({ + user: user._id, + firstName: invitation.firstName, + lastName: invitation.lastName, + cohort: invitation.cohort, + }); + + // Create auth token and attach to cookie + const authToken = generateToken(user._id.toString()); + attachAuthCookie(res, authToken); + + // Send back the new user + return res.status(201).send(user); +}; + +export default registerUser; From f89eff784035d85b268cb9a6b317da13e94c8be3 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:21:52 -0700 Subject: [PATCH 6/9] remove registerUser - moved to own file --- server/controllers/userController.ts | 67 +--------------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/server/controllers/userController.ts b/server/controllers/userController.ts index 3af1312..2a7f44a 100644 --- a/server/controllers/userController.ts +++ b/server/controllers/userController.ts @@ -1,73 +1,8 @@ import User from '../models/userModel'; -import Profile from '../models/profileModel'; import generateToken from '../utils/generateToken'; import { Request, Response, NextFunction } from 'express'; import { UserType } from '../types/user'; -import GraduateInvitation from '../models/graduateInvitationModel'; -// ENDPOINT POST api/users/register -// PURPOSE Register a new user -// ACCESS Public -const registerUser = async (req: Request, res: Response, next: NextFunction) => { - const { email, password } = req.body; - const { token } = req.query; - - try { - const isValidEmail = email.match(/[\w\d.]+@[a-z]+.[\w]+$/gim); - if (!isValidEmail) { - return res.status(400).json('Invalid Email'); - } - - const invitation = await GraduateInvitation.findOne({ - email, - token, - tokenExpiry: { $gt: new Date() }, - isRegistered: false, - }); - - //TODO Needs better error handling - this can trigger with situaions other than bad or missing token - if (!invitation) { - return res.status(400).json({ message: 'Invalid or expired registration token' }); - } - const userExists: UserType | null = await User.findOne({ email }); - if (userExists) { - return res.status(400).json({ message: 'User already exists!' }); - } - const user: UserType = await User.create({ - firstName: invitation.firstName, - lastName: invitation.lastName, - email, - password, - }); - - if (user) { - invitation.isRegistered = true; - await invitation?.save(); - await Profile.create({ - user: user._id, - firstName: invitation.firstName, - lastName: invitation.lastName, - cohort: invitation.cohort, - }); - res.locals.user = { - _id: user._id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - }; - - res.cookie('token', generateToken(user._id.toString())); - return res.status(201).json(res.locals.user); - } - } catch (error) { - console.error('Error during user signup:', error); - return next({ - log: 'Express error in createUser Middleware', - status: 503, - message: { err: 'An error occurred during sign-up' }, - }); - } -}; // ENDPOINT POST api/users/login // PURPOSE Authenticate User and get token // ACCESS Public @@ -165,4 +100,4 @@ const deleteUserByEmail = async (req: Request, res: Response, next: NextFunction } }; -export { registerUser, authUser, getUserById, deleteUserByEmail }; +export { authUser, getUserById, deleteUserByEmail }; From 70f722d30f2b5d481f8b675e66a18c02b53af762 Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:22:05 -0700 Subject: [PATCH 7/9] update import for registerUser --- server/routes/userRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/userRoutes.ts b/server/routes/userRoutes.ts index 6f9dd4e..8525eed 100644 --- a/server/routes/userRoutes.ts +++ b/server/routes/userRoutes.ts @@ -1,7 +1,7 @@ import express from 'express'; import { protect } from '../middleware/authMiddleware'; +import registerUser from '../controllers/userController/registerUser/registerUser'; import { - registerUser, authUser, getUserById, // deleteUserByEmail, From 74d56b2ccdb4ed5b6f09e9907b39c1eae3961b4d Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Wed, 26 Jun 2024 11:48:50 -0700 Subject: [PATCH 8/9] update email contact for expired invitation tokens --- .../userController/registerUser/registerUser.test.ts | 2 +- .../controllers/userController/registerUser/registerUser.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/controllers/userController/registerUser/registerUser.test.ts b/server/controllers/userController/registerUser/registerUser.test.ts index 4406cff..66434bc 100644 --- a/server/controllers/userController/registerUser/registerUser.test.ts +++ b/server/controllers/userController/registerUser/registerUser.test.ts @@ -112,7 +112,7 @@ describe('Tests for userController.registerUser', () => { expect(response.status).toEqual(400); expect(response.body).toEqual( new BadRequestError( - 'Token is expired, please reach out to TODO to get a new invitation', + 'Token is expired, please reach out to brok3turtl3@gmail.com to get a new invitation', ).serializeErrors(), ); }); diff --git a/server/controllers/userController/registerUser/registerUser.ts b/server/controllers/userController/registerUser/registerUser.ts index b508dd7..8f51a30 100644 --- a/server/controllers/userController/registerUser/registerUser.ts +++ b/server/controllers/userController/registerUser/registerUser.ts @@ -44,7 +44,9 @@ const registerUser = async (req: Request, res: Response) => { } // Check if token is expired if (invitation.tokenExpiry.getTime() < Date.now()) { - throw new BadRequestError('Token is expired, please reach out to TODO to get a new invitation'); + throw new BadRequestError( + 'Token is expired, please reach out to brok3turtl3@gmail.com to get a new invitation', + ); } if (invitation.isRegistered) { // Check if user is already registered From 62ab5c5248834e4a2dcaf3babf298c21365fbe2e Mon Sep 17 00:00:00 2001 From: seantokuzo Date: Fri, 28 Jun 2024 10:22:31 -0700 Subject: [PATCH 9/9] remove og mockity mocks --- __tests__/userController.tests.ts | 102 ------------------------------ 1 file changed, 102 deletions(-) delete mode 100644 __tests__/userController.tests.ts diff --git a/__tests__/userController.tests.ts b/__tests__/userController.tests.ts deleted file mode 100644 index aaef188..0000000 --- a/__tests__/userController.tests.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { authUser, getUserById, deleteUserByEmail } from '../server/controllers/userController'; -import User from '../server/models/userModel'; - -jest.mock('../server/models/userModel', () => ({ - findOne: jest.fn(), - create: jest.fn(), - findOneAndDelete: jest.fn(), -})); -jest.mock('../server/utils/generateToken', () => () => 'someFakeToken'); - -xdescribe('User Controller Tests', () => { - let mockRequest: Partial; - let mockResponse: Partial; - //TODO Add some error test for global error handler - const mockNext: NextFunction = jest.fn(); - - beforeEach(() => { - mockRequest = {}; - mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - locals: {}, - cookie: jest.fn().mockReturnThis(), - }; - }); - - describe('authUser function', () => { - it('should handle user authentication', async () => { - (User.findOne as jest.Mock).mockResolvedValue({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - password: 'hashedPassword', - matchPassword: jest.fn().mockResolvedValue(true), - }); - - mockRequest.body = { email: 'john@example.com', password: 'password' }; - - await authUser(mockRequest as Request, mockResponse as Response, mockNext); - - expect(mockResponse.json).toHaveBeenCalledWith({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - }); - expect(mockResponse.cookie).toHaveBeenCalledWith( - 'token', - 'someFakeToken', - expect.any(Object), - ); - }); - }); - - describe('getUserById function', () => { - it('should get a user by ID', async () => { - (User.findOne as jest.Mock).mockResolvedValue({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - }); - - mockRequest.params = { userId: 'someId' }; - - await getUserById(mockRequest as Request, mockResponse as Response, mockNext); - - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - }), - ); - }); - }); - - describe('deleteUserByEmail function', () => { - it('should delete a user by email', async () => { - (User.findOneAndDelete as jest.Mock).mockResolvedValue({ - _id: 'someId', - firstName: 'John', - lastName: 'Doh', - email: 'john@example.com', - }); - - mockRequest.params = { email: 'john@example.com' }; - - await deleteUserByEmail(mockRequest as Request, mockResponse as Response, mockNext); - - expect(mockResponse.status).toHaveBeenCalledWith(200); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - msg: 'User successfully deleted!', - }), - ); - }); - }); -});