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/.env.example b/.env.example index c86c0c60..8a0d85da 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,28 @@ PORT= -NODE_ENV= NODE_EN= -DOCKER_DATABASE_HOST_AUTH_METHOD= -DOCKER_DATABASE_NAME= -DOCKER_DATABASE_PASSWORD= +JWT_SECRET= +NODE_ENV= + +SMTP_HOST_PORT= +MP= +SMTP_HOST= +MAIL_ID= + +CLOUD_NAME= +API_KEY= +API_SECRET= + +SERVER_URL_DEV= +SERVER_URL_PRO= + DATABASE_URL_DEV= DATABASE_URL_TEST= DATABASE_URL_PRO= -DB_HOST_TYPE= \ No newline at end of file + + + +DB_HOST_TYPE= +DOCKER_DATABASE_USER= +DOCKER_DATABASE_HOST_AUTH_METHOD= +DOCKER_DATABASE_NAME= +DOCKER_DATABASE_PASSWORD= \ No newline at end of file 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..dcfc72f0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Team Ninjas Backend +# TEAM NINJAS BACKEND This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. @@ -23,22 +23,28 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. [https://github.com/atlp-rwanda/e-commerce-ninjas-bn](https://github.com/atlp-rwanda/e-commerce-ninjas-bn) -## Completed Features +## COMPLETED FEATURES - Welcome Endpoint - Register Endpoint +- Verification Email Endpoint +- Resend verification Endpoint +- Login 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 | POST | /api/auth/register | 201 CREATED | public | create user account | +| 3 | GET | /api/auth/verify-email/:token| 200 OK | public | Verifying email | +| 4 | POST | /api/auth/send-verify-email | 200 OK | public | Resend verification email | +| 4 | POST | /api/auth/login | 200 OK | public | Login with Email and Password | -## Installation +## INSTALLATION 1. Clone the repository: @@ -59,7 +65,7 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. npm run dev ``` -## Folder Structure +## FOLDER STRUCTURE - `.env`: Secure environment variables. - `src/`: Source code directory. @@ -80,7 +86,7 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. - `services/`: Service functions like sendEmails. - `index.ts`: Startup file for all requests. -## Initialize Sequelize CLI +## INITILIAZE SEQUELIZE CLI 1. Initialize Sequelize CLI: ```sh @@ -113,43 +119,4 @@ This is the backend for E-Commerce-Ninjas, written in Node.js with TypeScript. 9. Delete the Migration: ```sh npm run deleteAllTables - ``` - - - -## Initialize Sequelize CLI - -1. Initialize Sequelize CLI: - ```sh - npx sequelize-cli init - ``` -2. Generate Seeder: - ```sh - npx sequelize-cli seed:generate --name name-of-your-seeder - ``` -3. Generate Migrations: - ```sh - npx sequelize-cli migration:generate --name name-of-your-migration - ``` -4. Define Migration: - Edit the generated migration file to include the tables you want to create. -5. Define Seeder Data: - Edit the generated seeder file to include the data you want to insert. -6. Run the Seeder: - ```sh - npm run createAllSeeders - ``` -7. Run the Migration: - ```sh - npm run createAllTables - ``` -8. Delete the Seeder: - ```sh - npm run deleteAllSeeders - ``` -9. Delete the Migration: - ```sh - npm run deleteAllTables - ``` - - + ``` \ No newline at end of file diff --git a/package.json b/package.json index 325e9cce..11253985 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,6 @@ }, "nyc": { "extends": "@istanbuljs/nyc-config-typescript", - "check-coverage": true, - "all": true, "include": [ "src/**/!(*.test.*).[tj]s?(x)" ], @@ -46,7 +44,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-tokens.ts b/src/databases/migrations/20240523180022-create-sessions.ts similarity index 84% rename from src/databases/migrations/20240523180022-create-tokens.ts rename to src/databases/migrations/20240523180022-create-sessions.ts index b2ce8a30..cfb24af0 100644 --- a/src/databases/migrations/20240523180022-create-tokens.ts +++ b/src/databases/migrations/20240523180022-create-sessions.ts @@ -1,44 +1,44 @@ -import { QueryInterface, DataTypes } from "sequelize"; -export default { - up: async (queryInterface: QueryInterface) => { - await queryInterface.createTable("tokens", { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true - }, - userId: { - type: new DataTypes.INTEGER, - allowNull: false - }, - device: { - type: new DataTypes.STRING(280), - allowNull: true - }, - accessToken: { - type: new DataTypes.STRING(280), - allowNull: true - }, - verifyToken: { - 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("tokens"); - } -}; \ No newline at end of file +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/tokens.ts b/src/databases/models/session.ts similarity index 75% rename from src/databases/models/tokens.ts rename to src/databases/models/session.ts index 5ad6ccba..67c196a4 100644 --- a/src/databases/models/tokens.ts +++ b/src/databases/models/session.ts @@ -1,73 +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 TokenAttributes { - id: number; - userId: number; - device: string; - accessToken: string; - verifyToken: string; - createdAt: Date; - updatedAt: Date; -} - - class Tokens extends Model implements TokenAttributes { - declare id: number; - declare userId: number; - declare device: string; - declare accessToken: string; - declare verifyToken:string; - declare createdAt: Date; - declare updatedAt: Date; - - static associate(models: any) { - Tokens.belongsTo(models.Users, { foreignKey: "userId",as: "user" }); - } - } - - Tokens.init( - { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true - }, - userId: { - type: new DataTypes.INTEGER, - allowNull: false - }, - device: { - type: new DataTypes.STRING(280), - allowNull: true - }, - accessToken: { - type: new DataTypes.STRING(280), - allowNull: true - }, - verifyToken: { - 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: "tokens", - timestamps: true, - modelName:"Tokens" - } - ); - -export default Tokens; \ No newline at end of file +/* 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 66f14565..a316da93 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,5 +1,6 @@ -import jwt from "jsonwebtoken" +import jwt,{JwtPayload} from "jsonwebtoken" import dotenv from "dotenv" +import bcrypt from "bcrypt" dotenv.config @@ -7,4 +8,13 @@ dotenv.config 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 + ; + }; + + const comparePassword = async (password: string, hashedPassword: string) =>{ + return await bcrypt.compare(password, hashedPassword); +} + + export { generateToken, decodeToken, comparePassword } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fbd6c8ae..9c1219b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,5 +29,4 @@ app.listen(PORT, () => { console.log(`Server is running on the port ${PORT}`); }); - export default app; \ No newline at end of file diff --git a/src/middlewares/validation.ts b/src/middlewares/validation.ts index 6247a7d0..a3424634 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 { comparePassword, decodeToken } from "../helpers"; import { IRequest } from "../types"; const validation = (schema: Joi.ObjectSchema | Joi.ArraySchema) => async (req: Request, res: Response, next: NextFunction) => { @@ -21,35 +22,69 @@ const validation = (schema: Joi.ObjectSchema | Joi.ArraySchema) => async (req: R }; -const isUserExist = async (req: Request, res: Response, next: NextFunction) => { +const isUserExistByEmail = 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 }) } +} + +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) { + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ status: httpStatus.INTERNAL_SERVER_ERROR, message: error.message }); + } } const checkLoginUser = async (req: Request, res: Response, next: NextFunction) => { try { - const email: string = req.body.email - const user: UsersAttributes = await authRepositories.findUserByEmail(email); - if (user) { - const passwordMatches = (req.body.password === user.password) - if (!passwordMatches) return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); - (req as IRequest).loginUserId = user.id; - return next(); - } - return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); + const user: UsersAttributes = await authRepositories.findUserByAttributes("email", req.body.email); + if (!user) { + return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); + } + const passwordMatches = await comparePassword(req.body.password, user.password) + if (!passwordMatches) return res.status(httpStatus.BAD_REQUEST).json({ message: "Invalid Email or Password", data: null }); + (req as IRequest).loginUserId = user.id; + return next(); } catch (error) { - // res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: error.message }) + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: error.message }) } } -export { validation, isUserExist, checkLoginUser }; \ No newline at end of file + + +export { validation, isUserExistByEmail, isAccountVerified, checkLoginUser }; \ No newline at end of file diff --git a/src/modules/auth/controller/authControllers.ts b/src/modules/auth/controller/authControllers.ts index b6557c40..4b6dde4e 100644 --- a/src/modules/auth/controller/authControllers.ts +++ b/src/modules/auth/controller/authControllers.ts @@ -4,39 +4,57 @@ import userRepositories from "../repository/authRepositories"; import { generateToken } from "../../../helpers"; import httpStatus from "http-status"; import { UsersAttributes } from "../../../databases/models/users"; -import { IRequest, IToken } from "../../../types"; +import { IRequest } from "../../../types"; +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 }) + 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: { user: 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 }); + } +} + const loginUser = async (req: Request, res: Response) => { try { const userId = (req as IRequest).loginUserId; const token = generateToken(userId); - const newToken: IToken = { - userId, - device: req.headers["user-agent"] || "TEST DEVICE", - accessToken: token - } - await userRepositories.addToken(newToken); + const session = { userId, device: req.headers["user-device"], token: token, otp: null }; + await userRepositories.createSession(session); res.status(httpStatus.OK).json({ message: "Logged in successfully", data: { token } }); } catch (err) { - // return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: err.message }); + return res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ message: "Server error", data: err.message }); } } -export default { registerUser, loginUser } \ No newline at end of file +export default { registerUser, sendVerifyEmail, verifyEmail, loginUser } \ No newline at end of file diff --git a/src/modules/auth/repository/authRepositories.ts b/src/modules/auth/repository/authRepositories.ts index 2ade6bbf..c913e782 100644 --- a/src/modules/auth/repository/authRepositories.ts +++ b/src/modules/auth/repository/authRepositories.ts @@ -1,19 +1,30 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import Users from "../../../databases/models/users" -import Tokens from "../../../databases/models/tokens" -import { IToken } from "../../../types" +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 addToken = async (body: IToken) =>{ - return await Tokens.create(body); +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, addToken } \ 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 9e4e806d..6c5cbdef 100644 --- a/src/modules/auth/test/auth.spec.ts +++ b/src/modules/auth/test/auth.spec.ts @@ -1,40 +1,87 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Request, Response } from "express"; import chai, { expect } from "chai"; import chaiHttp from "chai-http"; import sinon from "sinon"; import httpStatus from "http-status"; import app from "../../.."; -import { isUserExist } from "../../../middlewares/validation"; +import { isUserExistByEmail } 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 = 0; +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.user.id; + expect(response.body).to.have.property("message", "Account created successfully. Please check email to verify account."); + done(error); + }); + }); + + 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({ + email: "user@example.com", + password: "userPassword" + }) + .end((error, response) => { + expect(response.status).to.equal(400); + expect(response.body).to.be.a("object"); + expect(response.body).to.have.property("message"); done(error); }); }); + it("Should be able to login a registered user", (done) => { router() .post("/api/auth/login") .send({ - email: "john.doe@example.com", - password: "password123" + email: "ecommerceninjas45@gmail.com", + password: "userPassword@123" }) .end((error, response) => { expect(response.status).to.equal(httpStatus.OK); @@ -46,6 +93,20 @@ describe("Authentication Test Cases", () => { }); }); + it("should return internal server error on login", (done) => { + sinon.stub(authRepositories, "createSession").throws(new Error("Database error")); + router() + .post("/api/auth/login") + .send({ + email: "ecommerceninjas45@gmail.com", + password: "userPassword@123" + }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); + done(err); + }); + }); + it("Should return validation error when no email or password given", (done) => { router() .post("/api/auth/login") @@ -64,7 +125,7 @@ describe("Authentication Test Cases", () => { .post("/api/auth/login") .send({ email: "fakeemail@gmail.com", - password: "fakepassword" + password: "userPassword@123" }) .end((error, response) => { expect(response).to.have.status(httpStatus.BAD_REQUEST); @@ -78,8 +139,8 @@ describe("Authentication Test Cases", () => { router() .post("/api/auth/login") .send({ - email: "user@example.com", - password: "fakepassword" + email: "ecommerceninjas45@gmail.com", + password: "fakePassword@123" }) .end((error, response) => { expect(response).to.have.status(httpStatus.BAD_REQUEST); @@ -89,26 +150,13 @@ describe("Authentication Test Cases", () => { }); }); - it("should return validation return message error and 400", (done) => { - router() - .post("/api/auth/register") - .send({ - email: "user@example.com", - password: "userPassword" - }) - .end((error, response) => { - expect(response.status).equal(400); - expect(response.body).to.be.a("object"); - expect(response.body).to.have.property("message"); - done(error); - }); - }); - -}) +}); describe("isUserExist Middleware", () => { + + before(() => { - app.post("/auth/register", isUserExist, (req: Request, res: Response) => { + app.post("/auth/register", isUserExistByEmail, (req: Request, res: Response) => { res.status(200).json({ message: "success" }); }); }); @@ -119,6 +167,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") @@ -130,13 +204,14 @@ 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" }) @@ -149,8 +224,19 @@ describe("isUserExist Middleware", () => { }); }); + it("should return internal server error on login", (done) => { + sinon.stub(authRepositories, "findUserByAttributes").throws(new Error("Database error")); + router() + .post("/api/auth/login") + .send({ email: "ecommerceninjas45@gmail.com", password: "userPassword@123" }) + .end((err, res) => { + expect(res).to.have.status(httpStatus.INTERNAL_SERVER_ERROR); + done(err); + }); + }); + 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") @@ -162,13 +248,14 @@ describe("isUserExist Middleware", () => { done(err); }); }); + }); 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(() => { @@ -185,7 +272,126 @@ 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(); + }); + + 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"); + } + }); + }); \ No newline at end of file diff --git a/src/modules/auth/validation/authValidations.ts b/src/modules/auth/validation/authValidations.ts index e9dba3c8..d74b5002 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 credentialSchema = 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,9 +21,15 @@ const authSchema = Joi.object({ }) }); -const loginSchema = Joi.object({ - email: Joi.string().email().required(), - password: Joi.string().min(6).required() +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 { authSchema, loginSchema }; \ No newline at end of file + +export { credentialSchema, emailSchema }; \ No newline at end of file diff --git a/src/routes/authRouter.ts b/src/routes/authRouter.ts index e43efd4c..dc5976f7 100644 --- a/src/routes/authRouter.ts +++ b/src/routes/authRouter.ts @@ -1,13 +1,15 @@ -import { validation, isUserExist, checkLoginUser } from "../middlewares/validation"; -import authControllers from "../modules/auth/controller/authControllers"; import { Router } from "express"; -import { authSchema, loginSchema } from "../modules/auth/validation/authValidations"; +import authControllers from "../modules/auth/controller/authControllers"; +import { validation, isUserExistByEmail, isAccountVerified, checkLoginUser } from "../middlewares/validation"; +import { emailSchema, credentialSchema } from "../modules/auth/validation/authValidations"; const router: Router = Router(); -router.post("/register", validation(authSchema), isUserExist, authControllers.registerUser); -router.post("/login", validation(loginSchema), checkLoginUser, authControllers.loginUser); +router.post("/register", validation(credentialSchema), isUserExistByEmail, authControllers.registerUser); +router.get("/verify-email/:token", isAccountVerified, authControllers.verifyEmail); +router.post("/send-verify-email", validation(emailSchema), isAccountVerified, authControllers.sendVerifyEmail); +router.post("/login", validation(credentialSchema), checkLoginUser, authControllers.loginUser); 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/swagger.json b/swagger.json index 9308a92c..4a94e02a 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.", @@ -85,7 +85,7 @@ } }, "responses": { - "200": { + "201": { "description": "Account created successfully.", "content": { "application/json": { @@ -96,20 +96,137 @@ "type": "string" }, "data": { - "type": "object", - "properties": { - "register": { - "$ref": "#/components/schemas/User" - }, - "token": { - "type": "string" - } + "user": { + "type": "object", + "$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" + } + } + } + } + } }, "500": { "description": "Internal server error", @@ -135,7 +252,7 @@ "/api/auth/login": { "post": { "tags": [ - "User" + "Authentication Routes" ], "summary": "Login User", "description": "Login a registered user by providing Email and Password",