From 96ba6aebe30d26aaf98335043cb286f1b474e7b3 Mon Sep 17 00:00:00 2001 From: Heisjabo Date: Wed, 24 Apr 2024 22:33:43 +0200 Subject: [PATCH] feat(edit-profile): Implement password update feature - Implement the feature to allow users update their password - Added validation check for old password, new password and confirm password - hash the new password before updating - documented the feature using swagger [Delivers #187419174] --- __test__/user.test.ts | 172 ++++++++++++++++------ src/controllers/userControllers.ts | 31 +++- src/docs/swagger.ts | 8 +- src/docs/users.ts | 39 +++++ src/middlewares/isLoggedIn.ts | 40 +++++ src/routes/userRoutes.ts | 7 +- src/schemas/passwordUpdate.ts | 10 ++ src/{helpers => utils}/comparePassword.ts | 0 src/utils/jsonwebtoken.ts | 5 + 9 files changed, 265 insertions(+), 47 deletions(-) create mode 100644 src/middlewares/isLoggedIn.ts create mode 100644 src/schemas/passwordUpdate.ts rename src/{helpers => utils}/comparePassword.ts (100%) diff --git a/__test__/user.test.ts b/__test__/user.test.ts index 32e9a70..724f905 100644 --- a/__test__/user.test.ts +++ b/__test__/user.test.ts @@ -5,53 +5,67 @@ import User from "../src/sequelize/models/users"; import * as userServices from "../src/services/user.service"; import sequelize, { connect } from "../src/config/dbConnection"; -const userData:any = { - name: 'yvanna', - username: 'testuser', - email: 'test1@gmail.com', - password:'test1234', - }; +const userData: any = { + name: "yvanna", + username: "testuser", + email: "test1@gmail.com", + password: "test1234", +}; - const loginData:any = { - email:'test1@gmail.com', - password:"test1234" - } +const userTestData = { + newPassword: "Test@123", + confirmPassword: "Test@123", + wrongPassword: "Test456", +}; + +const loginData: any = { + email: "test1@gmail.com", + password: "test1234", +}; describe("Testing user Routes", () => { beforeAll(async () => { try { await connect(); - await User.destroy({truncate:true}) + await User.destroy({ truncate: true }); } catch (error) { sequelize.close(); } }, 20000); - afterAll(async () => { + afterAll(async () => { await User.destroy({ truncate: true }); - await sequelize.close(); -}); -describe("Testing user authentication", () => { -test('should return 201 and create a new user when registering successfully', async () => { -const response = await request(app) -.post('/api/v1/users/register') -.send(userData); -expect(response.status).toBe(201); }, 20000); + await sequelize.close(); + }); + let token: any; + describe("Testing user authentication", () => { + test("should return 201 and create a new user when registering successfully", async () => { + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); + expect(response.status).toBe(201); + }, 20000); -test('should return 409 when registering with an existing email', async () => { - User.create(userData) - const response = await request(app) - .post('/api/v1/users/register') - .send(userData); - expect(response.status).toBe(409); }, 20000); + test("should return 409 when registering with an existing email", async () => { + User.create(userData); + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); + expect(response.status).toBe(409); + }, 20000); -test('should return 400 when registering with an invalid credential', async () => { - const userData = { - email: 'test@mail.com', name: "", username: 'existinguser', }; - const response = await request(app) - .post('/api/v1/users/register') - .send(userData); - - expect(response.status).toBe(400); }, 20000); }); + test("should return 400 when registering with an invalid credential", async () => { + const userData = { + email: "test@mail.com", + name: "", + username: "existinguser", + }; + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); + + expect(response.status).toBe(400); + }, 20000); + }); test("should return all users in db --> given '/api/v1/users'", async () => { const spy = jest.spyOn(User, "findAll"); @@ -60,19 +74,89 @@ test('should return 400 when registering with an invalid credential', async () = expect(spy).toHaveBeenCalled(); expect(spy2).toHaveBeenCalled(); }, 20000); - test("Should return status 401 to indicate Unauthorized user",async() =>{ - const loggedInUser ={ - email:userData.email, - password:"test", + test("Should return status 401 to indicate Unauthorized user", async () => { + const loggedInUser = { + email: userData.email, + password: "test", }; - const spyonOne = jest.spyOn(User,"findOne").mockResolvedValueOnce({ + const spyonOne = jest.spyOn(User, "findOne").mockResolvedValueOnce({ //@ts-ignore - email:userData.email, - password:loginData.password, + email: userData.email, + password: loginData.password, }); - const response = await request(app).post("/api/v1/users/login") - .send(loggedInUser) + const response = await request(app) + .post("/api/v1/users/login") + .send(loggedInUser); expect(response.body.status).toBe(401); spyonOne.mockRestore(); }); -}) + + test("should log a user in to retrieve a token", async () => { + const response = await request(app).post("/api/v1/users/login").send({ + email: userData.email, + password: userData.password, + }); + expect(response.status).toBe(200); + token = response.body.token; + }); + + test("should return 400 when adding an extra field while updating password", async () => { + const response = await request(app) + .put("/api/v1/users/passwordupdate") + .send({ + oldPassword: userData.password, + newPassword: userTestData.newPassword, + confirmPassword: userTestData.confirmPassword, + role: "seller", + }) + .set("Authorization", "Bearer " + token); + expect(response.status).toBe(400); + }); + + test("should return 401 when updating password without authorization", async () => { + const response = await request(app) + .put("/api/v1/users/passwordupdate") + .send({ + oldPassword: userData.password, + newPassword: userTestData.newPassword, + confirmPassword: userTestData.confirmPassword, + }); + expect(response.status).toBe(401); + }); + + test("should return 200 when password is updated", async () => { + const response = await request(app) + .put("/api/v1/users/passwordupdate") + .send({ + oldPassword: userData.password, + newPassword: userTestData.newPassword, + confirmPassword: userTestData.confirmPassword, + }) + .set("Authorization", "Bearer " + token); + expect(response.status).toBe(200); + }); + + test("should return 400 when confirm password and new password doesn't match", async () => { + const response = await request(app) + .put("/api/v1/users/passwordupdate") + .send({ + oldPassword: userData.password, + newPassword: userTestData.newPassword, + confirmPassword: userTestData.wrongPassword, + }) + .set("Authorization", "Bearer " + token); + expect(response.status).toBe(400); + }); + + test("should return 400 when old password is incorrect", async () => { + const response = await request(app) + .put("/api/v1/users/passwordupdate") + .send({ + oldPassword: userTestData.wrongPassword, + newPassword: userTestData.newPassword, + confirmPassword:userTestData.wrongPassword, + }) + .set("Authorization", "Bearer " + token); + expect(response.status).toBe(400); + }); +}); diff --git a/src/controllers/userControllers.ts b/src/controllers/userControllers.ts index f3fed45..024acb6 100644 --- a/src/controllers/userControllers.ts +++ b/src/controllers/userControllers.ts @@ -1,9 +1,10 @@ import { Request, Response } from "express"; import * as userService from "../services/user.service"; import { generateToken } from "../utils/jsonwebtoken"; -import { comparePasswords } from "../helpers/comparePassword"; +import { comparePasswords } from "../utils/comparePassword"; import { loggedInUser} from "../services/user.service"; import { createUserService, getUserByEmail } from "../services/user.service"; +import { hashedPassword } from "../utils/hashPassword"; export const fetchAllUsers = async (req: Request, res: Response) => { try { @@ -79,3 +80,31 @@ export const createUserController = async (req: Request, res: Response) => { res.status(500).json({ error: err }); } }; + +export const updatePassword = async (req: Request, res: Response) => { + const { oldPassword, newPassword, confirmPassword } = req.body; + try { + // @ts-ignore + const user = await getUserByEmail(req.user.email); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + const isPasswordValid = await comparePasswords(oldPassword, user.password); + if (!isPasswordValid) { + return res.status(400).json({ message: 'Old password is incorrect' }); + } + + if (newPassword !== confirmPassword) { + return res.status(400).json({ message: 'New password and confirm password do not match' }); + } + const password = await hashedPassword(newPassword); + user.password = password; + await user.save(); + return res.status(200).json({ message: 'Password updated successfully' }); + + } catch(err: any){ + return res.status(500).json({ + message: err.message + }) + } +} diff --git a/src/docs/swagger.ts b/src/docs/swagger.ts index e7f82ec..cb7a963 100644 --- a/src/docs/swagger.ts +++ b/src/docs/swagger.ts @@ -6,7 +6,9 @@ import { getUsers, loginAsUser, userSchema, - loginSchema + loginSchema, + updatePasswordSchema, + passwordUpdate } from "./users"; const docRouter = express.Router(); @@ -43,12 +45,16 @@ const options = { "/api/v1/users/login": { post: loginAsUser }, + "/api/v1/users/passwordupdate": { + put: passwordUpdate + } }, components: { schemas: { User: userSchema, Login:loginSchema, + updatePassword: updatePasswordSchema }, securitySchemes: { bearerAuth: { diff --git a/src/docs/users.ts b/src/docs/users.ts index ca2f301..fe978c3 100644 --- a/src/docs/users.ts +++ b/src/docs/users.ts @@ -19,6 +19,21 @@ export const userSchema = { }, } +export const updatePasswordSchema = { + type: "object", + properties: { + oldPassword: { + type: "string", + }, + newPassword: { + type: "string", + }, + confirmPassword: { + type: "string", + } + } +} + export const loginSchema ={ properties :{ email: { @@ -101,4 +116,28 @@ export const getUsers = { } } }; + +export const passwordUpdate = { + tags: ["Users"], + security: [{ bearerAuth: [] }], + summary: "Update Password", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/updatePassword" + } + } + } + }, + responses: { + 200: { + description: "OK", + }, + 400: { + description: "Bad Request" + } + } +} \ No newline at end of file diff --git a/src/middlewares/isLoggedIn.ts b/src/middlewares/isLoggedIn.ts new file mode 100644 index 0000000..068b844 --- /dev/null +++ b/src/middlewares/isLoggedIn.ts @@ -0,0 +1,40 @@ +import { getUserByEmail } from "../services/user.service"; +import { Request, Response, NextFunction } from "express"; +import { decodeToken } from "../utils/jsonwebtoken" + +export const isLoggedIn = async (req: Request, res: Response, next: NextFunction) => { + let token: string | undefined = undefined; + try{ + if ( + req.headers.authorization && + req.headers.authorization.startsWith("Bearer ") + ) { + token = req.headers.authorization.split(" ")[1]; + } + if (!token) { + return res.status(401).json({ + status: "Unauthorized", + message: "You are not logged in. Please login to continue.", + }); + } + if (typeof token !== "string") { + throw new Error("Token is not a string."); + } + const decoded: any = await decodeToken(token) + const loggedUser: any = await getUserByEmail(decoded.email); + if (!loggedUser) { + return res.status(401).json({ + status: "Unauthorized", + message: "Token has expired. Please login again.", + }); + } + // @ts-ignore + req.user = loggedUser; + next(); + } catch (error: any) { + return res.status(401).json({ + status: "failed", + error: error.message + " Token has expired. Please login again.", + }); + } +} \ No newline at end of file diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 23d9f47..f9250fe 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -2,13 +2,16 @@ import { Router } from "express"; import { fetchAllUsers, createUserController, - userLogin } + userLogin, + updatePassword} from "../controllers/userControllers"; import { emailValidation, validateSchema, } from "../middleware/validator"; import signUpSchema from "../schemas/signUpSchema"; +import { isLoggedIn } from "../middlewares/isLoggedIn"; +import { passwordUpdateSchema } from "../schemas/passwordUpdate"; const userRoutes = Router(); @@ -19,6 +22,8 @@ userRoutes.post("/register", validateSchema(signUpSchema), createUserController ) +userRoutes.post("/register", createUserController); +userRoutes.put("/passwordupdate", isLoggedIn, validateSchema(passwordUpdateSchema), updatePassword) export default userRoutes; diff --git a/src/schemas/passwordUpdate.ts b/src/schemas/passwordUpdate.ts new file mode 100644 index 0000000..168653e --- /dev/null +++ b/src/schemas/passwordUpdate.ts @@ -0,0 +1,10 @@ +import Joi from "joi"; + +export const passwordUpdateSchema = Joi.object({ + oldPassword: Joi.string() + .min(6).max(20).required(), + newPassword: Joi.string() + .min(6).max(20).required(), + confirmPassword: Joi.string() + .min(6).max(20).required() +}).options({ allowUnknown: false }) \ No newline at end of file diff --git a/src/helpers/comparePassword.ts b/src/utils/comparePassword.ts similarity index 100% rename from src/helpers/comparePassword.ts rename to src/utils/comparePassword.ts diff --git a/src/utils/jsonwebtoken.ts b/src/utils/jsonwebtoken.ts index 53e9af0..a2557b9 100644 --- a/src/utils/jsonwebtoken.ts +++ b/src/utils/jsonwebtoken.ts @@ -9,3 +9,8 @@ export const generateToken = async(user:IUser) =>{ return accessToken; } +export const decodeToken = async (token: string) => { + const decoded = await verify(token, `${env.jwt_secret}`); + return decoded; +} +