From c63a40cb21f40ec3e843a0d91171be1dc9956432 Mon Sep 17 00:00:00 2001 From: Nestor Cortina Date: Thu, 29 Oct 2020 19:17:05 -0500 Subject: [PATCH] first commit --- .editorconfig | 8 ++ .env.example | 23 ++++ .gitignore | 7 + .gitlab-ci.yml | 52 +++++++ jest.config.js | 7 + package.json | 66 +++++++++ src/app.ts | 57 ++++++++ src/bin/www.ts | 12 ++ src/config.ts | 20 +++ src/core/ApiError.ts | 116 ++++++++++++++++ src/core/ApiResponse.ts | 124 +++++++++++++++++ src/database/mongo.ts | 15 ++ src/database/pgPool.ts | 26 ++++ src/handlers/email.ts | 28 ++++ src/helpers/template.ts | 14 ++ .../templates/emails/confirmAccount.ts | 33 +++++ src/helpers/validator.ts | 30 ++++ src/interfaces/Iuser.ts | 6 + src/middleware/auth.ts | 24 ++++ src/routes/v1/access/schema.ts | 16 +++ src/routes/v1/access/signin.ts | 34 +++++ src/routes/v1/access/signup.ts | 61 +++++++++ src/routes/v1/index.ts | 13 ++ src/routes/v1/tests/test.ts | 9 ++ src/services/access/UserService.ts | 19 +++ src/services/tests/TestService.ts | 8 ++ tests/routes/v1/home/unit.test.ts | 17 +++ tests/routes/v1/signin/mock.ts | 7 + tests/routes/v1/signin/unit.test.ts | 93 +++++++++++++ tests/routes/v1/signup/mock.ts | 5 + tests/routes/v1/signup/unit.test.ts | 128 ++++++++++++++++++ tests/setup.ts | 5 + tsconfig.json | 17 +++ webpack/webpack.config.dev.js | 0 webpack/webpack.config.js | 3 + webpack/webpack.config.prod.js | 36 +++++ 36 files changed, 1139 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/bin/www.ts create mode 100644 src/config.ts create mode 100644 src/core/ApiError.ts create mode 100644 src/core/ApiResponse.ts create mode 100644 src/database/mongo.ts create mode 100644 src/database/pgPool.ts create mode 100644 src/handlers/email.ts create mode 100644 src/helpers/template.ts create mode 100644 src/helpers/templates/emails/confirmAccount.ts create mode 100644 src/helpers/validator.ts create mode 100644 src/interfaces/Iuser.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/routes/v1/access/schema.ts create mode 100644 src/routes/v1/access/signin.ts create mode 100644 src/routes/v1/access/signup.ts create mode 100644 src/routes/v1/index.ts create mode 100644 src/routes/v1/tests/test.ts create mode 100644 src/services/access/UserService.ts create mode 100644 src/services/tests/TestService.ts create mode 100644 tests/routes/v1/home/unit.test.ts create mode 100644 tests/routes/v1/signin/mock.ts create mode 100644 tests/routes/v1/signin/unit.test.ts create mode 100644 tests/routes/v1/signup/mock.ts create mode 100644 tests/routes/v1/signup/unit.test.ts create mode 100644 tests/setup.ts create mode 100644 tsconfig.json create mode 100644 webpack/webpack.config.dev.js create mode 100644 webpack/webpack.config.js create mode 100644 webpack/webpack.config.prod.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e62526b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true +[*] +end_of_line = crlf +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4683366 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +PORT=3000 +NAME_API=API + +PGUSER=user +PGHOST=localhost +PGPASSWORD=pass +PGDATABASE=name +PGPORT=5432 + +EMAILUSER=user +EMAILPASSWORD=pass +EMAILHOST=smtp.mailtrap.io +EMAILPORT=2525 + +URL_CLIENT=https://client.io + +DATABASE_MONGO=mongodb+srv://user:pass@cluster.mongodb.net/database + +SSL=false + +LOG_DIR=./logs + +SECRETKEY=sdfsd&%efgewr32 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f559564 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +package-lock.json +.env +build +production +logs/ +coverage/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..0ef271b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,52 @@ +image: node:12.19.0 + +stages: + - install + - test + - build + - deploy + +install: + stage: install + script: + - npm install + artifacts: + expire_in: 1h + paths: + - node_modules/ + cache: + paths: + - node_modules/ + +tests: + stage: test + dependencies: + - install + script: + - npm run test + +build: + stage: build + dependencies: + - install + script: + - npm run build + artifacts: + expire_in: 1h + paths: + - production/ + only: + - master + +Deploy: + image: ruby:latest + stage: deploy + dependencies: + - build + only: + - master + script: + - apt-get update -qy + - apt-get install -y ruby-dev + - gem install dpl + - dpl --provider=heroku --app=$HEROKU_APP_NAME --api-key=$HEROKU_API_KEY diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..d0edd95 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + setupFiles: ['/tests/setup.ts'], + collectCoverageFrom: ['/src/**/*.ts', '!**/node_modules/**'] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..78730d6 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "backend-architecture-nodejs", + "version": "1.0.0", + "author": "Nestor Cortina", + "description": "", + "main": "server.js", + "engines": { + "node": "12.19.0", + "npm": "6.14.8", + "typescript": "4.0.3" + }, + "scripts": { + "start": "node ./build/server.js", + "dev": "ts-node-dev src/bin/www.ts", + "test": "jest --forceExit --detectOpenHandles --coverage --verbose", + "prebuild": "rm -rf ./prebuild && tsc", + "deletedev": "rm -rf ./prebuild", + "build": "npm run prebuild && npm run overbuild", + "overbuild": "rm -rf build && webpack --config webpack/webpack.config.js -p --env=prod && npm run deletedev" + }, + "repository": { + "type": "git", + "url": "" + }, + "license": "ISC", + "dependencies": { + "bcrypt": "^5.0.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.17.1", + "helmet": "^4.1.1", + "joi": "^17.2.1", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.20", + "mongoose": "^5.10.5", + "morgan": "^1.10.0", + "nodemailer": "^6.4.14", + "pg": "^8.3.3" + }, + "devDependencies": { + "@types/compression": "^1.7.0", + "@types/bcrypt": "^3.0.0", + "@types/cors": "^2.8.8", + "@types/express": "^4.17.8", + "@types/http-errors": "^1.8.0", + "@types/jest": "^26.0.15", + "@types/jsonwebtoken": "^8.5.0", + "@types/lodash": "^4.14.162", + "@types/mongoose": "^5.7.36", + "@types/morgan": "^1.9.1", + "@types/node": "^14.14.0", + "@types/nodemailer": "^6.4.0", + "@types/pg": "^7.14.4", + "@types/supertest": "^2.0.10", + "babel-loader": "^8.1.0", + "dotenv": "^8.2.0", + "jest": "^26.6.0", + "supertest": "^5.0.0", + "ts-jest": "^26.4.1", + "ts-node-dev": "^1.0.0", + "typescript": "^4.0.2", + "webpack": "^4.44.1", + "webpack-cli": "^3.3.12", + "webpack-node-externals": "^2.5.2" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..15ff638 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,57 @@ +import express, { Request, Response, NextFunction, Application } from 'express' +import compression from 'compression'; +import helmet from 'helmet' +import cors from 'cors'; +import bodyParser from 'body-parser' +import morgan from 'morgan' + +import routesV1 from './routes/v1' +import { template } from './helpers/template' +import { environment } from './config' +import { pool } from './database/pgPool' +import { ApiError, InternalError, NotFoundError } from './core/ApiError' +import db from './database/mongo'; + +process.on('uncaughtException', (e) => { + console.log(e) +}) + +const app: Application = express() + +app.use(cors()); +app.use(helmet()); +app.use(compression()); + +app.use(bodyParser.json({ limit: '10mb' })) +app.use(bodyParser.urlencoded({ limit: '10mb', extended: true, parameterLimit: 50000 })) + +app.use(morgan('dev')) + +app.use('/v1', routesV1) +app.get('/', (req: Request, res: Response) => { + res.status(200).send(template('welcome to api')) +}) + +app.use((req, res, next) => next(new NotFoundError())) + +app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + if (err instanceof ApiError) { + ApiError.handle(err, res) + } else { + if (environment === 'development') { + console.log(err) + return res.status(500).send(err.message) + } + ApiError.handle(new InternalError(), res) + } +}) + +pool.connect() + .then() + .catch((error: any) => console.log(`ERROR: ${error.message || error}`)) + +db.once('open', function () { + console.log('The connection to MongoDB was successful.'); +}); + +export default app diff --git a/src/bin/www.ts b/src/bin/www.ts new file mode 100644 index 0000000..aa7ca8c --- /dev/null +++ b/src/bin/www.ts @@ -0,0 +1,12 @@ +import http from 'http'; +import app from '../app'; +import { port } from '../config' + +app.set('port', port); + +const server = http.createServer(app); + +server.listen(port, () => { + console.log(`server running on port ${port}`) +}) + .on('error', (e) => console.log(e)) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2dab454 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,20 @@ +if (!process.env.NODE_ENV) require('dotenv').config() + +export const environment = process.env.NODE_ENV +export const port = process.env.PORT + +export const pgUser = process.env.PGUSER +export const pgPassword = process.env.PGPASSWORD +export const pgHost = process.env.PGHOST +export const pgDatabase = process.env.PGDATABASE +export const pgPort = parseInt(process.env.PGPORT) +export const pgSsl = (process.env.SSL === 'false') ? false : true + +export const emailUser = process.env.EMAILUSER +export const emailPassword = process.env.EMAILPASSWORD +export const emailHost = process.env.EMAILHOST +export const emailPort = process.env.EMAILPORT + +export const urlClient = process.env.URL_CLIENT + +export const secretKey = process.env.SECRETKEY diff --git a/src/core/ApiError.ts b/src/core/ApiError.ts new file mode 100644 index 0000000..f67271f --- /dev/null +++ b/src/core/ApiError.ts @@ -0,0 +1,116 @@ +import { Response } from 'express' +import { environment } from '../config' +import { + AuthFailureResponse, + AccessTokenErrorResponse, + InternalErrorResponse, + NotFoundResponse, + BadRequestResponse, + ForbiddenResponse, +} from './ApiResponse' + +enum ErrorType { + BAD_TOKEN = 'BadTokenError', + TOKEN_EXPIRED = 'TokenExpiredError', + UNAUTHORIZED = 'AuthFailureError', + ACCESS_TOKEN = 'AccessTokenError', + INTERNAL = 'InternalError', + NOT_FOUND = 'NotFoundError', + NO_ENTRY = 'NoEntryError', + NO_DATA = 'NoDataError', + BAD_REQUEST = 'BadRequestError', + FORBIDDEN = 'ForbiddenError', +} + +export abstract class ApiError extends Error { + constructor(public type: ErrorType, public message: string = 'error') { + super(type); + } + + public static handle(err: ApiError, res: Response): Response { + switch (err.type) { + case ErrorType.BAD_TOKEN: + case ErrorType.TOKEN_EXPIRED: + case ErrorType.UNAUTHORIZED: + return new AuthFailureResponse(err.message).send(res); + case ErrorType.ACCESS_TOKEN: + return new AccessTokenErrorResponse(err.message).send(res); + case ErrorType.INTERNAL: + return new InternalErrorResponse(err.message).send(res); + case ErrorType.NOT_FOUND: + case ErrorType.NO_ENTRY: + case ErrorType.NO_DATA: + return new NotFoundResponse(err.message).send(res); + case ErrorType.BAD_REQUEST: + return new BadRequestResponse(err.message).send(res); + case ErrorType.FORBIDDEN: + return new ForbiddenResponse(err.message).send(res); + default: { + let message = err.message; + // Do not send failure message in production as it may send sensitive data + if (environment === 'production') message = 'Something wrong happened.'; + return new InternalErrorResponse(message).send(res); + } + } + } +} + +export class AuthFailureError extends ApiError { + constructor(message = 'Invalid Credentials') { + super(ErrorType.UNAUTHORIZED, message); + } +} + +export class InternalError extends ApiError { + constructor(message = 'Internal error') { + super(ErrorType.INTERNAL, message); + } +} + +export class BadRequestError extends ApiError { + constructor(message = 'Bad Request') { + super(ErrorType.BAD_REQUEST, message); + } +} + +export class NotFoundError extends ApiError { + constructor(message = 'Not Found') { + super(ErrorType.NOT_FOUND, message); + } +} + +export class ForbiddenError extends ApiError { + constructor(message = 'Permission denied') { + super(ErrorType.FORBIDDEN, message); + } +} + +export class NoEntryError extends ApiError { + constructor(message = "Entry don't exists") { + super(ErrorType.NO_ENTRY, message); + } +} + +export class BadTokenError extends ApiError { + constructor(message = 'Token is not valid') { + super(ErrorType.BAD_TOKEN, message); + } +} + +export class TokenExpiredError extends ApiError { + constructor(message = 'Token is expired') { + super(ErrorType.TOKEN_EXPIRED, message); + } +} + +export class NoDataError extends ApiError { + constructor(message = 'No data available') { + super(ErrorType.NO_DATA, message); + } +} + +export class AccessTokenError extends ApiError { + constructor(message = 'Invalid access token') { + super(ErrorType.ACCESS_TOKEN, message); + } +} diff --git a/src/core/ApiResponse.ts b/src/core/ApiResponse.ts new file mode 100644 index 0000000..e9649a7 --- /dev/null +++ b/src/core/ApiResponse.ts @@ -0,0 +1,124 @@ +import { Response } from 'express' + +export enum StatusCode { + SUCCESS = '10000', + FAILURE = '10001', + RETRY = '10002', + INVALID_ACCESS_TOKEN = '10003', +} + +enum ResponseStatus { + SUCCESS = 200, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + INTERNAL_ERROR = 500, +} + +abstract class ApiResponse { + constructor( + protected statusCode: StatusCode, + protected status: ResponseStatus, + protected message: string, + ) { } + + protected prepare(res: Response, response: T): Response { + return res.status(this.status).json(ApiResponse.sanitize(response)); + } + + public send(res: Response): Response { + return this.prepare(res, this); + } + + private static sanitize(response: T): T { + const clone: T = {} as T; + Object.assign(clone, response); + // delete {some_field}; + delete clone.status; + for (const i in clone) if (typeof clone[i] === 'undefined') delete clone[i]; + return clone; + } +} + +export class AuthFailureResponse extends ApiResponse { + constructor(message = 'Authentication Failure') { + super(StatusCode.FAILURE, ResponseStatus.UNAUTHORIZED, message); + } +} + +export class NotFoundResponse extends ApiResponse { + private url: string; + + constructor(message = 'Not Found') { + super(StatusCode.FAILURE, ResponseStatus.NOT_FOUND, message); + } + + send(res: Response): Response { + this.url = res.req.originalUrl; + return super.prepare(res, this); + } +} + +export class ForbiddenResponse extends ApiResponse { + constructor(message = 'Forbidden') { + super(StatusCode.FAILURE, ResponseStatus.FORBIDDEN, message); + } +} + +export class BadRequestResponse extends ApiResponse { + constructor(message = 'Bad Parameters') { + super(StatusCode.FAILURE, ResponseStatus.BAD_REQUEST, message); + } +} + +export class InternalErrorResponse extends ApiResponse { + constructor(message = 'Internal Error') { + super(StatusCode.FAILURE, ResponseStatus.INTERNAL_ERROR, message); + } +} + +export class SuccessMsgResponse extends ApiResponse { + constructor(message: string) { + super(StatusCode.SUCCESS, ResponseStatus.SUCCESS, message); + } +} + +export class FailureMsgResponse extends ApiResponse { + constructor(message: string) { + super(StatusCode.FAILURE, ResponseStatus.SUCCESS, message); + } +} + +export class SuccessResponse extends ApiResponse { + constructor(message: string, private data: T) { + super(StatusCode.SUCCESS, ResponseStatus.SUCCESS, message); + } + + send(res: Response): Response { + return super.prepare>(res, this); + } +} + +export class AccessTokenErrorResponse extends ApiResponse { + private instruction = 'refresh_token'; + + constructor(message = 'Access token invalid') { + super(StatusCode.INVALID_ACCESS_TOKEN, ResponseStatus.UNAUTHORIZED, message); + } + + send(res: Response): Response { + res.setHeader('instruction', this.instruction); + return super.prepare(res, this); + } +} + +export class TokenRefreshResponse extends ApiResponse { + constructor(message: string, private accessToken: string, private refreshToken: string) { + super(StatusCode.SUCCESS, ResponseStatus.SUCCESS, message); + } + + send(res: Response): Response { + return super.prepare(res, this); + } +} diff --git a/src/database/mongo.ts b/src/database/mongo.ts new file mode 100644 index 0000000..ba8704f --- /dev/null +++ b/src/database/mongo.ts @@ -0,0 +1,15 @@ +import mongoose from 'mongoose'; + +const db = mongoose.connection; + +mongoose.connect(process.env.DATABASE_MONGO, { + useUnifiedTopology: true, + useNewUrlParser: true, + useFindAndModify: false +}); + +db.on('error', (error: any) => { + console.log(error); +}); + +export default db; diff --git a/src/database/pgPool.ts b/src/database/pgPool.ts new file mode 100644 index 0000000..89271a2 --- /dev/null +++ b/src/database/pgPool.ts @@ -0,0 +1,26 @@ +import { Pool, PoolConfig, } from 'pg' +import { pgDatabase, pgHost, pgPassword, pgPort, pgSsl, pgUser } from '../config' + +const poolConfig: PoolConfig = { + user: pgUser, + password: pgPassword, + host: pgHost, + database: pgDatabase, + port: pgPort, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 9000, + ssl: { + rejectUnauthorized: pgSsl + } +} + +export const pool: Pool = new Pool(poolConfig) + +pool.on('connect', (client: any): void => { + console.info('Connection has been established successfully.') +}) + +pool.on('error', (error: Error) => { + console.log('Unable to connect to the database:', error) +}) diff --git a/src/handlers/email.ts b/src/handlers/email.ts new file mode 100644 index 0000000..5d877d4 --- /dev/null +++ b/src/handlers/email.ts @@ -0,0 +1,28 @@ +import nodemailer, { SendMailOptions } from 'nodemailer' +import { emailHost, emailPassword, emailPort, emailUser } from '../config' + +const transporter = nodemailer.createTransport({ + host: emailHost, + port: emailPort, + auth: { + user: emailUser, + pass: emailPassword + } +} as any) + +export const SendEmail = (Options: SendMailOptions) => { + + const optionsEmail: SendMailOptions = Object.assign(Options, { from: 'workshops ' }) + + transporter.sendMail(optionsEmail, (error, info) => { + if (error) { + console.log('Error occurred') + console.log(error.message) + return process.exit(1) + } + + console.log('Message sent successfully!') + + transporter.close() + }) +} diff --git a/src/helpers/template.ts b/src/helpers/template.ts new file mode 100644 index 0000000..883d6dd --- /dev/null +++ b/src/helpers/template.ts @@ -0,0 +1,14 @@ +export const template = (title: string, body?: string): string => /*html*/` + + + + + + Api + + +

${title}

+ ${body ? `

${body}

` : ''} + + +` diff --git a/src/helpers/templates/emails/confirmAccount.ts b/src/helpers/templates/emails/confirmAccount.ts new file mode 100644 index 0000000..388ca76 --- /dev/null +++ b/src/helpers/templates/emails/confirmAccount.ts @@ -0,0 +1,33 @@ +export const confirmAccountHtml = (url: string) => /*html*/` +

Confirmar tu Cuenta

+

Hola, estas a un paso de comenzar a crear una comunidad de workshops, solo debes confirmar tu cuenta en el siguiente enlace:

+ + Confirmar tu cuenta + +

Sino puedes acceder a este enlace , vísita : ${url}

+ +

Si no solicitaste este e-mail puedes ignorarlo

+` + +export const confirmAccountText = (url: string) => ` + Confirmar tu Cuenta + + + Hola, estas a un paso de comenzar a crear una comunidad, solo debes confirmar tu cuenta en el siguiente enlace: + + Sino puedes acceder a este enlace , vísita : ${url} + + Si no solicitaste este e-mail puedes ignorarlo +` diff --git a/src/helpers/validator.ts b/src/helpers/validator.ts new file mode 100644 index 0000000..20998d8 --- /dev/null +++ b/src/helpers/validator.ts @@ -0,0 +1,30 @@ +import Joi from 'joi' +import { Request, Response, NextFunction } from 'express' +import { BadRequestError } from '../core/ApiError' + +export enum ValidationSource { + BODY = 'body', + HEADER = 'headers', + QUERY = 'query', + PARAM = 'params', +} + +export default (schema: Joi.ObjectSchema, source: ValidationSource = ValidationSource.BODY) => ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { error } = schema.validate(req[source]) + + if (!error) return next() + + const { details } = error + const message = details.map((i) => i.message.replace(/['"]+/g, '')).join(',') + console.log(message) + + next(new BadRequestError(message)) + } catch (error) { + next(error) + } +} diff --git a/src/interfaces/Iuser.ts b/src/interfaces/Iuser.ts new file mode 100644 index 0000000..39d1260 --- /dev/null +++ b/src/interfaces/Iuser.ts @@ -0,0 +1,6 @@ +export interface Iuser { + name: string, + email: string, + password: string, + type?: number +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..1aca6cb --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction, Router } from 'express' +import jwt from 'jsonwebtoken' + +import { secretKey } from '../config' +import { AuthFailureError, InternalError } from '../core/ApiError' + +const router = Router() + +export default router.get('/', async (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.get('Authorization') + + if (!authHeader) return next(new AuthFailureError('Authorization Header Validation')) + + const token = authHeader.split(' ')[1] + let revisarToken + try { + revisarToken = jwt.verify(token, secretKey) + } catch (error) { + return next(new AuthFailureError('Not Authorized')) + } + + if (!revisarToken) return next(new AuthFailureError('Not Authorized')) + next() +}) diff --git a/src/routes/v1/access/schema.ts b/src/routes/v1/access/schema.ts new file mode 100644 index 0000000..f1914d7 --- /dev/null +++ b/src/routes/v1/access/schema.ts @@ -0,0 +1,16 @@ +import Joi from 'joi'; + +export default { + userCredential: Joi.object().keys({ + email: Joi.string().required().email(), + password: Joi.string().required().min(6), + }), + refreshToken: Joi.object().keys({ + refreshToken: Joi.string().required().min(1), + }), + signup: Joi.object().keys({ + name: Joi.string().required().min(3), + email: Joi.string().required().email(), + password: Joi.string().required().min(6), + }) +} diff --git a/src/routes/v1/access/signin.ts b/src/routes/v1/access/signin.ts new file mode 100644 index 0000000..56e9c10 --- /dev/null +++ b/src/routes/v1/access/signin.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction, Router } from 'express' +import jwt from 'jsonwebtoken' +import _ from 'lodash' +import bcrypt from 'bcrypt' +import { SuccessResponse } from '../../../core/ApiResponse' +import { secretKey } from '../../../config' +import { AuthFailureError, BadRequestError } from '../../../core/ApiError' +import UserService from '../../../services/access/UserService' +import validator from '../../../helpers/validator' +import schema from './schema' + +const router = Router() + +export default router.post('/', validator(schema.userCredential), async (req: Request, res: Response, next: NextFunction) => { + const { email, password } = req.body + const { rows } = await UserService.findByEmail(email) + const user = rows[0] + + if (!user) return next(new BadRequestError('User not registered')) + + const match = await bcrypt.compare(password, user.password) + if (!match) return next(new AuthFailureError('Authentication failure')) + + const token = jwt.sign({ + email: user.email, + nombre: user.nombre, + id: user.id + }, secretKey, { expiresIn: '1h' }) + + new SuccessResponse('Sign in Success', { + user: _.pick(user, ['id', 'nombre', 'email']), + tokens: token, + }).send(res) +}) diff --git a/src/routes/v1/access/signup.ts b/src/routes/v1/access/signup.ts new file mode 100644 index 0000000..a12c4e5 --- /dev/null +++ b/src/routes/v1/access/signup.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction, RequestHandler, Router } from 'express' +import _ from 'lodash' +import bcrypt from 'bcrypt' +import { SendMailOptions } from 'nodemailer' + +import schema from './schema' + +import UserService from '../../../services/access/UserService' +import validator from '../../../helpers/validator' +import { BadRequestError, InternalError } from '../../../core/ApiError' +import { SuccessResponse } from '../../../core/ApiResponse' +import { SendEmail } from '../../../handlers/email' +import { Iuser } from '../../../interfaces/Iuser' +import { confirmAccountHtml, confirmAccountText } from '../../../helpers/templates/emails/confirmAccount' +import { urlClient } from '../../../config' + +const router = Router() + +const validateEmailUser: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + const { rowCount } = await UserService.findByEmail(req.body.email) + if (rowCount > 0) { + next(new BadRequestError('User already registered')) + } + next() + } catch (error) { + console.log(error) + next(new InternalError()) + } +} + +const createUser: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + const passwordHash = await bcrypt.hash(req.body.password, 10) + + req.body.type = req.body.type || 0 + req.body.password = passwordHash + const user: Iuser = req.body + + const { rows } = await UserService.create(user) + + const url = `${urlClient}/jkdfsgkjfgbdsfjkagh` + const optionsEmail: SendMailOptions = { + to: user.email, + subject: 'Confirma tu cuenta de workshops', + text: confirmAccountText(url), + html: confirmAccountHtml(url) + } + + SendEmail(optionsEmail) + + new SuccessResponse('Signup Successful', { + user: _.pick(rows[0], ['id']) + }).send(res) + } catch (error) { + console.log(error) + next(new InternalError()) + } +} + +export default router.post('/', validator(schema.signup), validateEmailUser, createUser) diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts new file mode 100644 index 0000000..c3f7be1 --- /dev/null +++ b/src/routes/v1/index.ts @@ -0,0 +1,13 @@ +import express from 'express' +import test from './tests/test' +import signup from './access/signup' +import signin from './access/signin' +import auth from '../../middleware/auth' + +const router = express.Router() + +router.use('/test', test) +router.use('/signup', signup) +router.use('/signin', signin) + +export default router diff --git a/src/routes/v1/tests/test.ts b/src/routes/v1/tests/test.ts new file mode 100644 index 0000000..0406448 --- /dev/null +++ b/src/routes/v1/tests/test.ts @@ -0,0 +1,9 @@ +import { Request, Response, Router } from 'express' +import TestService from '../../../services/tests/TestService' + +const router = Router() + +export default router.post('/', async (req: Request, res: Response) => { + await TestService.deleteUserByEmail(req.body.email) + res.status(200).json({ test: 'ok' }) +}) diff --git a/src/services/access/UserService.ts b/src/services/access/UserService.ts new file mode 100644 index 0000000..97c3a44 --- /dev/null +++ b/src/services/access/UserService.ts @@ -0,0 +1,19 @@ +import { QueryResult } from 'pg' +import { pool } from '../../database/pgPool' +import { Iuser } from '../../interfaces/Iuser' + +export default class UserService { + public static create(user: Iuser): Promise> { + const { name, email, password, type } = user + + return pool.query(`INSERT INTO public.usuarios + (nombre, email, "password", tipo) + VALUES ($1,$2,$3,$4) RETURNING id;`, + [name, email, password, type]) + } + + public static findByEmail(email: string): Promise> { + return pool.query(`SELECT id, nombre, "password", email FROM usuarios WHERE email = $1`, + [email]) + } +} diff --git a/src/services/tests/TestService.ts b/src/services/tests/TestService.ts new file mode 100644 index 0000000..ac2d923 --- /dev/null +++ b/src/services/tests/TestService.ts @@ -0,0 +1,8 @@ +import { QueryResult } from 'pg' +import { pool } from '../../database/pgPool' + +export default class TestService { + public static deleteUserByEmail(email: string): Promise> { + return pool.query(`DELETE FROM public.usuarios WHERE email = $1`, [email]) + } +} diff --git a/tests/routes/v1/home/unit.test.ts b/tests/routes/v1/home/unit.test.ts new file mode 100644 index 0000000..a95c2ab --- /dev/null +++ b/tests/routes/v1/home/unit.test.ts @@ -0,0 +1,17 @@ +import supertest from 'supertest' + +import app from '../../../../src/app' + +describe('welcome to api', () => { + const endpoint = '/' + const request = supertest(app) + + beforeEach(() => { + }) + + it('Home', async (done) => { + const response = await request.get(endpoint) + expect(response.status).toBe(200) + done() + }) +}) diff --git a/tests/routes/v1/signin/mock.ts b/tests/routes/v1/signin/mock.ts new file mode 100644 index 0000000..b3deca4 --- /dev/null +++ b/tests/routes/v1/signin/mock.ts @@ -0,0 +1,7 @@ +import bcrypt from 'bcrypt' + +export const USER_EMAIL = 'random@test.com' +export const USER_PASSWORD = 'abc123' +export const USER_PASSWORD_HASH = '$2b$10$5EepC5H8P9mQoOjirSxPKOVeM7mBfSk6JqdYDuyHD1PWqKypEDpc2' + +export const bcryptCompareSpy = jest.spyOn(bcrypt, 'compare') diff --git a/tests/routes/v1/signin/unit.test.ts b/tests/routes/v1/signin/unit.test.ts new file mode 100644 index 0000000..98cb93a --- /dev/null +++ b/tests/routes/v1/signin/unit.test.ts @@ -0,0 +1,93 @@ +import supertest from 'supertest' + +import app from '../../../../src/app' +import { bcryptCompareSpy, USER_EMAIL, USER_PASSWORD, USER_PASSWORD_HASH } from './mock' + +describe('Login basic route', () => { + const endpoint = '/v1/signin' + const request = supertest(app) + + beforeEach(() => { + bcryptCompareSpy.mockClear() + }) + + it('Should send error when empty body is sent', async (done) => { + const response = await request.post(endpoint) + expect(response.status).toBe(400) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error when email is only sent', async (done) => { + const response = await request.post(endpoint).send({ email: USER_EMAIL }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/password/) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error when password is only sent', async (done) => { + const response = await request.post(endpoint).send({ password: USER_PASSWORD }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/email/) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error when email is not valid format', async (done) => { + const response = await request.post(endpoint).send({ email: '123' }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/valid email/) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error when password is not valid format', async (done) => { + const response = await request.post(endpoint).send({ + email: '123@abc.com', + password: '123' + }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/password length/) + expect(response.body.message).toMatch(/6 char/) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error when user not registered for email', async (done) => { + const response = await request.post(endpoint).send({ + email: '123@abc.com', + password: '123456', + }) + expect(response.status).toBe(400); + expect(response.body.message).toMatch(/not registered/) + expect(bcryptCompareSpy).not.toBeCalled() + done() + }) + + it('Should send error for wrong password', async (done) => { + const response = await request.post(endpoint).send({ + email: USER_EMAIL, + password: '123456', + }) + expect(response.status).toBe(401) + expect(response.body.message).toMatch(/authentication failure/i) + expect(bcryptCompareSpy).toBeCalledTimes(1) + done() + }) + + it('Should send success response for correct credentials', async (done) => { + const response = await request.post(endpoint).send({ + email: USER_EMAIL, + password: USER_PASSWORD, + }) + expect(response.status).toBe(200) + expect(response.body.message).toMatch(/Success/i) + expect(response.body.data).toBeDefined() + + expect(response.body.data.user).toHaveProperty('id') + + expect(bcryptCompareSpy).toBeCalledWith(USER_PASSWORD, USER_PASSWORD_HASH) + done() + }) +}) diff --git a/tests/routes/v1/signup/mock.ts b/tests/routes/v1/signup/mock.ts new file mode 100644 index 0000000..46e2f92 --- /dev/null +++ b/tests/routes/v1/signup/mock.ts @@ -0,0 +1,5 @@ +import bcrypt from 'bcrypt'; + +export const USER_NAME = 'abc' + +export const bcryptHashSpy = jest.spyOn(bcrypt, 'hash') diff --git a/tests/routes/v1/signup/unit.test.ts b/tests/routes/v1/signup/unit.test.ts new file mode 100644 index 0000000..80c6460 --- /dev/null +++ b/tests/routes/v1/signup/unit.test.ts @@ -0,0 +1,128 @@ +import supertest from 'supertest' + +import { bcryptHashSpy, USER_NAME } from './mock' +import { USER_EMAIL, USER_PASSWORD } from '../signin/mock' +import app from '../../../../src/app' + +describe('Signup basic route', () => { + const endpoint = '/v1/signup' + const endpointClear = '/v1/test' + const request = supertest(app) + + const email = 'abc@xyz.com' + + beforeEach(() => { + bcryptHashSpy.mockClear() + }) + + it('Should send error when empty body is sent', async (done) => { + const response = await request.post(endpoint) + expect(response.status).toBe(400) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when email is not sent', async (done) => { + const response = await request.post(endpoint).send({ + name: USER_NAME, + password: USER_PASSWORD + }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/email/) + expect(response.body.message).toMatch(/required/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when password is not sent', async (done) => { + const response = await request.post(endpoint).send({ + email, + name: USER_NAME + }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/password/) + expect(response.body.message).toMatch(/required/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when name is not sent', async (done) => { + const response = await request.post(endpoint).send({ + email: email, + password: USER_PASSWORD + }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/name/) + expect(response.body.message).toMatch(/required/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when email is not valid format', async (done) => { + const response = await request.post(endpoint).send({ + email: 'abc', + name: USER_NAME, + password: USER_PASSWORD + }) + expect(response.status).toBe(400); + expect(response.body.message).toMatch(/valid email/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when password is not valid format', async (done) => { + const response = await request.post(endpoint).send({ + email: email, + name: USER_NAME, + password: '123' + }) + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/password length/) + expect(response.body.message).toMatch(/6 char/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send error when user is registered for email', async (done) => { + const response = await request.post(endpoint).send({ + email: USER_EMAIL, + name: USER_NAME, + password: USER_PASSWORD + }) + + expect(response.status).toBe(400) + expect(response.body.message).toMatch(/already registered/) + expect(bcryptHashSpy).not.toBeCalled() + done() + }) + + it('Should send success response for correct data', async (done) => { + const response = await request.post(endpoint).send({ + email: email, + name: USER_NAME, + password: USER_PASSWORD + }) + + expect(response.status).toBe(200) + expect(response.body.message).toMatch(/Success/i) + expect(response.body.data).toBeDefined() + + expect(response.body.data.user).toHaveProperty('id') + + expect(bcryptHashSpy).toBeCalledTimes(1) + + expect(bcryptHashSpy).toBeCalledWith(USER_PASSWORD, 10) + + done() + }) + + it('Should delete the test data', async (done) => { + const response = await request.post(endpointClear).send({ + email: email + }) + + expect(response.status).toBe(200) + expect(response.body.test).toMatch(/ok/i) + done() + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..64c3675 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,5 @@ +import dotenv from 'dotenv' + +if (!process.env.GITLAB) { + dotenv.config() +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f7ed55e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "lib": ["ES2019"], + "target": "ES2019", + "esModuleInterop": true, + "noImplicitAny": true, + "moduleResolution": "node", + "sourceMap": false, + "outDir": "prebuild", + "baseUrl": ".", + "paths": { + "*": ["node_modules/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/webpack/webpack.config.dev.js b/webpack/webpack.config.dev.js new file mode 100644 index 0000000..e69de29 diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js new file mode 100644 index 0000000..fca6f57 --- /dev/null +++ b/webpack/webpack.config.js @@ -0,0 +1,3 @@ +module.exports = (env) => { + return require(`./webpack.config.${env}.js`) +} diff --git a/webpack/webpack.config.prod.js b/webpack/webpack.config.prod.js new file mode 100644 index 0000000..b8b6a38 --- /dev/null +++ b/webpack/webpack.config.prod.js @@ -0,0 +1,36 @@ +const path = require('path') +const nodeExternals = require('webpack-node-externals') + +module.exports = { + entry: { + main: [ + path.join(__dirname, '../prebuild/bin/www.js') + ] + }, + output: { + path: path.join(__dirname, '../build'), + filename: 'server.js' + }, + target: 'node', + node: { + __dirname: false, + __filename: false + }, + externals: [nodeExternals()], + target: 'node', + node: { + __dirname: false, + __filename: false + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader' + } + } + ] + } +}