diff --git a/__tests__/userController.tests.ts b/__tests__/userController.tests.ts deleted file mode 100644 index d7b16c10..00000000 --- a/__tests__/userController.tests.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { - registerUser, - 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('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({ - _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!', - }), - ); - }); - }); -}); diff --git a/server/controllers/userController.ts b/server/controllers/userController.ts index 3af1312d..2a7f44ad 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 }; diff --git a/server/controllers/userController/index.ts b/server/controllers/userController/index.ts new file mode 100644 index 00000000..4e5195e1 --- /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 00000000..66434bc9 --- /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 brok3turtl3@gmail.com 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 00000000..8f51a30f --- /dev/null +++ b/server/controllers/userController/registerUser/registerUser.ts @@ -0,0 +1,85 @@ +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 brok3turtl3@gmail.com 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; diff --git a/server/routes/userRoutes.ts b/server/routes/userRoutes.ts index 6f9dd4ec..8525eed2 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, diff --git a/server/utils/attachAuthCookie.ts b/server/utils/attachAuthCookie.ts new file mode 100644 index 00000000..b9224acd --- /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', + }); +}; diff --git a/server/utils/index.ts b/server/utils/index.ts new file mode 100644 index 00000000..02830439 --- /dev/null +++ b/server/utils/index.ts @@ -0,0 +1,4 @@ +export * from './attachAuthCookie'; +export * from './generateToken'; +export * from './validateEmail'; +export * from './validatePassword'; diff --git a/server/utils/validateEmail.ts b/server/utils/validateEmail.ts new file mode 100644 index 00000000..29c0be69 --- /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 00000000..652406d8 --- /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; +};