From 80e32f4bcc4289bbc5bf6354835181dfc6c3cc2b Mon Sep 17 00:00:00 2001 From: AimePazzo <108128511+AimePazzo@users.noreply.github.com> Date: Tue, 28 May 2024 22:53:32 +0300 Subject: [PATCH] Ft send verification email 187584913 (#30) * [finishes #187584913] finished sending verification email features * [finishes #187584913] finished sending verification email features --- .circleci/config.yml | 2 +- .github/workflows/ci.yml | 8 +- .nycrc | 7 + README.md | 12 +- package.json | 5 +- .../migrations/20240520180022-create-users.ts | 2 +- .../20240523180022-create-sessions.ts | 44 ++++ src/databases/models/session.ts | 73 +++++++ src/databases/models/users.ts | 5 +- src/helpers/index.ts | 10 +- src/middlewares/validation.ts | 52 ++++- .../auth/controller/authControllers.ts | 37 +++- .../auth/repository/authRepositories.ts | 24 +- src/modules/auth/test/auth.spec.ts | 205 ++++++++++++++++-- .../auth/validation/authValidations.ts | 14 +- src/routes/authRouter.ts | 8 +- src/services/sendEmail.ts | 32 +++ src/types/index.d.ts | 10 + swagger.json | 137 +++++++++++- 19 files changed, 622 insertions(+), 65 deletions(-) create mode 100644 .nycrc create mode 100644 src/databases/migrations/20240523180022-create-sessions.ts create mode 100644 src/databases/models/session.ts create mode 100644 src/types/index.d.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index f1e33660..a366d88b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: - image: cimg/node:18.17.0 steps: - setup_remote_docker: - version: 20.10.7 + version: docker24 - checkout - run: name: update-npm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c09813..ca98541d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: push: branches: - develop - env: PORT: ${{ secrets.PORT }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} @@ -17,6 +16,12 @@ env: API_SECRET: ${{ secrets.API_SECRET }} CLOUD_NAME: ${{ secrets.CLOUD_NAME }} JWT_SECRET: ${{ secrets.JWT_SECRET }} + SERVER_URL_PRO : ${{ secrets.SERVER_URL_PRO }} + SMTP_HOST_PORT: ${{ secrets.SMTP_HOST_PORT }} + MP : ${{ secrets.MP }} + SMTP_HOST: ${{ secrets.SMTP_HOST }} + MAIL_ID: ${{ secrets.MAIL_ID }} + DB_HOST_TYPE: ${{ secrets.DB_HOST_TYPE }} jobs: build: @@ -47,6 +52,7 @@ jobs: - run: npm run coverage --if-present + - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 env: diff --git a/.nycrc b/.nycrc new file mode 100644 index 00000000..41ce9718 --- /dev/null +++ b/.nycrc @@ -0,0 +1,7 @@ +{ + "check-coverage": true, + "lines": 80, + "functions": 80, + "branches": 80, + "statements": 80 +} \ No newline at end of file diff --git a/README.md b/README.md index 8730599f..3a067e9c 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,18 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. - Welcome Endpoint - Register Endpoint +- Verification Email Endpoint +- Resend verification Endpoint ## TABLE OF API ENDPOINTS SPECIFICATION AND DESCRIPTION -| No | VERBS | ENDPOINTS | STATUS | ACCESS | DESCRIPTION | -|----|-------|-------------------|--------|--------|-------------------- | -| 1 | GET | / | 200 OK | public | Show welcome message| -| 2 | POST | /api/auth/register| 200 OK | public | create user account | +| No | VERBS | ENDPOINTS | STATUS | ACCESS | DESCRIPTION | +|----|-------|------------------------------|-------------|--------|---------------------------| +| 1 | GET | / | 200 OK | public | Show welcome message | +| 2 | GET | /api/auth/verify-email/:token| 200 OK | public | Verifying email | +| 3 | POST | /api/auth/register | 201 CREATED | public | create user account | +| 4 | POST | /api/auth/send-verify-email | 200 OK | public | Resend verification email | diff --git a/package.json b/package.json index ad141f5b..8129e3d0 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ }, "nyc": { "extends": "@istanbuljs/nyc-config-typescript", - "check-coverage": true, - "all": true, "include": [ "src/**/!(*.test.*).[tj]s?(x)" ], @@ -45,7 +43,8 @@ "text-summary" ], "report-dir": "coverage", - "lines": 40 + "check-coverage": true, + "all": true }, "keywords": [], "author": "", diff --git a/src/databases/migrations/20240520180022-create-users.ts b/src/databases/migrations/20240520180022-create-users.ts index b9fb8248..6c9959b8 100644 --- a/src/databases/migrations/20240520180022-create-users.ts +++ b/src/databases/migrations/20240520180022-create-users.ts @@ -73,7 +73,7 @@ export default { defaultValue: false }, status: { - type: new DataTypes.BOOLEAN, + type: new DataTypes.STRING(128), allowNull: false, defaultValue: true }, diff --git a/src/databases/migrations/20240523180022-create-sessions.ts b/src/databases/migrations/20240523180022-create-sessions.ts new file mode 100644 index 00000000..cfb24af0 --- /dev/null +++ b/src/databases/migrations/20240523180022-create-sessions.ts @@ -0,0 +1,44 @@ +import { QueryInterface, DataTypes } from "sequelize"; +export default { + up: async (queryInterface: QueryInterface) => { + await queryInterface.createTable("sessions", { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: new DataTypes.INTEGER, + allowNull: false + }, + device: { + type: new DataTypes.STRING(280), + allowNull: true + }, + token: { + type: new DataTypes.STRING(280), + allowNull: true + }, + otp: { + type: new DataTypes.STRING(280), + allowNull: true + }, + createdAt: { + field: "createdAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updatedAt: { + field: "updatedAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.dropTable("sessions"); + } +}; diff --git a/src/databases/models/session.ts b/src/databases/models/session.ts new file mode 100644 index 00000000..67c196a4 --- /dev/null +++ b/src/databases/models/session.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable require-jsdoc */ +import { Model, DataTypes } from "sequelize"; +import sequelizeConnection from "../config/db.config"; +export interface SessionAttributes { + id: number; + userId: number; + device: string; + token: string; + otp: string; + createdAt: Date; + updatedAt: Date; +} + + class Session extends Model implements SessionAttributes { + declare id: number; + declare userId: number; + declare device: string; + declare token: string; + declare otp:string; + declare createdAt: Date; + declare updatedAt: Date; + + static associate(models: any) { + Session.belongsTo(models.Users, { foreignKey: "userId",as: "user" }); + } + } + + Session.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: new DataTypes.INTEGER, + allowNull: false + }, + device: { + type: new DataTypes.STRING(280), + allowNull: true + }, + token: { + type: new DataTypes.STRING(280), + allowNull: true + }, + otp: { + type: new DataTypes.STRING(280), + allowNull: true + }, + createdAt: { + field: "createdAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updatedAt: { + field: "updatedAt", + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }, + { + sequelize: sequelizeConnection, + tableName: "sessions", + timestamps: true, + modelName:"Sessions" + } + ); + +export default Session; diff --git a/src/databases/models/users.ts b/src/databases/models/users.ts index 25281160..42a5d339 100644 --- a/src/databases/models/users.ts +++ b/src/databases/models/users.ts @@ -48,6 +48,9 @@ class Users extends Model implements U declare updatedAt?: Date; // Define any static methods or associations here + static associate(models: any) { + Users.hasOne(models.Tokens, { foreignKey: "userId",as: "token" }); + } } Users.init( @@ -117,7 +120,7 @@ Users.init( defaultValue: false }, status: { - type: new DataTypes.BOOLEAN, + type: new DataTypes.STRING(128), allowNull: true, defaultValue: true }, diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 05d1aaf4..6b312664 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,4 +1,4 @@ -import jwt from "jsonwebtoken" +import jwt,{JwtPayload} from "jsonwebtoken" import dotenv from "dotenv" dotenv.config @@ -6,4 +6,10 @@ dotenv.config const generateToken = (id: number) => { return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "1h" }); }; - export { generateToken} \ No newline at end of file + + const decodeToken = (token: string) => { + return jwt.verify(token, process.env.JWT_SECRET) as JwtPayload + ; + }; + + export { generateToken, decodeToken} \ No newline at end of file diff --git a/src/middlewares/validation.ts b/src/middlewares/validation.ts index c3cdd8dc..2ee859f9 100644 --- a/src/middlewares/validation.ts +++ b/src/middlewares/validation.ts @@ -4,6 +4,7 @@ import authRepositories from "../modules/auth/repository/authRepositories"; import { UsersAttributes } from "../databases/models/users"; import Joi from "joi"; import httpStatus from "http-status"; +import { decodeToken } from "../helpers"; const validation = (schema: Joi.ObjectSchema | Joi.ArraySchema) => async (req: Request, res: Response, next: NextFunction) => { try { @@ -22,16 +23,51 @@ const validation = (schema: Joi.ObjectSchema | Joi.ArraySchema) => async (req: R const isUserExist = async (req: Request, res: Response, next: NextFunction) => { try { - const email:string = req.body.email - const userExists:UsersAttributes = await authRepositories.findUserByEmail(email); - if (userExists) { - return res.status(httpStatus.BAD_REQUEST).json({ status: httpStatus.BAD_REQUEST, message: "User already exists." }); + const userExists: UsersAttributes = await authRepositories.findUserByAttributes("email", req.body.email); + if (userExists && userExists.isVerified === true) { + return res.status(httpStatus.BAD_REQUEST).json({ status: httpStatus.BAD_REQUEST, message: "Account already exists." }); + } + if (userExists && userExists.isVerified === false) { + return res.status(httpStatus.BAD_REQUEST).json({ status: httpStatus.BAD_REQUEST, message: "Account already exists. Please verify your account" }); + } + return next(); + } catch (error) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }) } - return next(); + +} + +const isAccountVerified = async (req: any, res: Response, next: NextFunction) => { + try { + let user: any = null; + if (req?.params?.token) { + const decodedToken = await decodeToken(req.params.token); + user = await authRepositories.findUserByAttributes("id", decodedToken.id); + } + if (req?.body?.email) { + user = await authRepositories.findUserByAttributes("email", req.body.email); + } + + if (!user) { + return res.status(httpStatus.NOT_FOUND).json({ message: "Account not found." }); + } + + if (user.isVerified) { + return res.status(httpStatus.BAD_REQUEST).json({ message: "Account already verified." }); + } + + const session = await authRepositories.findSessionByUserId(user.id); + if (!session) { + return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid token." }); + } + + req.session = session; + req.user = user; + next(); } catch (error) { - res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR , message: error.message}) + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); } - } -export {validation,isUserExist}; \ No newline at end of file + +export { validation, isUserExist, isAccountVerified }; \ No newline at end of file diff --git a/src/modules/auth/controller/authControllers.ts b/src/modules/auth/controller/authControllers.ts index 731531ac..297ffff1 100644 --- a/src/modules/auth/controller/authControllers.ts +++ b/src/modules/auth/controller/authControllers.ts @@ -4,19 +4,40 @@ import userRepositories from "../repository/authRepositories"; import { generateToken } from "../../../helpers"; import httpStatus from "http-status"; import { UsersAttributes } from "../../../databases/models/users"; - +import authRepositories from "../repository/authRepositories"; +import { sendVerificationEmail } from "../../../services/sendEmail"; const registerUser = async (req: Request, res: Response): Promise => { try { - const register:UsersAttributes = await userRepositories.registerUser(req.body); + const register: UsersAttributes = await userRepositories.createUser(req.body); const token: string = generateToken(register.id); - const data = {register, token}; - - res.status(httpStatus.OK).json({message:"Account created successfully. Please check email to verify account.", data}) - }catch(error) { + const session = {userId: register.id, device: req.headers["user-device"] , token: token, otp: null }; + await authRepositories.createSession(session); + await sendVerificationEmail(register.email, "Verification Email", `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${token}`); + res.status(httpStatus.CREATED).json({ message: "Account created successfully. Please check email to verify account.", data: register }) + } catch (error) { res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); } - + +} + +const sendVerifyEmail = async(req:any, res:Response) =>{ + try { + await sendVerificationEmail(req.user.email, "Verification Email", `${process.env.SERVER_URL_PRO}/api/auth/verify-email/${req.session.token}`); + res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Verification email sent successfully." }); + } catch (error) { + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); + } +} + +const verifyEmail = async (req: any, res: Response) => { + try { + await authRepositories.destroySession(req.user.id, req.session.token) + await authRepositories.UpdateUserByAttributes("isVerified", true, "id", req.user.id); + res.status(httpStatus.OK).json({ status: httpStatus.OK, message: "Account verified successfully, now login." }); + } catch (error) { + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); + } } -export default { registerUser } \ No newline at end of file +export default { registerUser, sendVerifyEmail, verifyEmail } \ No newline at end of file diff --git a/src/modules/auth/repository/authRepositories.ts b/src/modules/auth/repository/authRepositories.ts index cb8e6b9b..5a403590 100644 --- a/src/modules/auth/repository/authRepositories.ts +++ b/src/modules/auth/repository/authRepositories.ts @@ -1,13 +1,29 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import Users from "../../../databases/models/users" +import Session from "../../../databases/models/session" -const registerUser = async (body:any) =>{ +const createUser = async (body:any) =>{ return await Users.create(body) } -const findUserByEmail = async (email:string) =>{ - return await Users.findOne({ where: { email: email} }) +const findUserByAttributes = async (key:string, value:any) =>{ + return await Users.findOne({ where: { [key]: value} }) } +const UpdateUserByAttributes = async (updatedKey:string, updatedValue:any, whereKey:string, whereValue:any) =>{ + return await Users.update({ [updatedKey]: updatedValue }, { where: { [whereKey]: whereValue} }); +} + +const createSession = async (body: any) => { + return await Session.create(body); +} + +const findSessionByUserId = async( userId:number ) => { + return await Session.findOne({ where: { userId } }); +} + +const destroySession = async (userId: number, token:string) =>{ + return await Session.destroy({ where: {userId, token } }); +} -export default {registerUser, findUserByEmail} \ No newline at end of file +export default {createUser, createSession, findUserByAttributes, destroySession, UpdateUserByAttributes, findSessionByUserId} \ No newline at end of file diff --git a/src/modules/auth/test/auth.spec.ts b/src/modules/auth/test/auth.spec.ts index 5e09b74a..66629506 100644 --- a/src/modules/auth/test/auth.spec.ts +++ b/src/modules/auth/test/auth.spec.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// /* eslint-disable @typescript-eslint/no-explicit-any */ import { Request, Response } from "express"; import chai, { expect } from "chai"; import chaiHttp from "chai-http"; @@ -7,29 +9,58 @@ import app from "../../.."; import { isUserExist } from "../../../middlewares/validation"; import authRepositories from "../repository/authRepositories"; import Users from "../../../databases/models/users"; - +import Session from "../../../databases/models/session"; +import { sendVerificationEmail,transporter } from "../../../services/sendEmail"; chai.use(chaiHttp); const router = () => chai.request(app); + +let userId: number; +let verifyToken: string | null = null; + describe("Authentication Test Cases", () => { - it("Should be able to register new user", (done) => { + afterEach(async () => { + const tokenRecord = await Session.findOne({ where: { userId } }); + if (tokenRecord) { + verifyToken = tokenRecord.dataValues.token; + } + }); + + it("should register a new user", (done) => { router() .post("/api/auth/register") .send({ - email: "user@example.com", + email: "ecommerceninjas45@gmail.com", password: "userPassword@123" }) .end((error, response) => { - expect(response.status).to.equal(httpStatus.OK); - expect(response.body).to.be.a("object"); + expect(response.status).to.equal(httpStatus.CREATED); + expect(response.body).to.be.an("object"); expect(response.body).to.have.property("data"); - expect(response.body.message).to.be.a("string"); + userId = response.body.data.id; + expect(response.body).to.have.property("message", "Account created successfully. Please check email to verify account."); done(error); }); }); - it("should return validation return message error and 400", (done) => { + it("should verify the user successfully", (done) => { + if (!verifyToken) { + throw new Error("verifyToken is not set"); + } + + router() + .get(`/api/auth/verify-email/${verifyToken}`) + .end((err, res) => { + expect(res.status).to.equal(httpStatus.OK); + expect(res.body).to.be.an("object"); + expect(res.body).to.have.property("status", httpStatus.OK); + expect(res.body).to.have.property("message", "Account verified successfully, now login."); + done(err); + }) + }); + + it("should return validation error and 400", (done) => { router() .post("/api/auth/register") .send({ @@ -37,13 +68,13 @@ describe("Authentication Test Cases", () => { password: "userPassword" }) .end((error, response) => { - expect(response.status).equal(400); + expect(response.status).to.equal(400); expect(response.body).to.be.a("object"); expect(response.body).to.have.property("message"); done(error); }); }); -}) +}); describe("isUserExist Middleware", () => { before(() => { @@ -58,6 +89,32 @@ describe("isUserExist Middleware", () => { }); it("should return user already exists", (done) => { + router() + .post("/api/auth/register") + .send({ + email: "ecommerceninjas45@gmail.com", + password: "userPassword@123" + }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.BAD_REQUEST); + expect(res.body).to.be.an("object"); + expect(res.body).to.have.property("status", httpStatus.BAD_REQUEST); + expect(res.body).to.have.property("message", "Account already exists."); + done(err); + }); + }); + + it("should return 'Account already exists. Please verify your account' if user exists and is not verified", (done) => { + const mockUser = Users.build({ + id: 1, + email: "user@example.com", + password: "hashedPassword", + isVerified: false, + createdAt: new Date(), + updatedAt: new Date() + }); + + sinon.stub(authRepositories, "findUserByAttributes").resolves(mockUser); router() .post("/api/auth/register") @@ -69,13 +126,13 @@ describe("isUserExist Middleware", () => { expect(res).to.have.status(httpStatus.BAD_REQUEST); expect(res.body).to.be.an("object"); expect(res.body).to.have.property("status", httpStatus.BAD_REQUEST); - expect(res.body).to.have.property("message", "User already exists."); + expect(res.body).to.have.property("message", "Account already exists. Please verify your account"); done(err); }); }); it("should return internal server error", (done) => { - sinon.stub(authRepositories, "findUserByEmail").throws(new Error("Database error")); + sinon.stub(authRepositories, "findUserByAttributes").throws(new Error("Database error")); router() .post("/auth/register") .send({ email: "usertesting@gmail.com" }) @@ -89,7 +146,7 @@ describe("isUserExist Middleware", () => { }); it("should call next if user does not exist", (done) => { - sinon.stub(authRepositories, "findUserByEmail").resolves(null); + sinon.stub(authRepositories, "findUserByAttributes").resolves(null); router() .post("/auth/register") @@ -106,7 +163,7 @@ describe("POST /auth/register - Error Handling", () => { let registerUserStub: sinon.SinonStub; beforeEach(() => { - registerUserStub = sinon.stub(authRepositories, "registerUser").throws(new Error("Test error")); + registerUserStub = sinon.stub(authRepositories, "createUser").throws(new Error("Test error")); }); afterEach(() => { @@ -123,7 +180,125 @@ describe("POST /auth/register - Error Handling", () => { status: httpStatus.INTERNAL_SERVER_ERROR, message: "Test error" }); - done(err) + done(err); + }); + }); +}); + +describe("isAccountVerified Middleware", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should return 'Account not found' if user is not found", (done) => { + sinon.stub(authRepositories, "findUserByAttributes").resolves(null); + + router() + .post("/api/auth/send-verify-email") + .send({ email: "nonexistent@example.com" }) + .end((err,res)=>{ + expect(res.status).to.equal(httpStatus.NOT_FOUND); + expect(res.body).to.have.property("message", "Account not found."); + done(err); + }) + }); + + it("should return 'Account already verified' if user is already verified", (done) => { + const mockUser = Users.build({ + id: 1, + email: "user@example.com", + password: "hashedPassword", + isVerified: true, + createdAt: new Date(), + updatedAt: new Date() + }); + + sinon.stub(authRepositories, "findUserByAttributes").resolves(mockUser); + + router() + .post("/api/auth/send-verify-email") + .send({ email: "user@example.com" }) + .end((err, res) => { + expect(res.status).to.equal(httpStatus.BAD_REQUEST); + expect(res.body).to.have.property("message", "Account already verified."); + done(err); + }); + }); + + +}); + +describe("Authentication Test Cases", () => { + let findUserByAttributesStub: sinon.SinonStub; + let findSessionByUserIdStub: sinon.SinonStub; + + beforeEach(() => { + findUserByAttributesStub = sinon.stub(authRepositories, "findUserByAttributes"); + findSessionByUserIdStub = sinon.stub(authRepositories, "findSessionByUserId"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should send a verification email successfully", (done) => { + const mockUser = { id: 1, email: "user@example.com", isVerified: false }; + const mockSession = { token: "testToken" }; + + findUserByAttributesStub.resolves(mockUser); + findSessionByUserIdStub.resolves(mockSession); + + router() + .post("/api/auth/send-verify-email") + .send({ email: "user@example.com" }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.OK); + expect(res.body).to.have.property("message", "Verification email sent successfully."); + done(err); + }); + }); + it("should return 400 if session is not found", (done) => { + const mockUser = { id: 1, email: "user@example.com", isVerified: false }; + const mockSession = { token: "testToken" }; + + findUserByAttributesStub.resolves(mockUser); + findSessionByUserIdStub.resolves(mockSession) + findSessionByUserIdStub.resolves(null); + router() + .post("/api/auth/send-verify-email") + .send({ email: "user@example.com" }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.BAD_REQUEST); + expect(res.body).to.have.property("message", "Invalid token."); + done(err); + }); +}); + +it("should return internal server error", (done) => { + findSessionByUserIdStub.resolves(null); + const token = "invalid token"; + router() + .get(`/api/auth/verify-email/${token}`) + .send({ email: "user@example.com" }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); + expect(res.body).to.have.property("message"); + done(err); }); +}); +}); + +describe("sendVerificationEmail", () => { + afterEach(() => { + sinon.restore(); }); -}); \ No newline at end of file + + it("should throw an error when sendMail fails", async () => { + sinon.stub(transporter, "sendMail").rejects(new Error("Network Error")); + try { + await sendVerificationEmail("email@example.com", "subject", "message"); + } catch (error) { + expect(error).to.be.an("error"); + } + }); +}); diff --git a/src/modules/auth/validation/authValidations.ts b/src/modules/auth/validation/authValidations.ts index 114610a8..ccb31a83 100644 --- a/src/modules/auth/validation/authValidations.ts +++ b/src/modules/auth/validation/authValidations.ts @@ -5,7 +5,7 @@ interface User { password: string; } -const authSchema = Joi.object({ +const registerSchema = Joi.object({ email: Joi.string().email().required().messages({ "string.base": "email should be a type of text", "string.email": "email must be a valid email", @@ -21,4 +21,14 @@ const authSchema = Joi.object({ }) }); -export {authSchema}; \ No newline at end of file +const emailSchema = Joi.object({ + email: Joi.string().email().required().messages({ + "string.base": "email should be a type of text", + "string.email": "email must be a valid email", + "string.empty": "email cannot be an empty field", + "any.required": "email is required" + }) + +}); + +export {registerSchema, emailSchema}; \ No newline at end of file diff --git a/src/routes/authRouter.ts b/src/routes/authRouter.ts index f8fdf23d..d9a9cf1e 100644 --- a/src/routes/authRouter.ts +++ b/src/routes/authRouter.ts @@ -1,12 +1,14 @@ -import {validation,isUserExist} from "../middlewares/validation"; +import {validation,isUserExist, isAccountVerified} from "../middlewares/validation"; import authControllers from "../modules/auth/controller/authControllers"; import { Router } from "express"; -import {authSchema} from "../modules/auth/validation/authValidations"; +import { emailSchema, registerSchema} from "../modules/auth/validation/authValidations"; const router: Router = Router(); -router.post("/register", validation(authSchema), isUserExist, authControllers.registerUser); +router.post("/register", validation(registerSchema), isUserExist, authControllers.registerUser); +router.get("/verify-email/:token", isAccountVerified, authControllers.verifyEmail); +router.post("/send-verify-email", validation(emailSchema), isAccountVerified, authControllers.sendVerifyEmail); export default router; \ No newline at end of file diff --git a/src/services/sendEmail.ts b/src/services/sendEmail.ts index e69de29b..ba200491 100644 --- a/src/services/sendEmail.ts +++ b/src/services/sendEmail.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Request, Response } from "express"; +import nodemailer, { SendMailOptions } from "nodemailer"; +import dotenv from "dotenv"; + +dotenv.config(); +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_HOST_PORT), + secure: true, + auth: { + user: process.env.MAIL_ID, + pass: process.env.MP + } +}); + +const sendVerificationEmail = async(email: string, subject: string, message: string) => { + try { + const mailOptionsVerify: SendMailOptions = { + from: process.env.MAIL_ID, + to: email, + subject: subject, + text: message + }; + + await transporter.sendMail(mailOptionsVerify); + } catch (error) { + throw new Error(error); + } +}; + +export { sendVerificationEmail, transporter }; diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 00000000..bae93053 --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,10 @@ +export interface IToken{ + userId: number; + device: string; + accessToken: string; +} + +export interface ILogin{ + email: string; + password: string; +} \ No newline at end of file diff --git a/swagger.json b/swagger.json index 6e65fc48..4fcd369e 100644 --- a/swagger.json +++ b/swagger.json @@ -21,8 +21,8 @@ "description": "Initial/Fake Endpoint | GET Route" }, { - "name": "Register Route", - "description": "Registration for user | POST Route" + "name": "Authentication Routes", + "description": "Authentication Endpoint | POST Route" } ], "schemes": [ @@ -55,7 +55,7 @@ "/api/auth/register": { "post": { "tags": [ - "Register Route" + "Authentication Routes" ], "summary": "Register a new user", "description": "This endpoint allows for registering a new user by providing the required details.", @@ -82,7 +82,7 @@ } }, "responses": { - "200": { + "201": { "description": "Account created successfully.", "content": { "application/json": { @@ -94,14 +94,127 @@ }, "data": { "type": "object", - "properties": { - "register": { - "$ref": "#/components/schemas/User" - }, - "token": { - "type": "string" - } - } + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/auth/send-verify-email": { + "post": { + "tags": [ + "Authentication Routes" + ], + "summary": "Resend verification email again", + "description": "This endpoint allows user to request verification email again", + "requestBody": { + "description": "Sending email for verification", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + }, + "required": ["email"] + } + } + } + }, + "responses": { + "200": { + "description": "Verification email sent successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/auth/verify-email/{token}": { + "get": { + "tags": [ + "Authentication Routes" + ], + "summary": "Verify user email", + "description": "This endpoint allows user to verify their email", + "parameters": [ + { + "in":"path", + "name": "token", + "type": "string", + "description":"parsing token" + } + ], + "responses": { + "200": { + "description": "Email verified successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "message": { + "type": "string" } } }