diff --git a/package-lock.json b/package-lock.json index 6dfb8df5..18b97fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "sequelize-cli": "^6.6.2", "sequelize-typescript": "^2.1.6", "sinon": "^18.0.0", + "sinon-chai": "^3.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.2", @@ -9241,6 +9242,15 @@ "url": "https://opencollective.com/sinon" } }, + "node_modules/sinon-chai": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", + "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", + "peerDependencies": { + "chai": "^4.0.0", + "sinon": ">=4.0.0" + } + }, "node_modules/sinon/node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -10812,4 +10822,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 52f57a2a..ec0cff54 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "start": "ts-node-dev src/index.ts", "dev": "ts-node-dev src/index.ts", "test": "cross-env NODE_ENV=test npm run deleteAllTables && cross-env NODE_ENV=test npm run createAllTables && cross-env NODE_ENV=test npm run createAllSeeders && nyc cross-env NODE_ENV=test mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", + "test-local": "cross-env NODE_ENV=development npm run deleteAllTables && cross-env NODE_ENV=development npm run createAllTables && cross-env NODE_ENV=development npm run createAllSeeders && nyc cross-env NODE_ENV=development mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", "coveralls": "nyc --reporter=lcov --reporter=text-lcov npm test | coveralls", "coverage": "cross-env NODE_ENV=test nyc mocha --require ts-node/register 'src/**/*.spec.ts' --timeout 600000 --exit", "lint": "eslint . --ext .ts", @@ -82,6 +83,7 @@ "sequelize-cli": "^6.6.2", "sequelize-typescript": "^2.1.6", "sinon": "^18.0.0", + "sinon-chai": "^3.7.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.2", @@ -107,4 +109,4 @@ "eslint-plugin-import": "^2.29.1", "lint-staged": "^15.2.2" } -} \ No newline at end of file +} diff --git a/src/databases/config/config.ts b/src/databases/config/config.ts index 3e72492f..33e59b5c 100644 --- a/src/databases/config/config.ts +++ b/src/databases/config/config.ts @@ -1,15 +1,16 @@ +/* eslint-disable comma-dangle */ import dotenv from "dotenv"; dotenv.config(); const commonDatabaseConfig = { - dialect: "postgres" + dialect: "postgres", }; const sequelizeConfig = { development: { ...commonDatabaseConfig, - url: process.env.DATABASE_URL_DEV + url: process.env.DATABASE_URL_DEV, }, test: { ...commonDatabaseConfig, @@ -17,9 +18,9 @@ const sequelizeConfig = { dialectOptions: { ssl: { require: true, - rejectUnauthorized: false - } - } + rejectUnauthorized: false, + }, + }, }, production: { ...commonDatabaseConfig, @@ -27,10 +28,10 @@ const sequelizeConfig = { dialectOptions: { ssl: { require: true, - rejectUnauthorized: false - } - } - } + rejectUnauthorized: false, + }, + }, + }, }; -module.exports = sequelizeConfig; \ No newline at end of file +module.exports = sequelizeConfig; diff --git a/src/databases/config/db.config.ts b/src/databases/config/db.config.ts index c5208c53..0c54a72d 100644 --- a/src/databases/config/db.config.ts +++ b/src/databases/config/db.config.ts @@ -1,9 +1,10 @@ -import { config } from "dotenv" -import { Sequelize } from "sequelize" +/* eslint-disable comma-dangle */ +import { config } from "dotenv"; +import { Sequelize } from "sequelize"; -config() -const NODE_ENV: string = process.env.NODE_ENV || "development" -const DB_HOST_MODE: string = process.env.DB_HOST_TYPE || "remote" +config(); +const NODE_ENV: string = process.env.NODE_ENV || "development"; +const DB_HOST_MODE: string = process.env.DB_HOST_TYPE || "remote"; /** * Get the URI for the database connection. @@ -12,11 +13,11 @@ const DB_HOST_MODE: string = process.env.DB_HOST_TYPE || "remote" function getDbUri(): string { switch (NODE_ENV) { case "development": - return process.env.DATABASE_URL_DEV as string + return process.env.DATABASE_URL_DEV as string; case "test": - return process.env.DATABASE_URL_TEST as string + return process.env.DATABASE_URL_TEST as string; default: - return process.env.DATABASE_URL_PRO as string + return process.env.DATABASE_URL_PRO as string; } } @@ -30,15 +31,15 @@ function getDialectOptions() { : { ssl: { require: true, - rejectUnauthorized: false - } - } + rejectUnauthorized: false, + }, + }; } const sequelizeConnection: Sequelize = new Sequelize(getDbUri(), { dialect: "postgres", dialectOptions: getDialectOptions(), - logging: false -}) + logging: false, +}); -export default sequelizeConnection \ No newline at end of file +export default sequelizeConnection; diff --git a/src/index.spec.ts b/src/index.spec.ts index bbaef758..e4c2d294 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,20 +1,147 @@ -import chai, { expect } from "chai"; -import chaiHttp from "chai-http"; +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable comma-dangle */ import app from "./index"; +import chai from "chai"; +import chaiHttp from "chai-http"; +const { expect } = require("chai"); +const sinon = require("sinon"); +const sinonChai = require("sinon-chai"); +const { userAuthorization } = require("./middlewares/authorization"); +const httpStatus = require("http-status"); +import * as helpers from "./helpers/index"; +import authRepositories from "./modules/auth/repository/authRepositories"; chai.use(chaiHttp); -const router = () => chai.request(app) +chai.use(sinonChai); +// +const router = () => chai.request(app); describe("Initial configuration", () => { + it("Should return `Welcome to the e-Commerce-Ninja BackEnd` when GET on /", (done) => { + router() + .get("/") + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body).to.be.a("object"); + expect(res.body).to.have.property( + "message", + "Welcome to the e-Commerce Ninjas BackEnd." + ); + done(err); + }); + }); +}); + +describe("userAuthorization middleware", () => { + let req, res, next, roles; + + beforeEach(() => { + roles = ["admin", "user"]; + req = { + headers: {}, + user: null, + session: null, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub().returnsThis(), + }; + next = sinon.spy(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should respond with 401 if no authorization header", async () => { + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); + }); + + it("should respond with 401 if no session found", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves(null); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); + }); + + it("should respond with 401 if no user found", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon.stub(authRepositories, "findUserByAttributes").resolves(null); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); + }); + + it("should respond with 401 if user role is not authorized", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon + .stub(authRepositories, "findUserByAttributes") + .resolves({ role: "guest" }); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(res.status).to.have.been.calledWith(httpStatus.UNAUTHORIZED); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.UNAUTHORIZED, + message: "Not authorized", + }); + }); + + it("should call next if user is authorized", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").resolves({ id: "userId" }); + sinon.stub(authRepositories, "findSessionByUserIdAndToken").resolves({}); + sinon + .stub(authRepositories, "findUserByAttributes") + .resolves({ role: "admin" }); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); + + expect(next).to.have.been.calledOnce; + expect(req.user).to.deep.equal({ role: "admin" }); + expect(req.session).to.deep.equal({}); + }); + + it("should respond with 500 if an unexpected error occurs", async () => { + req.headers.authorization = "Bearer validToken"; + sinon.stub(helpers, "decodeToken").rejects(new Error("Unexpected error")); + + const middleware = userAuthorization(roles); + await middleware(req, res, next); - it("Should return `Welcome to the e-Commerce-Ninja BackEnd` when GET on /", (done) => { - router() - .get("/") - .end((err, res) => { - expect(res).to.have.status(200); - expect(res.body).to.be.a("object"); - expect(res.body).to.have.property("message", "Welcome to the e-Commerce Ninjas BackEnd."); - done(err); - }); + expect(res.status).to.have.been.calledWith( + httpStatus.INTERNAL_SERVER_ERROR + ); + expect(res.json).to.have.been.calledWith({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: "Unexpected error", }); + }); }); diff --git a/src/middlewares/authorization.ts b/src/middlewares/authorization.ts new file mode 100644 index 00000000..e863db1d --- /dev/null +++ b/src/middlewares/authorization.ts @@ -0,0 +1,64 @@ +/* eslint-disable comma-dangle */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Request, Response, NextFunction } from "express"; +import { UsersAttributes } from "../databases/models/users"; +import authRepository from "../modules/auth/repository/authRepositories"; +import httpStatus from "http-status"; +import { decodeToken } from "../helpers"; +import Session from "../databases/models/session"; + +interface ExtendedRequest extends Request { + user: UsersAttributes; + session: Session; +} + +export const userAuthorization = function (roles: string[]) { + return async (req: ExtendedRequest, res: Response, next: NextFunction) => { + try { + let token: string; + if (req.headers.authorization?.startsWith("Bearer")) { + token = req.headers.authorization.split(" ").at(-1); + } + + if (!token) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + const decoded: any = await decodeToken(token); + + const session: Session = await authRepository.findSessionByUserIdAndToken( + decoded.id, token + ); + if (!session) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + const user = await authRepository.findUserByAttributes("id", decoded.id); + + if (!user) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + if (!roles.includes(user.role)) { + res + .status(httpStatus.UNAUTHORIZED) + .json({ status: httpStatus.UNAUTHORIZED, message: "Not authorized" }); + } + + req.user = user; + req.session = session; + next(); + } catch (error: any) { + res.status(httpStatus.INTERNAL_SERVER_ERROR).json({ + status: httpStatus.INTERNAL_SERVER_ERROR, + message: error.message, + }); + } + }; +}; diff --git a/src/modules/auth/repository/authRepositories.ts b/src/modules/auth/repository/authRepositories.ts index 4eb75488..9f4504fb 100644 --- a/src/modules/auth/repository/authRepositories.ts +++ b/src/modules/auth/repository/authRepositories.ts @@ -1,31 +1,52 @@ +/* eslint-disable comma-dangle */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import Users from "../../../databases/models/users" -import Session from "../../../databases/models/session" +import Users from "../../../databases/models/users"; +import Session from "../../../databases/models/session"; + +const createUser = async (body: any) => { + return await Users.create(body); +}; + +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 +) => { + await Users.update( + { [updatedKey]: updatedValue }, + { where: { [whereKey]: whereValue } } + ); + return await findUserByAttributes(whereKey, whereValue); +}; -const createUser = async (body:any) =>{ - return await Users.create(body) -} +const createSession = async (body: any) => { + return await Session.create(body); +}; +const findSessionByUserId = async (userId: number) => { + return await Session.findOne({ where: { userId } }); +}; -const findUserByAttributes = async (key:string, value:any) =>{ - return await Users.findOne({ where: { [key]: value} }) +const findSessionByUserIdAndToken = async (userId: number, token: string) => { + return await Session.findOne({where: {token, userId}}) } -const updateUserByAttributes = async (updatedKey:string, updatedValue:any, whereKey:string, whereValue:any) =>{ - await Users.update({ [updatedKey]: updatedValue }, { where: { [whereKey]: whereValue} }); - return await findUserByAttributes(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 } }); -} +const destroySession = async (userId: number, token: string) => { + return await Session.destroy({ where: { userId, token } }); +}; -export default { createUser, createSession, findUserByAttributes, destroySession, updateUserByAttributes, findSessionByUserId } \ No newline at end of file +export default { + createUser, + createSession, + findUserByAttributes, + destroySession, + updateUserByAttributes, + findSessionByUserId, + findSessionByUserIdAndToken +};