diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3d36e52..8120725 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,6 +34,13 @@ jobs: env: DB_CONNECTION: ${{ secrets.DB_CONNECTION }} TEST_DB: ${{ secrets.TEST_DB }} + SMTP_HOST: ${{ secrets.SMTP_HOST }} + SMTP_USER: ${{ secrets.SMTP_USER }} + SMTP_PASS: ${{ secrets.SMTP_PASS }} + SMTP_PORT: ${{ secrets.SMTP_PORT }} + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}}, + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}}, + GOOGLE_CALLBACK_URL: ${{secrets.GOOGLE_CALLBACK_URL}}, run: npm run test - name: Build application diff --git a/__test__/user.test.ts b/__test__/user.test.ts index b6ab8e4..9323212 100644 --- a/__test__/user.test.ts +++ b/__test__/user.test.ts @@ -3,20 +3,34 @@ import { beforeAll, afterAll, jest, test } from "@jest/globals"; import app from "../src/utils/server"; import User from "../src/sequelize/models/users"; import * as userServices from "../src/services/user.service"; +import * as mailServices from "../src/services/mail.service"; import sequelize, { connect } from "../src/config/dbConnection"; const userData: any = { - name: "yvanna", - username: "testuser", - email: "test1@gmail.com", - password: "test1234", + name: "yvanna5", + username: "testuser5", + email: "test15@gmail.com", + password: "test12345", +}; + +const dummySeller = { + name: "dummyseller", + username: "username", + email: "srukundo02@gmail.com", + password: "1234567890", + role: "seller", +}; +const userTestData = { + newPassword: "Test@123", + confirmPassword: "Test@123", + wrongPassword: "Test456", }; const loginData: any = { email: "test1@gmail.com", password: "test1234", }; -describe("Testing a user Routes", () => { +describe("Testing user Routes", () => { beforeAll(async () => { try { await connect(); @@ -30,17 +44,22 @@ describe("Testing a user Routes", () => { await User.destroy({ truncate: true }); 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); + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); expect(response.status).toBe(201); - }, 40000); + }, 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); + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); expect(response.status).toBe(409); - }, 40000); + }, 20000); test("should return 400 when registering with an invalid credential", async () => { const userData = { @@ -48,31 +67,115 @@ describe("Testing a user Routes", () => { name: "", username: "existinguser", }; - const response = await request(app).post("/api/v1/users/register").send(userData); + const response = await request(app) + .post("/api/v1/users/register") + .send(userData); expect(response.status).toBe(400); - }, 40000); + }, 20000); + }); + + test("should return all users in db --> given '/api/v1/users'", async () => { + const spy = jest.spyOn(User, "findAll"); + const spy2 = jest.spyOn(userServices, "getAllUsers"); + const response = await request(app).get("/api/v1/users"); + expect(spy).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + }, 20000); + test("Should return status 401 to indicate Unauthorized user", async () => { + const loggedInUser = { + email: userData.email, + password: "test", + }; + const spyonOne = jest.spyOn(User, "findOne").mockResolvedValueOnce({ + //@ts-ignore + email: userData.email, + password: loginData.password, + }); + const response = await request(app) + .post("/api/v1/users/login") + .send(loggedInUser); + expect(response.body.status).toBe(401); + spyonOne.mockRestore(); + }, 20000); + + test("Should send otp verification code", async () => { + const spy = jest.spyOn(mailServices, "sendEmailService"); + const response = await request(app).post("/api/v1/users/login").send({ + email: dummySeller.email, + password: dummySeller.password, + }); + + expect(response.body.message).toBe("OTP verification code has been sent ,please use it to verify that it was you"); + // expect(spy).toHaveBeenCalled(); + }, 40000); + + 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 all users in db --> given '/api/v1/users'", async () => { - const spy = jest.spyOn(User, "findAll"); - const spy2 = jest.spyOn(userServices, "getAllUsers"); - const response = await request(app).get("/api/v1/users"); - expect(spy).toHaveBeenCalled(); - expect(spy2).toHaveBeenCalled(); -}, 40000); -test("Should return status 401 to indicate Unauthorized user", async () => { - const loggedInUser = { - email: userData.email, - password: "test", - }; - const spyonOne = jest.spyOn(User, "findOne").mockResolvedValueOnce({ - //@ts-ignore - email: userData.email, - password: loginData.password, + 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); }); - const response = await request(app).post("/api/v1/users/login").send(loggedInUser); - expect(response.body.status).toBe(401); - spyonOne.mockRestore(); }); diff --git a/src/controllers/userControllers.ts b/src/controllers/userControllers.ts index 6152856..48634f4 100644 --- a/src/controllers/userControllers.ts +++ b/src/controllers/userControllers.ts @@ -1,14 +1,18 @@ import { Request, Response } from "express"; import * as userService from "../services/user.service"; import { generateToken } from "../utils/jsonwebtoken"; -import { comparePasswords } from "../helpers/comparePassword"; -import { loggedInUser } from "../services/user.service"; -import { createUserService } from "../services/user.service"; +import * as mailService from "../services/mail.service"; +import { IUser, STATUS, SUBJECTS } from "../types"; +import { comparePasswords } from "../utils/comparePassword"; +import { loggedInUser} from "../services/user.service"; +import { createUserService, getUserByEmail, updateUserPassword } from "../services/user.service"; +import { hashedPassword } from "../utils/hashPassword"; +import Token, { TokenAttributes } from "../sequelize/models/Token"; +import User from "../sequelize/models/users"; +import { verifyOtpTemplate } from "../email-templates/verifyotp"; export const fetchAllUsers = async (req: Request, res: Response) => { try { - // const users = await userService.getAllUsers(); - const users = await userService.getAllUsers(); if (users.length <= 0) { @@ -32,14 +36,15 @@ export const fetchAllUsers = async (req: Request, res: Response) => { export const userLogin = async (req: Request, res: Response) => { const { email, password } = req.body; - const user = await loggedInUser(email); - const accessToken = await generateToken(user); - if (!user) { + const user: IUser = await loggedInUser(email); + let accessToken; + if (!user || user === null) { res.status(404).json({ status: 404, message: "User Not Found ! Please Register new ancount", }); } else { + accessToken = await generateToken(user); const match = await comparePasswords(password, user.password); if (!match) { res.status(401).json({ @@ -52,30 +57,115 @@ export const userLogin = async (req: Request, res: Response) => { message: "Logged in", token: accessToken, }); + if (user.role.includes("seller")) { + const token = Math.floor(Math.random() * 90000 + 10000); + //@ts-ignore + await Token.create({ token: token, userId: user.id }); + await mailService.sendEmailService(user, SUBJECTS.CONFIRM_2FA, verifyOtpTemplate(token), token); + return res.status(200).json({ + status: STATUS.PENDING, + message: "OTP verification code has been sent ,please use it to verify that it was you", + }); + } else { + return res.status(200).json({ + status: 200, + message: "Logged in", + token: accessToken, + }); + } } } }; export const createUserController = async (req: Request, res: Response) => { try { - const { name, email, username, password } = req.body; - const user = await createUserService(name, email, username, password); - - if (!user) { + const { name, email, username, password, role } = req.body; + const user = await createUserService(name, email, username, password, role); + if (!user || user == null) { return res.status(409).json({ status: 409, message: "Username or email already exists", }); } - - res.status(201).json({ + return res.status(201).json({ status: 201, - message: "User successfully created.", + message: "User successfully created." }); } catch (err: any) { if (err.name === "UnauthorizedError" && err.message === "User already exists") { return res.status(409).json({ error: "User already exists" }); } - res.status(500).json({ error: err }); + return 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' }); + } + + if(await comparePasswords(newPassword, user.password)){ + return res.status(400).json({ message: 'New password is similar to the old one. Please use a new password'}) + } + + const password = await hashedPassword(newPassword); + await updateUserPassword(user, password) + return res.status(200).json({ message: 'Password updated successfully' }); + + } catch(err: any){ + return res.status(500).json({ + message: err.message + }) + } +}; + +export const tokenVerification = async (req: any, res: Response) => { + const foundToken: TokenAttributes = req.token; + + try { + const tokenCreationTime = new Date(String(foundToken?.createdAt)).getTime(); + const currentTime = new Date().getTime(); + const timeDifference = currentTime - tokenCreationTime; + + if (timeDifference > 600000) { + await Token.destroy({ where: { userId: foundToken.userId } }); + return res.status(401).json({ + message: "Token expired", + }); + } + + const user: IUser | null = await User.findOne({ where: { id: foundToken.userId } }); + + if (user) { + const token = await generateToken(user); + + await Token.destroy({ where: { userId: foundToken.userId } }); + + return res.status(200).json({ + message: "Login successful", + token, + user, + }); + } else { + return res.status(404).json({ + message: "User not found", + }); + } + } catch (error: any) { + return res.status(500).json({ + message: error.message, + }); } }; diff --git a/src/docs/swagger.ts b/src/docs/swagger.ts index f5fc119..38ea8da 100644 --- a/src/docs/swagger.ts +++ b/src/docs/swagger.ts @@ -1,7 +1,7 @@ import express from "express"; import { serve, setup } from "swagger-ui-express"; import { env } from "../utils/env"; -import { createUsers, getUsers, loginAsUser, userSchema, loginSchema } from "./users"; +import { createUsers, getUsers, loginAsUser, userSchema, loginSchema, updatePasswordSchema, passwordUpdate, verifyOTPToken } from "./users"; const docRouter = express.Router(); @@ -43,12 +43,19 @@ const options = { "/api/v1/users/login": { post: loginAsUser, }, + "/api/v1/users/passwordupdate": { + put: passwordUpdate, + }, + "/api/v1/users/2fa-verify": { + post: verifyOTPToken, + }, }, components: { schemas: { User: userSchema, Login: loginSchema, + updatePassword: updatePasswordSchema, }, securitySchemes: { bearerAuth: { diff --git a/src/docs/users.ts b/src/docs/users.ts index ae2c33c..7f29ec2 100644 --- a/src/docs/users.ts +++ b/src/docs/users.ts @@ -17,6 +17,21 @@ export const userSchema = { }, }; +export const updatePasswordSchema = { + type: "object", + properties: { + oldPassword: { + type: "string", + }, + newPassword: { + type: "string", + }, + confirmPassword: { + type: "string", + }, + }, +}; + export const loginSchema = { properties: { email: { @@ -96,5 +111,70 @@ export const loginAsUser = { 200: { description: "OK", }, + 400: { + description: "Bad request missing or extra filed", + }, + 404: { + description: "Account not found", + }, + 409: { + description: "Invalid credentials", + }, + 500: { + description: "Internal server error", + }, + }, +}; + +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", + }, + }, +}; +export const verifyOTPToken = { + tags: ["Users"], + summary: "verify OTP token for seller during login process", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + properties: { + token: { + type: "number", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfuly logged in ", + }, + 403: { + description: "forbidden token expired", + }, + 404: { + description: "Inavalid token or not found", + }, }, }; diff --git a/src/email-templates/verifyotp.ts b/src/email-templates/verifyotp.ts new file mode 100644 index 0000000..f924e49 --- /dev/null +++ b/src/email-templates/verifyotp.ts @@ -0,0 +1,29 @@ +export const verifyOtpTemplate = (token: number) => { + return ` + +
+ + +We noticed a login attempt to your Eagle E-commerce account. If this was you, please verify your new device using the following one-time verification code
+ +> +
${token}
+This verification code is valid for 10 minutes.
+If you don't recognize this login attempt, someone may be trying to access your account. We recommend you change your password immediately.
+Your account is safe 😎.
+