From 489613ecab5c95bea0ea86456fc76bdfdfc3f8c4 Mon Sep 17 00:00:00 2001 From: Nick Louloudakis <38852788+luludak@users.noreply.github.com> Date: Sat, 23 Sep 2023 05:03:37 +0100 Subject: [PATCH] Added Project Files --- Dockerfile | 9 + README.md | 115 ++++++++++ TestDockerfile | 9 + __tests__/api/api.users-admin.test.js | 107 +++++++++ __tests__/api/api.users-generic.test.js | 195 +++++++++++++++++ __tests__/api/api.users-simple.test.js | 132 ++++++++++++ __tests__/app/app.performance.test.js | 23 ++ __tests__/app/app.unit.test.js | 44 ++++ __tests__/db/db.test.js | 99 +++++++++ __tests__/performance/performance-helper.js | 37 ++++ __tests__/performance/performance-test.yml | 87 ++++++++ __tests__/setup/setup.js | 39 ++++ __tests__/setup/teardown.js | 21 ++ __tests__/setup/test-helper.js | 7 + db-cleanup.js | 2 + docker-compose.yml | 33 +++ endpoints/auth.js | 33 +++ endpoints/orders.js | 168 +++++++++++++++ endpoints/users.js | 227 ++++++++++++++++++++ jest.config.js | 7 + models/order.js | 24 +++ models/user.js | 41 ++++ package.json | 42 ++++ server.js | 36 ++++ 24 files changed, 1537 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 TestDockerfile create mode 100644 __tests__/api/api.users-admin.test.js create mode 100644 __tests__/api/api.users-generic.test.js create mode 100644 __tests__/api/api.users-simple.test.js create mode 100644 __tests__/app/app.performance.test.js create mode 100644 __tests__/app/app.unit.test.js create mode 100644 __tests__/db/db.test.js create mode 100644 __tests__/performance/performance-helper.js create mode 100644 __tests__/performance/performance-test.yml create mode 100644 __tests__/setup/setup.js create mode 100644 __tests__/setup/teardown.js create mode 100644 __tests__/setup/test-helper.js create mode 100644 db-cleanup.js create mode 100644 docker-compose.yml create mode 100644 endpoints/auth.js create mode 100644 endpoints/orders.js create mode 100644 endpoints/users.js create mode 100644 jest.config.js create mode 100644 models/order.js create mode 100644 models/user.js create mode 100644 package.json create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03939cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:latest + +RUN mkdir -p ../app +WORKDIR ../app +COPY package.json ../app +RUN npm install +COPY . ../app +EXPOSE 3000 +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e04e836 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Simple User Registration & Authentication + +**This project was implemented and provided as the sample project for Software Testing Course 2022-23 at the University of Edinburgh**. + +The project exposes an API related to operations regarding User registration, authentication and usage, allowing a simple order placement of predefined boxes of an imaginary food shop under a limited amount of choices (`Box1` and `Box2`). The system also poses different user access levels (`Admin` and `User`), with elevated permissions to the administrator and respective restrictions to the simple user. + +The project is written in JavaScript (utilizing some of ES6 such as destructuring and `async/await` instead of promises, while keeping some of CommonJS traits, such as module loading via `require`), utilizing `Node.js` engine. In addition, `MongoDB` is utilized for the Database, and `Mongoose` package is utilized for usage in the project (DB schema definition, CRUD operations). + +The system utilizes `JWT` for authentication and `Bcrypt` for password encryption. +The system utilizes `Express` package for API buildup. + +For testing purposes, we utilize `Jest`, giving emphasis to Unit & Integration Tests. Although a UI interface is not included, you can always build a UI on top of the system and utilize additional frameworks for testing, such as `Puppeteer`. + +In addition, for performance testing and metrics reporting, we utilize [`Artillery`](https://www.artillery.io/). + +## Prerequisites + +[`Node.js`]("https://nodejs.org/en/"), [`MongoDB`]("https://www.mongodb.com/") and `npm` package need to be installed in your system. + +## Setup Instructions + +Setup by running `npm install` in the project folder. +This will generate a `node_modules` folder in your project folder containing all necessary packages. +Following that, and given you have downloaded and installed `MongoDB`, start it by doing `sudo systemctl start mongod`. +Once `MongoDB` is initiated, you can start your server by running `node server.js`. + +# File Structure + +```bash +├── .env.local | The local configuration file +├── .env | The configuration file used on CI. +├── db-cleanup.js | A script for DB cleanup +├── docker-compose.yml | Docker services composition file, used on CI. +├── Dockerfile | Main application Docker file, used on CI. +├── endpoints | Application Endpoint modules. +│   ├── auth.js +│   ├── orders.js +│   └── users.js +├── jest.config.js | Tests setup. +├── models | Mongoose DB schema definition modules +│   ├── order.js +│   └── user.js +├── package.json | Package installation and app setup file. +├── README.md +├── server.js | Main server file. +├── TestDockerfile | Test image on Docker creation file. +└── __tests__ | Test files, structured by functionality. + ├── api + │   ├── api.users-admin.test.js + │   ├── api.users-generic.test.js + │   └── api.users-simple.test.js + ├── app + │   ├── app.performance.test.js + │   └── app.unit.test.js + ├── db + │   └── db.test.js + ├── performance | Tests used for performance runs. + │   ├── performance-helper.js + │   └── performance-test.yml + └── setup | Tests Setup/Teardown helper files. + ├── setup.js + ├── teardown.js + └── test-helper.js +``` + +## Run Instructions + +Before setting up the project, make sure that MongoDB is setup, by running: + +``` +sudo systemctl status mongod +``` + +You can start it by doing `sudo systemctl start mongod`, or stop it by doing `sudo systemctl stop mongod`. + +To setup the server locally, run `node server.js`. The project will start. +You can then setup options such as port in the respective configuration file. + +Keep in mind that you will need to use the _local_ configuration, therefore it is recommended +thet you rename `.env.local` file to `.env` (discarding or renaming the existing `.env` file which relates to CI), configure your system so that it utilizes `.env.local` before you start the server locally or ignore local changes on `.env`file and rename the `.env.local`. + +To execute Unit/Integration tests, run `npm test`. +Tests will then be executed and results will be displayed on console. + +To execute Performance tests, run `npm run performance`. +Performance stress tests will then run and results will be displayed on console, but also be exported in `JSON` format in `performance.json` file on the base project folder. + +To generate a report of performance results after their run, you can run `npm run report`. +This will automatically generate an `html` file, visually presenting the results in diagrams and statistics. + +# Configuration + +You can configure a certain number of parameters, such as system port and auth key secret at your `.env` configuration file. + +# DB Cleanup + +The test suite contains setup and teardown logic, leaving the database in a clean state after each run. + +However, in case you ended up with a database containing unwanted test entries, you can clean it all up by running `node db-cleanup.js`. + +# Continuous Integration + +In order to setup the application for Continuous Integration tests execution (which are all tests, excluding the ones for performance), we have included some Docker files related to it to the project. However, you need to make a number of steps in order to prepare your project to CI/CD. We used `Gitlab CI` in order to do so, but you are free to choose another technology in case you want to do so: + +_Disclaimer: All actions are proposed in order to setup the project and are just suggestions. Since they require applying sudo permissions to processes. Use at your own risk and discretion. It is highly recommended not to do it on any device containing sensitive data._ + +1. Clone the project to a new Gitlab repository. +2. Enable CI/CD in your project. You can find directions [here](https://docs.gitlab.com/ee/ci/enable_or_disable_ci.html). +3. Register a Gitlab Runner in a machine you wish to run it. Once again, you can follow the instructions provided in the [official Gitlab documentation](https://docs.gitlab.com/runner/register/). +4. Grant elevated permissions to the runner by modifying the `config.toml` file and setting `privileged = true` and `volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]`. An indicated location for the runner is `/etc/gitlab-runner`. +5. Install Docker and Docker Compose ([instructions here](https://docs.docker.com/engine/install/)) in that same machine, and set their permissions so that they can run without sudo command - so that they can be utilized by the CI pipeline on the runner. +6. Start gitlab runner by running `sudo gitlab-runner start`. +7. Setup a local docker network on your device, with the name "mynetwork". You can do so by running `docker network create mynetwork`. This is crucial, as otherwise your docker components will not be able to communicate each other. +8. Important note: make sure that your local `mongodb` instance or any other local service does not run on the same port with the one the docker containers are setup to run, as you will end up having conflicts. By default, we have setup the port `3000` for our API app, and `27017` for our `mongodb` instance. +9. You are all set! Commit to your repo and you should see the runner running your CI pipeline and the tests passing, upon successful installation. diff --git a/TestDockerfile b/TestDockerfile new file mode 100644 index 0000000..dce5cb1 --- /dev/null +++ b/TestDockerfile @@ -0,0 +1,9 @@ +FROM node:latest + +RUN mkdir -p ../app +WORKDIR ../app +COPY package.json ../app +RUN npm install +COPY . ../app + +CMD ["npm", "test"] \ No newline at end of file diff --git a/__tests__/api/api.users-admin.test.js b/__tests__/api/api.users-admin.test.js new file mode 100644 index 0000000..6100afc --- /dev/null +++ b/__tests__/api/api.users-admin.test.js @@ -0,0 +1,107 @@ +const axios = require("axios"); +const {prepare} = require("../setup/test-helper"); + +describe("Admin User Tests", () => { + + let config = null; + let userLogin = null; + let userToDeleteLogin = null; + let adminLogin = null; + let admin2Login = null; + + beforeAll(async () => { + + // Login all related users. + + userLogin = await axios.post(prepare("/users/login/"), { + email: "testuser@test.com", + password: "12345" + }); + + userToDeleteLogin = await axios.post(prepare("/users/login/"), { + email: "testusertodelete@test.com", + password: "12345" + }); + + adminLogin = await axios.post(prepare("/users/login/"), { + email: "test@test.com", + password: "12345" + }); + + admin2Login = await axios.post(prepare("/users/login/"), { + email: "test2@test.com", + password: "12345" + }); + + const {accessToken} = adminLogin.data; + + // Note we use JWT authentication for the API, + // therefore we need to authenticate our request for the test. + config = { + headers: { Authorization: `Bearer ${accessToken}` } + } + }); + + it("should get all users", async () => { + const response = await axios.get(prepare("/users"), config); + expect(response.status).toEqual(200); + }); + + it("should get posted orders", async () => { + // Get all orders of user. + const response = await axios.get(prepare("/orders"), config); + expect(response.status).toEqual(200); + }); + + it("should get orders of another user", async () => { + // Get all orders of user. + const {id} = userLogin.data.user; + const response = await axios.get(prepare("/orders/user/" + id), config); + expect(response.status).toEqual(200); + }); + + + it("should hit generic admin user error", async () => { + await axios.get(prepare("/users/nouser"), config).catch(error => { + // Unauthorized + expect(error.response.status).toEqual(400); + }); + }); + + + it("should add an order", async () => { + // Insert order. + const insertedOrderResponse = await axios.post(prepare("/order"), { + "type": "Box2", + "description": "{Test Order}" + }, config); + + const {_id} = insertedOrderResponse.data; + + // Get previously inserted object and check. + const responseOrders = await axios.get(prepare("/order/" + _id), config); + expect(responseOrders.data.type).toEqual("Box2"); + + }); + + it("should delete a simple user", async () => { + const response = await axios.delete(prepare("/user/" + userToDeleteLogin.data.user.id), config); + expect(response.status).toEqual(200); + + }); + + it("should fail to delete an admin user", async () => { + await axios.delete(prepare("/user/" + admin2Login.data.user.id), config).catch(error => { + // Unauthorized + expect(error.response.status).toEqual(403); + }); + }); + + it("should update credentials", async () => { + expect(true).toEqual(true); + }); + + it("should fail updating credentials of another user", async () => { + expect(true).toEqual(true); + }); +}); \ No newline at end of file diff --git a/__tests__/api/api.users-generic.test.js b/__tests__/api/api.users-generic.test.js new file mode 100644 index 0000000..4e92b34 --- /dev/null +++ b/__tests__/api/api.users-generic.test.js @@ -0,0 +1,195 @@ + +const axios = require("axios"); +const {prepare} = require("../setup/test-helper"); + +describe("Generic User Tests", () => { + + let adminLogin = null; + + beforeAll(async () => { + // Login admin user. + + adminLogin = await axios.post(prepare("/users/login/"), { + email: "test@test.com", + password: "12345" + }); + }); + + it("should check system is on", async () => { + const response = await axios.get(prepare("/")); + expect(response.status).toEqual(200); + }); + + + it("should login user", async () => { + const userLogin = await axios.post(prepare("/users/login/"), { + email: "testuser@test.com", + password: "12345" + }); + const {accessToken} = userLogin.data; + expect(userLogin.status).toEqual(200); + expect(accessToken).not.toEqual(null); + }); + + + it("should fail to login user (wrong password)", async () => { + await axios.post(prepare("/users/login/"), { + email: "testuser@test.com", + password: "1234567" + }).catch((error) => { + const {response} = error; + const {accessToken} = response.data; + expect(response.status).toEqual(401); + expect(accessToken).toEqual(null); + }); + + }); + + it("should hit random endpoint", async () => { + await axios.get(prepare("/user/something")).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(404); + }); + }); + + it("should fail unauthorized Access actions", async () => { + + await axios.get(prepare("/users")).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.get(prepare("/orders")).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.get(prepare("/orders/user/" + adminLogin.data.user.id)).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.post(prepare("/order"), { + "type": "Box2", + "description": "{Test Order}" + }).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.put(prepare("/order"), { + "type": "Box1", + "description": "{Test Order}" + }).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.delete(prepare("/order/12345"), { + "type": "Box2", + "description": "{Test Order}" + }).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.put(prepare("/user"), { + "name": "ShouldNotUpdate" + }).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + await axios.delete(prepare("/user/12345"), { + "type": "Box2", + "description": "{Test Order}" + }).catch(error => { + // 401 - Unauthorized Access + expect(error.response.status).toEqual(401); + }); + + }); + + it("should register user", async () => { + const response = await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testusernew@test.com", + "password": "12345", + "address": "Somewhere 10" + }); + expect(response.status).toEqual(201); + }); + + it("should fail to register user (existing email)", async () => { + + + const response = await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testuserbrandnew@test.com", + "password": "12345", + "address": "Somewhere 10" + }); + + expect(response.status).toEqual(201); + + await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testuserbrandnew@test.com", + "password": "12345", + "address": "Somewhere 10" + }).catch(error => { + // Conflict - user exists. + expect(error.response.status).toEqual(409); + }); + + + }); + + it("should fail to register user (malformed email)", async () => { + await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testuseranothernew", + "password": "12345", + "address": "Somewhere 10" + }).catch(error => { + expect(error.response.status).toEqual(400); + }); + }); + + it("should fail to register user (no password)", async () => { + await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testuseranothernew2@test.com", + "address": "Somewhere 10" + }).catch(error => { + expect(error.response.status).toEqual(400); + }); + }); + + it("should fail to register user (no name)", async () => { + await axios.post(prepare("/users/register"), { + "role": "User", + "email": "testuseranothernew3@test.com", + "password": "12345", + "address": "Somewhere 10" + }).catch(error => { + expect(error.response.status).toEqual(400); + }); + }); + + it("should fail to register user (no address)", async () => { + await axios.post(prepare("/users/register"), { + "name": "User New", + "role": "User", + "email": "testuseranothernew4@test.com", + "password": "12345", + }).catch(error => { + expect(error.response.status).toEqual(400); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/api.users-simple.test.js b/__tests__/api/api.users-simple.test.js new file mode 100644 index 0000000..d493e37 --- /dev/null +++ b/__tests__/api/api.users-simple.test.js @@ -0,0 +1,132 @@ + +const axios = require("axios"); +const {prepare} = require("../setup/test-helper"); + + +describe("Simple User Tests", () => { + + let config = null; + let adminLogin = null; + + beforeAll(async () => { + + // Login all related users. + + adminLogin = await axios.post(prepare("/users/login/"), { + email: "test@test.com", + password: "12345" + }); + + const login = await axios.post(prepare("/users/login/"), { + email: "testuser@test.com", + password: "12345" + }); + + const {accessToken} = login.data; + + config = { + headers: { Authorization: `Bearer ${accessToken}` } + }; + }); + + it("should fail to get users", async () => { + + const response = await axios.get(prepare("/users"), config).catch(error => { + expect(error.response.status).toEqual(403); + }); + + }); + + it("should update credentials", async () => { + + await axios.put(prepare("/user"), { + "name": "Updated User" + }, config); + + const response = await axios.get(prepare("/user"), config); + + const {data} = response; + expect(data.name).toEqual("Updated User"); + }); + + it("should fail updating credentials of another user", async () => { + expect(true).toEqual(true); + }); + + it("should get user orders", async () => { + const response = await axios.get(prepare("/orders"), config); + expect(response.status).toEqual(200); + }); + + + it("should get a specific order", async () => { + + // Insert Order. + await axios.post(prepare("/order"), { + "type": "Box1", + "description": "{Test Order}" + }, config); + + const allOrdersResponse = await axios.get(prepare("/orders"), config); + + const {data} = allOrdersResponse; + const firstOrderID = data[0]._id; + + const singleOrderResponse = await axios.get(prepare("/order/" + firstOrderID), config); + expect(singleOrderResponse.status).toEqual(200); + }); + + it("should add, then update an order", async () => { + // Insert order. + const insertedResponse = await axios.post(prepare("/order"), { + "type": "Box2" + }, config); + + // Update order. + const updated = await axios.put(prepare("/order/"), { + "_id": insertedResponse.data._id, + "type": "Box1", + "description": "{Test Order Updated}" + }, config); + + + // Get previously inserted object and check. + const orderResponse = await axios.get(prepare("/order/") + insertedResponse.data._id, config); + const {data} = orderResponse; + + + expect(data.type).toEqual("Box1"); + expect(data.description).toEqual("{Test Order Updated}"); + }); + + + it("should add, then delete an order", async () => { + const insertedOrderResponse = await axios.post(prepare("/order"), { + "type": "Box2", + "description": "{Test Order}" + }, config); + + const {data} = insertedOrderResponse; + + await axios.delete(prepare("/order/" + data._id), config); + await axios.get(prepare("/order/" + data._id), config).catch(error => { + expect(error.response.status).toEqual(404); + }); + + }); + + it("should fail to access orders of another user", async () => { + await axios.get(prepare("/orders/user/" + adminLogin.data.user.id), config).catch(error => { + // Unauthorized + expect(error.response.status).toEqual(403); + }); + }); + + it("should fail deleting a user", async () => { + await axios.delete(prepare("/user/" + adminLogin.data.user.id), config).catch(error => { + // Unauthorized + expect(error.response.status).toEqual(403); + }); + }); + +}); \ No newline at end of file diff --git a/__tests__/app/app.performance.test.js b/__tests__/app/app.performance.test.js new file mode 100644 index 0000000..3b526a0 --- /dev/null +++ b/__tests__/app/app.performance.test.js @@ -0,0 +1,23 @@ +const express = require("express"); +const axios = require("axios"); +const {prepare} = require("../setup/test-helper") + +// This is a very basic, simple way one can check for thresholds in app execution. +// Although provided for demonstration purposes, the usage of Artillery tests for +// performance testing is recommended +describe("Testing Routes with different time thresholds.", () => { + it("Get / in 1000ms", async () => { + const response = await axios.get(prepare("/")); + expect(response.status).toEqual(200); + }, 1000); + + it("Get / in 500ms", async () => { + const response = await axios.get(prepare("/")); + expect(response.status).toEqual(200); + }, 500); + + it("Get / in 100ms", async () => { + const response = await axios.get(prepare("/")); + expect(response.status).toEqual(200); + }, 100); +}); \ No newline at end of file diff --git a/__tests__/app/app.unit.test.js b/__tests__/app/app.unit.test.js new file mode 100644 index 0000000..1a63bc0 --- /dev/null +++ b/__tests__/app/app.unit.test.js @@ -0,0 +1,44 @@ +const axios = require("axios"); +const mongoose = require("mongoose"); +const {prepare} = require("../setup/test-helper"); +require('dotenv').config({ path: '.env' }); + + +describe("Basic Unit Tests.", () => { + it("should check system is on", async () => { + const response = await axios.get(prepare("/")); + expect(response.status).toEqual(200); + }); + + it("should check env vars is properly loaded", async () => { + expect(process.env.TEST_FLAG === "true").toEqual(true); + }); +}); + +/* Included Mocking test as a sample. +* Consider in case of project extension to test components +* utilizing them. +*/ +describe("Mock Endpoints Demo", () => { + + let axiosMocked = null; + + beforeAll(() => { + jest.mock('axios'); + axiosMocked = require("axios"); + axiosMocked.get.mockResolvedValue({ + data: {}, + status: 200 + }); + + }); + + it("Check mock endpoints.", async () => { + const response = await axiosMocked.get(prepare("/users")); + expect(response.status).toEqual(200); + }); + + afterAll(() => { + jest.unmock('axios'); + }); +}); \ No newline at end of file diff --git a/__tests__/db/db.test.js b/__tests__/db/db.test.js new file mode 100644 index 0000000..d5d0698 --- /dev/null +++ b/__tests__/db/db.test.js @@ -0,0 +1,99 @@ + +const axios = require("axios"); +const mongoose = require("mongoose"); +const { User } = require("../../models/user"); +const { Order } = require("../../models/order"); +require('dotenv').config({ path: '.env' }); + +describe("Database Tests.", () => { + beforeAll(async () => { + await mongoose.connect( + process.env.DB_ENDPOINT + ); + }); + + it("should check DB connection was successful", async () => { + const state = mongoose.STATES[mongoose.connection.readyState]; + expect(state).toEqual("connected"); + }); + + it("Add User", async () => { + const user = await new User({ + "name": "User New", + "role": "User", + "email": "testuserfordb1@test.com", + "password": "12345", + "address": "Somewhere 10" + }).save(); + + const userFound = User.findOne({ _id: user._id }); + expect(userFound).not.toEqual(null); + }); + + it("Update User", async () => { + const user = await new User({ + "name": "User New", + "role": "User", + "email": "testuserfordb2@test.com", + "password": "12345", + "address": "Somewhere 10" + }).save(); + + const userFound = await User.findByIdAndUpdate(user._id, {"role": "Admin"}, {"new": true}); + expect(userFound.role).toEqual("Admin"); + }); + + it("Delete User", async () => { + + const user = await new User({ + "name": "User New", + "role": "User", + "email": "testuserfordb3@test.com", + "password": "12345", + "address": "Somewhere 10" + }).save(); + + await User.deleteOne({_id: user._id}); + + const userFound = await User.findOne({ _id: user._id }); + expect(userFound).toEqual(null); + }); + + it("Add Order", async () => { + const order = await new Order({ + "type": "Box1", + "description": "{Test Order}" + }).save(); + + const orderFound = Order.findOne({ _id: order._id }); + expect(orderFound).not.toEqual(null); + }); + + it("Update Order", async () => { + const order = await new Order({ + "type": "Box1", + "description": "{Test Order}" + }).save(); + + const orderFound = await Order.findByIdAndUpdate(order._id, {"type": "Box2"}, {"new": true}); + expect(orderFound.type).toEqual("Box2"); + }); + + it("Delete Order", async () => { + const order = await new Order({ + "type": "Box1", + "description": "{Test Order}" + }).save(); + + await Order.deleteOne({_id: order._id}); + + const orderFound = await Order.findOne({ _id: order._id }); + expect(orderFound).toEqual(null); + }); + + + afterAll(async () => { + await mongoose.connection.close(); + }); + +}); \ No newline at end of file diff --git a/__tests__/performance/performance-helper.js b/__tests__/performance/performance-helper.js new file mode 100644 index 0000000..084c52e --- /dev/null +++ b/__tests__/performance/performance-helper.js @@ -0,0 +1,37 @@ +const generateSignupData = (requestParams, ctx, ee, next) => { + ctx.vars["name"] = "Test PerformanceUser"; + ctx.vars["email"] = "testperformanceuser@test.com"; + ctx.vars["password"] = "12345"; + ctx.vars["address"] = "Somewhere X"; + ctx.vars["role"] = "Admin"; + + return next(); +} + +const generateLoginData = (requestParams, ctx, ee, next) => { + ctx.vars["email"] = "testperformanceuser@test.com"; + ctx.vars["password"] = "12345"; + + return next(); +} + +const generateOrderData = (requestParams, ctx, ee, next) => { + ctx.vars["type"] = "Box1"; + ctx.vars["description"] = "{Test Order - Performance}"; + + return next(); +} + +const generateUpdatedOrderData = (requestParams, ctx, ee, next) => { + ctx.vars["type"] = "Box1"; + ctx.vars["description"] = "{Test Order - Updated}"; + + return next(); +} + +module.exports = { + generateSignupData, + generateLoginData, + generateOrderData, + generateUpdatedOrderData +}; \ No newline at end of file diff --git a/__tests__/performance/performance-test.yml b/__tests__/performance/performance-test.yml new file mode 100644 index 0000000..50a96d6 --- /dev/null +++ b/__tests__/performance/performance-test.yml @@ -0,0 +1,87 @@ +# Performance test focuses into measuring the times +# of the most commonly used operations in the API. +# For simplicity (to avoid multiple users addition, etc), +# we have omitted user deletion as this is a rare operation, +# but it can be easily added to the scenarios. +config: + target: "http://localhost:3000" + phases: + - duration: 10 + arrivalRate: 10 + processor: "./performance-helper.js" + plugins: + metrics-by-endpoint: + # Group metrics by request name rather than URL. + # To focus per-scenario, set this to false. + useOnlyRequestNames: true + +scenarios: + - name: "Check API" + flow: + - get: + url: "/" + + - name: "Register User" + arrivalRate: 1 + flow: + - post: + url: "/users/register" + beforeRequest: generateSignupData + json: + name: "{{ name }}" + email: "{{ email }}" + password: "{{ password }}" + role: "{{ role }}" + address: "{{ address }}" + + - name: "Login user and apply multiple order actions" + flow: + - post: + url: "/users/login" + beforeRequest: generateLoginData + json: + email: "{{ email }}" + password: "{{ password }}" + capture: + - json: "$.accessToken" + as: "accessToken" + + # Order placement. + - post: + url: "/order" + beforeRequest: generateOrderData + headers: + Authorization: 'Bearer {{ accessToken }}' + json: + type: "{{ type }}" + description: "{{ description }}" + capture: + - json: "$._id" + as: "orderID" + + # Access actions. + - get: + url: "/users" + headers: + Authorization: 'Bearer {{ accessToken }}' + + - get: + url: "/orders" + headers: + Authorization: 'Bearer {{ accessToken }}' + + # Modification/Deletion Actions. + - put: + url: "/order" + beforeRequest: generateUpdatedOrderData + headers: + Authorization: 'Bearer {{ accessToken }}' + json: + _id: "{{ orderID }}" + type: "{{ type }}" + description: "{{ description }}" + + - delete: + url: "/order/{{ orderID }}" + headers: + Authorization: 'Bearer {{ accessToken }}' \ No newline at end of file diff --git a/__tests__/setup/setup.js b/__tests__/setup/setup.js new file mode 100644 index 0000000..54de94c --- /dev/null +++ b/__tests__/setup/setup.js @@ -0,0 +1,39 @@ +const axios = require("axios"); +const {prepare} = require("./test-helper") + +module.exports = async () => { + // We use the API to register. + // Alternatively, you can do it by using Mongoose API, + // by applying direct DB operations. + await axios.post(prepare("/users/register"), { + "name": "Admin", + "role": "Admin", + "email": "test@test.com", + "password": "12345", + "address": "Somewhere 10" + }); + + await axios.post(prepare("/users/register"), { + "name": "Admin2", + "role": "Admin", + "email": "test2@test.com", + "password": "12345", + "address": "Somewhere 10" + }); + + await axios.post(prepare("/users/register"), { + "name": "User", + "role": "User", + "email": "testuser@test.com", + "password": "12345", + "address": "Somewhere 10" + }); + + await axios.post(prepare("/users/register"), { + "name": "User ToDelete", + "role": "User", + "email": "testusertodelete@test.com", + "password": "12345", + "address": "Somewhere 10" + }); +}; \ No newline at end of file diff --git a/__tests__/setup/teardown.js b/__tests__/setup/teardown.js new file mode 100644 index 0000000..6493a14 --- /dev/null +++ b/__tests__/setup/teardown.js @@ -0,0 +1,21 @@ +const mongoose = require("mongoose"); +const { User } = require("../../models/user"); +const { Order } = require("../../models/order"); + +require('dotenv').config({ path: '.env' }); + +module.exports = async () => { + + // Contrary to setup, for demonstration purposes, + // we use Mongoose to cleanup the DB. + await mongoose.connect( + process.env.DB_ENDPOINT + ); + + await User.deleteMany({email: /^test/}).catch(e => { + console.log(e) + }); + await Order.deleteMany({description: /\{Test\sOrder/}); + + await mongoose.connection.close(); +} \ No newline at end of file diff --git a/__tests__/setup/test-helper.js b/__tests__/setup/test-helper.js new file mode 100644 index 0000000..efac810 --- /dev/null +++ b/__tests__/setup/test-helper.js @@ -0,0 +1,7 @@ +require('dotenv').config({ path: '.env' }); + +module.exports = { + prepare: (endpoint) => { + return `${process.env.BASE_URL}:${process.env.PORT}${process.env.URL_POSTFIX}${endpoint}`; + } +}; \ No newline at end of file diff --git a/db-cleanup.js b/db-cleanup.js new file mode 100644 index 0000000..8fe3f7f --- /dev/null +++ b/db-cleanup.js @@ -0,0 +1,2 @@ +require("./__tests__/setup/teardown")(); +console.log("DB Cleanup complete.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70db045 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3" + +services: + st-sample: + container_name: st-sample + image: sample + restart: always + build: . + expose: + - "3000" + ports: + - "3000:3000" + links: + - mongo + networks: + - mynetwork + + mongo: + container_name: mongo + image: mongo + volumes: + - /sampledb + expose: + - "27017" + ports: + - "27017:27017" + + networks: + - mynetwork + +networks: + mynetwork: + external: true \ No newline at end of file diff --git a/endpoints/auth.js b/endpoints/auth.js new file mode 100644 index 0000000..9718f48 --- /dev/null +++ b/endpoints/auth.js @@ -0,0 +1,33 @@ +require('dotenv').config(); + +var jwt = require("jsonwebtoken"); + +const user = require("../models/user"); +const User = user.User; + +/* Auth User module. */ +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (token == null){ + return res.sendStatus(401); + } + jwt.verify(token, process.env.API_SECRET, (error, user) => { + + if (error) { + console.log(error) + return res.status(403).send({"message": "Unauthorized access."}); + } + + User.findOne({_id: user.id}).then(userFound => { + user.role = userFound.role; + req.user = user; + next(); + }); + }); +} + +module.exports = { + authenticateToken: authenticateToken +}; \ No newline at end of file diff --git a/endpoints/orders.js b/endpoints/orders.js new file mode 100644 index 0000000..62a7751 --- /dev/null +++ b/endpoints/orders.js @@ -0,0 +1,168 @@ +require('dotenv').config(); + +const {authenticateToken} = require("./auth"); + +const user = require("../models/user"); +const User = user.User; + +const order = require("../models/order"); +const Order = order.Order; + +module.exports = (app) => { + + /* Get orders of any user. */ + app.get("/orders/user/:userID", authenticateToken, async (req, res) => { + try { + + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + const {userID} = req.params; + const allOrders = await Order.find({user: userID}); + + return res.status(200).json(allOrders); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Get orders of user. */ + app.get("/orders", authenticateToken, async (req, res) => { + try{ + let allOrders = await Order.find({ user : req.user.id }); + return res.status(200).json(allOrders); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Get a single order info. */ + app.get("/order/:orderID", authenticateToken, async (req, res) => { + try{ + const {orderID} = req.params; + const orderFound = await Order.findOne({ _id: orderID }); + + if(orderFound) { + // Admin can see orders of any person, including himself/herself. + // Simple users can only access their orders. + if(req.user.role !== "Admin" && orderFound.user.toString() !== req.user.id) { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + return res.status(200).json(orderFound); + } else { + return res.status(404).json({ + "message": "No order found." + }); + } + } catch(error) { + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Add a new order. Admins can NOT add orders for other members. */ + app.post("/order", authenticateToken, async (req, res) => { + try { + const newOrder = new Order({ ...req.body }); + newOrder.user = req.user.id; + const userExists = await User.exists({ _id: req.user.id }); + // Obtain orders only if respective user exists. + if(userExists) { + const insertedOrder = await newOrder.save(); + return res.status(201).json(insertedOrder); + } else { + return res.status(400).json({ + "message": "No user associated with order." + }); + } + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Update an *EXISTING* order. Admins CAN update orders of other members. */ + app.put("/order", authenticateToken, async (req, res) => { + try { + const id = req.body._id; + const orderFound = await Order.findOne({ _id: id }); + // If the user is not admin, then no access to other user orders + // should be allowed. + if(req.user.role !== "Admin" && orderFound.user.toString() !== req.user.id) { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + + if(orderFound) { + const updatedOrder = await Order.findByIdAndUpdate(id, req.body, {"new": true}); + return res.status(201).json(updatedOrder); + } else { + return res.status(404).json({ + "message": "No order found." + }); + } + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Delete an order. */ + app.delete("/order/:orderID", authenticateToken, async (req, res) => { + try{ + const { orderID } = req.params; + const orderFound = await Order.findOne({ _id: orderID }); + + if(req.user.role !== "Admin" && orderFound.user.toString() !== req.user.id) { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + + const orderDeleted = await Order.findByIdAndDelete(orderID); + return res.status(200).json(orderDeleted); + } catch(error) { + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Get orders of any user. */ + app.get("/orders/user/:userID", authenticateToken, async (req, res) => { + try { + + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + const {userID} = req.params; + const allOrders = await Order.find({user: userID}); + return res.status(200).json(allOrders); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); +} \ No newline at end of file diff --git a/endpoints/users.js b/endpoints/users.js new file mode 100644 index 0000000..73bd348 --- /dev/null +++ b/endpoints/users.js @@ -0,0 +1,227 @@ +var jwt = require("jsonwebtoken"); +var bcrypt = require("bcrypt"); + +require('dotenv').config(); + +const user = require("../models/user"); +const User = user.User; + +const order = require("../models/order"); +const Order = order.Order; + +const {authenticateToken} = require("./auth"); + +module.exports = (app) => { + + /****** User Authentication Tasks. ******/ + + /* Register a new user */ + app.post("/users/register", async (req, res) => { + try { + // TODO: Check if it can be removed. + // If email is in bad form, return 400. + if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) { + return res.status(400).json({ + "message": "Email is in bad form." + }); + } + // If user exists (email), prevent insertion. + const userExists = await User.exists({ email: req.body.email }); + if(!userExists) { + let data = { ...req.body }; + data.password = bcrypt.hashSync(req.body.password, 8); + const newUser = new User(data); + const insertedUser = await newUser.save(); + return res.status(201).json(insertedUser); + } else { + return res.status(409).json({ + "message": "User Exists." + }); + } + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Authenticate a user. */ + app.post("/users/login", async (req, res) => { + try{ + User.findOne({ email: req.body.email }).exec((error, user) => { + if (error) { + res.status(500).send({ + message: err + }); + return; + } + if (!user) { + return res.status(404).send({ + message: "User Not found." + }); + } + + //Compare passwords. + var passwordIsValid = bcrypt.compareSync( + req.body.password, + user.password + ); + // Checking if password was valid and send response accordingly. + if (!passwordIsValid) { + return res.status(401).send({ + accessToken: null, + message: "Invalid Password!" + }); + } + //signing token with user id (utilizing API secret). + var token = jwt.sign({ + id: user.id + }, process.env.API_SECRET, { + expiresIn: 86400 + }); + + // Responding to client request with user profile success message. + res.status(200) + .send({ + user: { + id: user._id, + email: user.email, + role: user.role, + name: user.name, + }, + message: "Login successfull", + accessToken: token, + }); + }); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* ADMINISTRATOR TASKS. */ + app.get("/users", authenticateToken, async (req, res) => { + try{ + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + + const allUsers = await User.find(); + return res.status(200).json(allUsers); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Get information of any user. */ + app.get("/users/:userID", authenticateToken, async (req, res) => { + try{ + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + const { userID } = req.params; + const userFound = await User.findOne({ user: userID }); + return res.status(200).json(userFound); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Delete a user. */ + app.delete("/user/:userID", authenticateToken, async (req, res) => { + try{ + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + const { userID } = req.params; + const userFound = await User.findOne({ _id: userID }); + if(userFound.role === "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access - Admins can not delete admins." + }); + } + + const userDeleted = await User.findByIdAndDelete(userID); + return res.status(200).json(userDeleted); + } catch(error) { + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* USER TASKS */ + + /* Get information of self user. */ + app.get("/user", authenticateToken, async (req, res) => { + try { + const userFound = await User.findOne({ _id: req.user.id }); + return res.status(200).json(userFound); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Update information of self user. */ + app.put("/user", authenticateToken, async (req, res) => { + try { + const userFound = await User.findOne({ _id: req.user.id }); + if(userFound) { + const updatedUser = await User.findByIdAndUpdate(req.user.id, req.body, {"new": true}); + return res.status(201).json(updatedUser); + } else { + return res.status(400).json({ + "message": "No user found." + }); + } + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); + + /* Get information of a user. */ + app.get("/users/:userID", authenticateToken, async (req, res) => { + try{ + + // This is an admin-only operation. + if(req.user.role !== "Admin") { + return res.status(403).json({ + "message": "Unauthorized Access." + }); + } + + const { userID } = req.params; + const userFound = await User.findOne({ user: userID }); + return res.status(200).json(userFound); + } catch(error) { + console.log(error); + return res.status(400).json({ + "message": "Bad Request." + }); + } + }); +}; \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..cdbff62 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +//jest.config.js +module.exports = { + testTimeout: 20000, + globalSetup: "./__tests__/setup/setup.js", + globalTeardown: "./__tests__/setup/teardown.js", + modulePathIgnorePatterns: ["./__tests__/setup/*", "./__tests__/performance/*"] +}; \ No newline at end of file diff --git a/models/order.js b/models/order.js new file mode 100644 index 0000000..5fc8782 --- /dev/null +++ b/models/order.js @@ -0,0 +1,24 @@ +// @/order.js +const mongoose = require("mongoose"); + +const OrderSchema = new mongoose.Schema({ + type: { + type: String, + enum : ['Box1','Box2'], + default: 'Box1', + required: false, + }, + description: { + type: String, + required: false, + }, + + // Note: We intentionally let this loosely (not required) for DB testing purposes + // (0-N relationship). + // Under a normal working environment, this should be mandatory (1-N relationship). + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } +}); + +const Order = mongoose.model("Order", OrderSchema); + +module.exports = { Order }; \ No newline at end of file diff --git a/models/user.js b/models/user.js new file mode 100644 index 0000000..c55a950 --- /dev/null +++ b/models/user.js @@ -0,0 +1,41 @@ +// @/user.js +const mongoose = require("mongoose"); + +const UserSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: [true, "email already exists in database!"], + lowercase: true, + trim: true, + required: [true, "email not provided"], + validate: { + validator: function (v) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); + }, + message: '{VALUE} is not a valid email!' + } + }, + password: { + type: String, + required: true, + }, + address: { + type: String, + required: true, + }, + role: { + type: String, + enum : ['User','Admin'], + default: 'User', + required: true, + }, +}); + +const User = mongoose.model("User", UserSchema); + +module.exports = { User }; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..47739a7 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "st-sample-project", + "version": "0.1.0", + "description": "Software Testing 2022-23 Sample Test Project", + "main": "server.js", + "scripts": { + "test-unit": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/app/app.unit.test.js", + "test-performance": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/app/app.performance.test.js", + "test-db": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/db/db.test.js", + "test-api-admin": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/api/api.users-admin.test.js", + "test-api-simple": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/api/api.users-simple.test.js", + "test-api-generic": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config -- __tests__/api/api.users-generic.test.js", + "test": "DOTENV_CONFIG_PATH=.env jest --setupFiles dotenv/config", + "performance": "artillery run __tests__/performance/performance-test.yml --output performance.json && node db-cleanup.js", + "report": "artillery report --output performance.html performance.json" + }, + "keywords": [ + "software", + "testing", + "sample", + "project" + ], + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^0.27.2", + "bcrypt": "^5.0.1", + "body-parser": "^1.20.0", + "cli-table": "^0.3.11", + "cors": "^2.8.5", + "dotenv": "^16.0.2", + "express": "^4.18.1", + "express-flash": "^0.0.2", + "express-jwt": "^7.7.5", + "express-session": "^1.17.3", + "express-validator": "^6.14.2", + "jest": "^29.0.2", + "jest-measure": "^0.0.14", + "jwks-rsa": "^2.1.4", + "mongoose": "^6.5.4" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..3548452 --- /dev/null +++ b/server.js @@ -0,0 +1,36 @@ +const mongoose = require("mongoose"); +const express = require("express"); + +require('dotenv').config(); + +const app = express(); +app.use(express.json()); + +require('./endpoints/users')(app); +require('./endpoints/orders')(app); + +app.get("/", async (req, res) => { + try{ + return res.status(200).json({"message": "OK"}); + } catch(err) { + console.log(err); + return res.status(400).json({ + "message": "Bad Request." + }); + } +}); + +const start = async () => { + try { + await mongoose.connect( + process.env.DB_ENDPOINT + ); + app.listen(process.env.PORT, () => console.log(`Server started on port ${process.env.PORT}.`)); + } catch (error) { + await mongoose.connection.close(); + console.error(error); + process.exit(1); + } +}; + +start(); \ No newline at end of file