diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0611608 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{js,json,jsx,css}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.env b/.env new file mode 100644 index 0000000..411f3a3 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +NODE_ENV=development +DATABASE_URL=mysql://nodebr:nodebr@localhost/nodebr +PORT=8080 +COOKIE_SECRET=here_be_dragons diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..049ab83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,271 @@ + +# Created by https://www.gitignore.io/api/u,node,sublimetext,osx,linux,windows,notepadpp,vim,emacs,webstorm,netbeans + +### Windows ### +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +#!! ERROR: u is undefined. Use list command to see defined gitignore types !!# + +### SublimeText ### +# cache files for sublime text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# workspace files are user-specific +*.sublime-workspace + +# project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using SublimeText +# *.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + + +### NotepadPP ### +# Notepad++ backups # +*.bak + + +### Vim ### +# swap +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +# session +Session.vim +# temporary +.netrwhist +# auto-generated tag files +tags + + +### Emacs ### +# -*- mode: gitignore; -*- +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### WebStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + + +### NetBeans ### +nbproject/private/ +build/ +nbbuild/ +nbdist/ +.nb-gradle/ diff --git a/README.md b/README.md index 020c3f3..fa7f8bc 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,17 @@ -# Comunidade NodeBR +# Backend NodeBR -Este repositório engloba o frontend e o backend do site oficial da comunidade -https://nodebr.org. - -Todos estão convidados a participar, basta escolher em qual parte do projeto -você deseja contribuir. - -### Backend +![](https://circleci.com/gh/nodebr/nodebr/tree/backend.svg?style=shield&circle-token=b074cef2067115bcb93a37b1645cdedf7db96938) +[![codecov](https://codecov.io/gh/nodebr/nodebr/branch/backend/graph/badge.svg)](https://codecov.io/gh/nodebr/nodebr/branch/backend) -O nosso backend é uma aplicação simples rodando Node.js e conectada a um banco -de dados. Pessoas sem muita experiência não terão problemas em entender o código -e pode *botar a mão na massa* em pouco tempo. - -Envie PR para a branch: [backend](https://github.com/nodebr/nodebr/tree/backend) +Este repositório refere-se ao backend da comunidade NodeBR hospedada no website +https://nodebr.org. -### Frontend +### Documentação -Nosso frontend é um pouco mais complicado pois os interessados terão que entender -um pouco de React, Redux e WebPack. Caso você seja familiar com estas tecnologias, -mesmo que ha pouco tempo, está convidado a enviar sua PR. +Para que você possa contribuir e desenvolver de forma ágil e sem dúvidas +conselhamos que você leia a documentação do projeto disponível no link abaixo. -Envie PR para a branch: [frontend](https://github.com/nodebr/nodebr/tree/frontend) +https://github.com/nodebr/nodebr/wiki/Backend ### Canais de ajuda diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..5316e4a --- /dev/null +++ b/circle.yml @@ -0,0 +1,16 @@ +machine: + services: + - mysql + environment: + DATABASE_URL: mysql://ubuntu@localhost/circle_test + NODE_ENV: test + COOKIE_SECRET: here_be_dragons + node: + version: v6.9 +test: + override: + - npm run test-cov + post: + - bash <(curl -s https://codecov.io/bash) + pre: + - npm run knex -- migrate:latest diff --git a/index.js b/index.js new file mode 100644 index 0000000..91f6570 --- /dev/null +++ b/index.js @@ -0,0 +1,10 @@ +'use strict' + +const server = require('./lib/server') + +// Inicia o servidor na porta especificada pelo environment +server.listen(process.env.PORT, err => { + if (err) throw err + + console.log(`Servidor iniciado na porta ${process.env.PORT}`) +}) diff --git a/lib/async-handler.js b/lib/async-handler.js new file mode 100644 index 0000000..cbd8397 --- /dev/null +++ b/lib/async-handler.js @@ -0,0 +1,12 @@ +const co = require('co') + +/** + * Um helper para registrar handlers assíncronos + * @todo Implementar funcionalidade para Promises + * @param {Function} handler Um generator que irá receber a requisição + * @return {Function} Uma função que pode ser usada como handler no Express + */ +module.exports = handler => (req, res, next) => { + co(handler.bind(null, req, res, next)) + .catch(err => next(err)) +} diff --git a/lib/db/base.js b/lib/db/base.js new file mode 100644 index 0000000..00282f8 --- /dev/null +++ b/lib/db/base.js @@ -0,0 +1,11 @@ +'use strict' + +/** + * O modelo base para todos os outros models do nosso sistema + * @param {Object} bookshelf Uma instância do Bookshelf + * @return {Object} A mesma instância do Bookshelf com as configurações + * padrões para todos os models + */ +module.exports = bookshelf => { + bookshelf.Model = bookshelf.Model.extend({ uuid: true }) +} diff --git a/lib/db/drop-dabase.js b/lib/db/drop-dabase.js new file mode 100644 index 0000000..d2c114f --- /dev/null +++ b/lib/db/drop-dabase.js @@ -0,0 +1,28 @@ +/* $lab:coverage:off$ */ +const co = require('co') + +/** + * Cria uma função no Bookshelf para zerar o banco de dados + * @param {Object} bookshelf Uma instância do Bookshelf + */ +module.exports = bookshelf => { + const { knex } = bookshelf + + bookshelf.dropDatabase = co.wrap(function * () { + // Desabilita este comando em qualquer outro ambiante que não seja desenvolvimento + if (process.env.NODE_ENV === 'production') { + return Promise.reject(new Error('Você não pode executar o dropDatabase neste ambiente')) + } + + // Pega todas as tabelas no nosso banco de dados + const result = yield knex.raw('SHOW TABLES;') + const tables = result[0].map(table => table[Object.keys(table)[0]]) + + yield knex.transaction(co.wrap(function * (trx) { + yield knex.raw('SET FOREIGN_KEY_CHECKS = 0;') + yield Promise.all(tables.map(table => knex.raw(`DROP TABLE ${table};`))) + yield knex.raw('SET FOREIGN_KEY_CHECKS = 1;') + })) + }) +} +/* $lab:coverage:on$ */ diff --git a/lib/db/index.js b/lib/db/index.js new file mode 100644 index 0000000..31493b4 --- /dev/null +++ b/lib/db/index.js @@ -0,0 +1,26 @@ +'use strict' + +const knex = require('knex')(require('./knexfile')[process.env.NODE_ENV]) +const bookshelf = require('bookshelf')(knex) +const glob = require('glob') +const path = require('path') + +// Registra todos os plugins necessários para o bookshelf +bookshelf.plugin('registry') +bookshelf.plugin('visibility') +bookshelf.plugin('virtuals') +bookshelf.plugin(require('bookshelf-uuid')) +bookshelf.plugin(require('bookshelf-modelbase').pluggable) +bookshelf.plugin(require('bookshelf-bcrypt')) +bookshelf.plugin(require('./base')) +bookshelf.plugin(require('./truncate')) +bookshelf.plugin(require('./drop-dabase')) + +const modelsPath = path.resolve(__dirname, '../../resources') + +// Faz o eager loading de todos os módulos +glob.sync(`${modelsPath}/**/model.js`) +.map(require) +.forEach(model => model(bookshelf)) + +module.exports = bookshelf diff --git a/lib/db/knexfile.js b/lib/db/knexfile.js new file mode 100644 index 0000000..9728b6a --- /dev/null +++ b/lib/db/knexfile.js @@ -0,0 +1,5 @@ +// No momento só precisamos de um env configurado para o banco de dados +exports[process.env.NODE_ENV] = { + client: 'mysql2', + connection: process.env.DATABASE_URL +} diff --git a/lib/db/migrations/20161114212251_hello_world.js b/lib/db/migrations/20161114212251_hello_world.js new file mode 100644 index 0000000..bead55c --- /dev/null +++ b/lib/db/migrations/20161114212251_hello_world.js @@ -0,0 +1,24 @@ +'use strict' + +exports.up = knex => { + // Apenas roda esta migration se estivermos em um ambiente de desenvolvimento + if (process.env.NODE_ENV !== 'production') { + return knex.schema.createTableIfNotExists('hello_world', table => { + table.uuid('id').primary() + table.string('nome') + table.string('sobrenome') + table.string('email') + table.timestamps() + }) + } else { + return Promise.resolve() + } +} + +exports.down = knex => { + if (process.env.NODE_ENV !== 'production') { + return knex.schema.dropTableIfExists('hello_world') + } else { + return Promise.resolve() + } +} diff --git a/lib/db/migrations/20161114224639_users.js b/lib/db/migrations/20161114224639_users.js new file mode 100644 index 0000000..c014455 --- /dev/null +++ b/lib/db/migrations/20161114224639_users.js @@ -0,0 +1,8 @@ +exports.up = knex => knex.schema.createTableIfNotExists('users', table => { + table.uuid('id').primary() + table.string('username', 20).unique().notNullable() + table.string('password', 60).notNullable() + table.timestamps() +}) + +exports.down = knex => knex.schema.dropTableIfExists('users') diff --git a/lib/db/truncate.js b/lib/db/truncate.js new file mode 100644 index 0000000..9f7c783 --- /dev/null +++ b/lib/db/truncate.js @@ -0,0 +1,27 @@ +'use strict' + +/** + * Cria uma função no Bookshelf para zerar o banco de dados + * @param {Object} bookshelf Uma instância do Bookshelf + */ +module.exports = bookshelf => { + const { knex } = bookshelf + + bookshelf.truncate = () => { + // Desabilita este comando em qualquer outro ambiante que não seja desenvolvimento + if (process.env.NODE_ENV === 'production') { + return Promise.reject(new Error('Você não pode executar o truncate neste ambiente')) + } + + // Constrói + const commands = Object.keys(bookshelf._models) + .map(key => bookshelf._models[key].prototype.tableName) + .map(tableName => `TRUNCATE TABLE ${tableName};`) + + return knex.transaction(trx => { + return trx.raw('SET FOREIGN_KEY_CHECKS = 0;') + .then(() => Promise.all(commands.map(command => trx.raw(command)))) + .then(() => trx.raw('SET FOREIGN_KEY_CHECKS = 1;')) + }) + } +} diff --git a/lib/error-handler.js b/lib/error-handler.js new file mode 100644 index 0000000..c75c28b --- /dev/null +++ b/lib/error-handler.js @@ -0,0 +1,31 @@ +'use strict' + +/** + * Um middleware para captura de erros + * @param {Error} err O erro gerado em um middleware anterior + * @param {Object} req Uma interface de requisição do Express + * @param {Object} res Uma interface de resposta do Express + * @param {Function} next Um callback caso necessário + */ +module.exports = (err, req, res, next) => { + // Verifica se já não houve a resposta + if (res.headersSent) { + return next(err) + } + + // Verifica os tipos de erros que podemos ter + if (err.isJoi) { + return res.status(422).send({ + error: 'ValidationError', + message: err.details[0].message, + path: err.details[0].path, + type: err.details[0].type + }) + } else if (err.isBoom) { + return res.status(err.output.statusCode).send(err.output.payload) + } else { + // Caso nenhum tipo de erro seja encontrado então é um erro no servidor + console.error(err.stack) + return res.status(500).send({ error: 'InternalServerError' }) + } +} diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..9fda91b --- /dev/null +++ b/lib/server.js @@ -0,0 +1,33 @@ +'use strict' + +const express = require('express') +const glob = require('glob') +const path = require('path') +const helmet = require('helmet') +const morgan = require('morgan') + +// Middlewares customizados +const errorHandler = require('./error-handler') + +// Injeta cada rota na nossa app +const app = express() + +// Adiciona todos os middlewares necessários +app.use(helmet()) + +// Print request logs no console +app.use(morgan('combined')) + +// Faz o require de todas as rotas dentro dos resources e injeta automaticamente +// na nossa app, assim não é necessário fazer o require um por um +const resourcePath = path.resolve(__dirname, '../resources') + +glob.sync(`${resourcePath}/**/routes.js`) +.map(require) +.forEach(route => route && app.use(route)) + +// O gerenciador de erros deve vir por último +app.use(errorHandler) + +// Bootstrapping finalizado, agora é seguro exportar a instância da nossa app +module.exports = app diff --git a/lib/session.js b/lib/session.js new file mode 100644 index 0000000..2dd91e7 --- /dev/null +++ b/lib/session.js @@ -0,0 +1,40 @@ +const cookieSession = require('cookie-session') + +/** + * Um middleware para checagem de sessão + * @param {Object} [config] Configurações da sessão + * @param {Boolean} [config.restrict=true] Deixar que apenas usuários logados + * acessem o handler + * @return {Function} Uma função que serve de middleware + */ +module.exports = (config = { + restrict: true +}) => { + const session = cookieSession({ + name: 'session', + keys: [process.env.COOKIE_SECRET], + maxAge: 7 * 24 * 60 * 60 * 1000, // Uma semana + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + signed: true, + overwrite: true + }) + + return (req, res, next) => { + // Checa a sessão utilizando o middleware cookie-session + session(req, res, err => { + if (err) { + return next(err) + } + + // Caso o acesso seja restrito à usuários logados precisamos verificar + // se a sessão foi lida com sucesso + if ((!req.session || !req.session.user_id) && config.restrict) { + return res.status(401).send({ error: 'Unauthorized' }) + } + + // Tudo sob controle, podemos executar o handler + next() + }) + } +} diff --git a/lib/validator.js b/lib/validator.js new file mode 100644 index 0000000..50eca3c --- /dev/null +++ b/lib/validator.js @@ -0,0 +1,41 @@ +'use strict' + +const Joi = require('joi') + +/** + * Um middleware para validar a request antes de chegar no handler + * @param {Object} schemas Um objeto onde cada chave representa a chave da + * requisição que deve ser validada + * @return {Function} Uma função que pode ser utilizada como middleware + */ +module.exports = schemas => (req, res, next) => { + // Uma sinaleira simples guardando o número de validações assíncronas que + // resta fazer antes de chamar o callback + let validations = Object.keys(schemas).length + let nextWasCalled = false + + Object.keys(schemas).forEach(schema => { + Joi.validate(req[schema], schemas[schema], (err, value) => { + // Se houver um erro chame o callback com este erro, mas tenha certeza + // que irá chamar apenas uma vez + if (err && !nextWasCalled) { + nextWasCalled = true + return next(err) + } else { + // Faça o monkeypatch da requisição para evitar que campos não + // validados continuem pela pileline do Express + req[schema] = value + + // Diminui um elemento na sinaleira + --validations + + // Se a sinaleira for 0 significa que todas as validações já voltaram + // agora basta verificar se o next ainda não foi chamado (em caso de + // erro) e passar para o próximo middleware + if (validations === 0 && !nextWasCalled) { + next() + } + } + }) + }) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..51b6ea8 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "nodebr", + "version": "1.0.0", + "description": "Este repositório refere-se ao backend da comunidade NodeBR hospedada no website https://nodebr.org.", + "main": "index.js", + "scripts": { + "test": "standard && lab --verbose --colors --assert code --ignore __core-js_shared__", + "test-cov": "npm test -- -r console -o stdout -r html -o coverage/coverage.html -r lcov -o coverage/lcov.info", + "knex": "knex --knexfile ./lib/db/knexfile.js", + "start": "node index.js", + "migrate": "npm run knex migrate:latest", + "reset": "node ./scripts/drop-database; npm run migrate" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodebr/nodebr.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/nodebr/nodebr/issues" + }, + "homepage": "https://github.com/nodebr/nodebr#readme", + "dependencies": { + "body-parser": "^1.15.2", + "bookshelf": "^0.10.2", + "bookshelf-bcrypt": "^2.1.0", + "bookshelf-modelbase": "^2.10.1", + "bookshelf-uuid": "^1.0.0", + "boom": "^4.2.0", + "co": "^4.6.0", + "cookie-session": "^2.0.0-alpha.2", + "express": "^4.14.0", + "glob": "^7.1.1", + "helmet": "^3.1.0", + "joi": "^9.2.0", + "knex": "^0.12.6", + "morgan": "^1.7.0", + "mysql2": "^1.1.1" + }, + "devDependencies": { + "code": "^4.0.0", + "eslint-config-standard": "^6.2.1", + "eslint-plugin-standard": "^2.0.1", + "lab": "^11.2.1", + "standard": "^8.5.0", + "supertest": "^2.0.1" + } +} diff --git a/resources/hello-world/handlers.js b/resources/hello-world/handlers.js new file mode 100644 index 0000000..134cc99 --- /dev/null +++ b/resources/hello-world/handlers.js @@ -0,0 +1,25 @@ +'use strict' + +const db = require('../../lib/db') +const HelloWorld = db.model('HelloWorld') + +exports.findAll = (req, res) => { + HelloWorld.findAll() + .then(collection => res.send(collection)) +} + +exports.findOne = (req, res) => { + HelloWorld.findById(req.params.id) + .then(helloWorld => res.send(helloWorld)) +} + +exports.remove = (req, res) => { + HelloWorld.forge({ id: req.params.id }) + .destroy() + .then(() => res.send({ success: true })) +} + +exports.create = (req, res) => { + HelloWorld.create(req.body) + .then(helloWorld => res.send(helloWorld)) +} diff --git a/resources/hello-world/model.js b/resources/hello-world/model.js new file mode 100644 index 0000000..90b91e5 --- /dev/null +++ b/resources/hello-world/model.js @@ -0,0 +1,9 @@ +'use strict' + +/** + * Cria o modelo HelloWorld + * @param {Function} bookshelf Uma instância do Bookshelf + */ +module.exports = bookshelf => bookshelf.model('HelloWorld', { + tableName: 'hello_world' +}) diff --git a/resources/hello-world/routes.js b/resources/hello-world/routes.js new file mode 100644 index 0000000..06a7fe7 --- /dev/null +++ b/resources/hello-world/routes.js @@ -0,0 +1,27 @@ +'use strict' + +const express = require('express') +const bodyParser = require('body-parser') + +const validator = require('../../lib/validator') +const schemas = require('./schemas') +const handlers = require('./handlers') +const router = express.Router() + +router.get('/hello-world', handlers.findAll) +router.get('/hello-world/:id', handlers.findOne) + +router.post('/hello-world', + bodyParser.json(), + validator({ body: schemas.create }), + handlers.create) + +router.delete('/hello-world/:id', handlers.remove) + +// Somente exportamos esta rota caso o ambiente for de desenvolvimento +// pois não queremos que a mesma esteja disponível em produção +/* $lab:coverage:off$ */ +if (process.env.NODE_ENV !== 'production') { + module.exports = router +} +/* $lab:coverage:on$ */ diff --git a/resources/hello-world/schemas.js b/resources/hello-world/schemas.js new file mode 100644 index 0000000..5f5f6fe --- /dev/null +++ b/resources/hello-world/schemas.js @@ -0,0 +1,17 @@ +'use strict' + +const Joi = require('joi') + +exports.model = Joi.object({ + id: Joi.string().uuid(), + nome: Joi.string(), + sobrenome: Joi.string(), + email: Joi.string().email() +}) + +exports.create = exports.model.concat(Joi.object({ + id: Joi.forbidden(), + nome: Joi.required(), + sobrenome: Joi.required(), + email: Joi.required() +})) diff --git a/resources/sessions/handlers.js b/resources/sessions/handlers.js new file mode 100644 index 0000000..d1e6d09 --- /dev/null +++ b/resources/sessions/handlers.js @@ -0,0 +1,36 @@ +const Boom = require('boom') + +const db = require('../../lib/db') +const User = db.model('User') + +exports.create = function * (req, res) { + // Verifica se o usuário já está logado + if (req.session && req.session.user_id) { + throw Boom.badData('Você já está logado') + } + + // Pega no banco de dados o usuário que precisamos + const user = yield User.findOne({ username: req.body.username }) + .catch(User.NotFoundError, () => { + throw Boom.badData('Não foi possível encontrar o usuário') + }) + + // Verifica se a senha está ok + if (!(yield user.compare(req.body.password))) { + throw Boom.badData('Sua senha está incorreta') + } + + // Seta a sessão e retorna sucesso + req.session.user_id = user.id + res.send({ success: true }) +} + +exports.findOne = (req, res) => { + User.findById(req.session.user_id) + .then((user) => res.send(user)) +} + +exports.remove = (req, res) => { + req.session = null + res.send({ success: true }) +} diff --git a/resources/sessions/routes.js b/resources/sessions/routes.js new file mode 100644 index 0000000..b906d35 --- /dev/null +++ b/resources/sessions/routes.js @@ -0,0 +1,24 @@ +const router = require('express').Router() + +const handlers = require('./handlers') +const schemas = require('./schemas') +const validator = require('../../lib/validator') +const session = require('../../lib/session') +const asyncHandler = require('../../lib/async-handler') +const bodyParser = require('body-parser') + +router.post('/sessions', + session({ restrict: false }), + bodyParser.json(), + validator({ body: schemas.create }), + asyncHandler(handlers.create)) + +router.get('/sessions', + session(), + handlers.findOne) + +router.delete('/sessions', + session(), + handlers.remove) + +module.exports = router diff --git a/resources/sessions/schemas.js b/resources/sessions/schemas.js new file mode 100644 index 0000000..ac011be --- /dev/null +++ b/resources/sessions/schemas.js @@ -0,0 +1,6 @@ +const Joi = require('joi') + +exports.create = Joi.object({ + username: Joi.string().token().min(3).max(20).required(), + password: Joi.string().min(8).max(120).required() +}) diff --git a/resources/users/handlers.js b/resources/users/handlers.js new file mode 100644 index 0000000..6bf92bb --- /dev/null +++ b/resources/users/handlers.js @@ -0,0 +1,16 @@ +const db = require('../../lib/db') +const User = db.model('User') + +exports.create = (req, res, next) => { + User.create(req.body) + .then(user => res.send({ success: true })) + .catch(err => { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(400).send({ + error: 'Este usuário já existe' + }) + } + + return next(err) + }) +} diff --git a/resources/users/model.js b/resources/users/model.js new file mode 100644 index 0000000..48c7b1e --- /dev/null +++ b/resources/users/model.js @@ -0,0 +1,5 @@ +module.exports = bookshelf => bookshelf.model('User', { + tableName: 'users', + hidden: [ 'password' ], + bcrypt: { field: 'password' } +}) diff --git a/resources/users/routes.js b/resources/users/routes.js new file mode 100644 index 0000000..4ce1cd7 --- /dev/null +++ b/resources/users/routes.js @@ -0,0 +1,15 @@ +const express = require('express') +const bodyParser = require('body-parser') + +const validator = require('../../lib/validator') +const schemas = require('./schemas') +const handlers = require('./handlers') + +const router = express.Router() + +router.post('/usuarios', + bodyParser.json(), + validator({ body: schemas.create }), + handlers.create) + +module.exports = router diff --git a/resources/users/schemas.js b/resources/users/schemas.js new file mode 100644 index 0000000..941f4ab --- /dev/null +++ b/resources/users/schemas.js @@ -0,0 +1,13 @@ +const Joi = require('joi') + +exports.model = Joi.object({ + id: Joi.string().uuid(), + username: Joi.string(), + password: Joi.string() +}) + +exports.create = exports.model.concat(Joi.object({ + id: Joi.forbidden(), + username: Joi.string().token().min(3).max(20).required(), + password: Joi.string().min(8).max(120).required() +})) diff --git a/scripts/drop-database.js b/scripts/drop-database.js new file mode 100644 index 0000000..b6d04ea --- /dev/null +++ b/scripts/drop-database.js @@ -0,0 +1,8 @@ +const db = require('../lib/db') + +db.dropDatabase() +.then(() => process.exit(0)) +.catch(err => { + console.error(err.stack) + process.exit(1) +}) diff --git a/test/fixtures/hello-world.js b/test/fixtures/hello-world.js new file mode 100644 index 0000000..ba68584 --- /dev/null +++ b/test/fixtures/hello-world.js @@ -0,0 +1,28 @@ +'use strict' + +const { knex } = require('../../lib/db') + +/** + * Insere dois registros na tabela hello_world + * @return {Promise} Uma promise que resolve quando os registros forem inseridos + */ +exports.insertMultiple = () => { + return knex('hello_world').insert([ + { + id: 'ae5c4ed1-916b-4193-bb1c-2f278a4e0243', + nome: 'João', + sobrenome: 'Ninguém', + email: 'joao_ninguem@gmail.com', + created_at: new Date(), + updated_at: new Date() + }, + { + id: '4eb8065f-4483-4877-bfb5-147eb8d2766c', + nome: 'Alan', + sobrenome: 'Hoffmeister', + email: 'alanhoffmeister@gmail.com', + created_at: new Date(), + updated_at: new Date() + } + ]) +} diff --git a/test/fixtures/index.js b/test/fixtures/index.js new file mode 100644 index 0000000..81efb5f --- /dev/null +++ b/test/fixtures/index.js @@ -0,0 +1,15 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +// Faz o require de todas as fixtures do projeto, assim não precisamos fazer +// o require de cada arquivo individualmente +module.exports = fs.readdirSync(__dirname) +.filter(file => file !== 'index.js') +.reduce((prev, curr) => { + const key = curr.replace(/\.js$/i, '') + prev[key] = require(path.resolve(__dirname, curr)) + + return prev +}, {}) diff --git a/test/fixtures/sessions.js b/test/fixtures/sessions.js new file mode 100644 index 0000000..5cd75b0 --- /dev/null +++ b/test/fixtures/sessions.js @@ -0,0 +1,15 @@ +const server = require('../../lib/server') +const request = require('supertest') + +/** + * Cria um cookie compatível com a header Cookie para ser usado com o supertest + * @param {String} username O nome do usuário + * @param {String} password A senha do usuário + * @return {Promise} Uma promise que resolve com o cookie de autenticação + */ +exports.cookie = (username, password) => request(server) +.post('/sessions') +.send({ username, password }) +.then(res => res.header['set-cookie'] + .map(e => e.split(';')[0]) + .join(';')) diff --git a/test/fixtures/users.js b/test/fixtures/users.js new file mode 100644 index 0000000..a53e66f --- /dev/null +++ b/test/fixtures/users.js @@ -0,0 +1,22 @@ +const { knex } = require('../../lib/db') + +/** + * Insere dois registros na tabela `users` + * @return {Promise} Uma promise que resolve quando os registros forem inseridos + */ +exports.insertMultiple = () => knex('users').insert([ + { + id: '212dd279-129f-474a-beb2-a1cac605cf48', + username: 'alanhoff', + password: '$2a$12$SQM/UfsYvHrQ0sWWMQHsfOSg6Hfd.fGFQrj8VymlZGzmtr78BIs6i', // password + created_at: new Date(), + updated_at: new Date() + }, + { + id: '6af458a8-df22-4da9-a726-89d14076e220', + username: 'thebergamo', + password: '$2a$12$SQM/UfsYvHrQ0sWWMQHsfOSg6Hfd.fGFQrj8VymlZGzmtr78BIs6i', // password + created_at: new Date(), + updated_at: new Date() + } +]) diff --git a/test/resources/hello-world.js b/test/resources/hello-world.js new file mode 100644 index 0000000..38340a2 --- /dev/null +++ b/test/resources/hello-world.js @@ -0,0 +1,106 @@ +'use strict' + +const lab = exports.lab = require('lab').script() +const expect = require('code').expect +const co = require('co') +const request = require('supertest') + +const server = require('../../lib/server') +const db = require('../../lib/db') +const HelloWorld = db.model('HelloWorld') +const fixtures = require('../fixtures') + +const ENDPOINT = '/hello-world' + +lab.describe('hello-world', () => { + lab.beforeEach(() => db.truncate()) + + lab.describe(`POST ${ENDPOINT}`, () => { + lab.test('não deve aceitar um payload que não esteja de acordo com o schema', co.wrap(function * () { + const req = yield request(server) + .post(ENDPOINT) + .send({ hello: 'World' }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.error).to.equal('ValidationError') + })) + + lab.test('deve criar uma nova linha no banco de dados', co.wrap(function * () { + const req = yield request(server) + .post(ENDPOINT) + .send({ + nome: 'Alan', + sobrenome: 'Hoffmeister', + email: 'alanhoffmeister@gmail.com' + }) + + expect(req.statusCode).to.equal(200) + expect(req.res.body.nome).to.equal('Alan') + expect(req.res.body.sobrenome).to.equal('Hoffmeister') + expect(req.res.body.email).to.equal('alanhoffmeister@gmail.com') + + const data = yield HelloWorld.findAll() + expect(data).to.have.length(1) + expect(data.at(0).toJSON()).to.contain({ + id: req.res.body.id, + nome: 'Alan', + sobrenome: 'Hoffmeister', + email: 'alanhoffmeister@gmail.com' + }) + })) + }) + + lab.describe(`GET ${ENDPOINT}`, () => { + lab.test('deve retornar todas as linhas do banco de dados', co.wrap(function * () { + yield fixtures['hello-world'].insertMultiple() + + const req = yield request(server) + .get(ENDPOINT) + + expect(req.statusCode).to.equal(200) + expect(req.res.body).to.be.an.array() + expect(req.res.body).to.have.length(2) + expect(req.res.body.map(item => item.id)).to.contain([ + 'ae5c4ed1-916b-4193-bb1c-2f278a4e0243', + '4eb8065f-4483-4877-bfb5-147eb8d2766c' + ]) + })) + }) + + lab.describe(`GET ${ENDPOINT}/:id`, () => { + lab.test('deve retornar a linha selecionada', co.wrap(function * () { + yield fixtures['hello-world'].insertMultiple() + + const req = yield request(server) + .get(`${ENDPOINT}/ae5c4ed1-916b-4193-bb1c-2f278a4e0243`) + + expect(req.statusCode).to.equal(200) + expect(req.res.body).to.be.an.object() + expect(req.res.body).to.contain({ + id: 'ae5c4ed1-916b-4193-bb1c-2f278a4e0243', + nome: 'João', + sobrenome: 'Ninguém', + email: 'joao_ninguem@gmail.com' + }) + })) + }) + + lab.describe(`DELETE ${ENDPOINT}/:id`, () => { + lab.test('deve remover do banco de dados a linha selecionada', co.wrap(function * () { + yield fixtures['hello-world'].insertMultiple() + + const req = yield request(server) + .delete(`${ENDPOINT}/ae5c4ed1-916b-4193-bb1c-2f278a4e0243`) + + expect(req.statusCode).to.equal(200) + expect(req.res.body).to.be.an.object() + expect(req.res.body).to.equal({ success: true }) + + const data = yield HelloWorld.findById('ae5c4ed1-916b-4193-bb1c-2f278a4e0243', { + require: false + }) + + expect(data).to.not.exist() + })) + }) +}) diff --git a/test/resources/sessions.js b/test/resources/sessions.js new file mode 100644 index 0000000..657a026 --- /dev/null +++ b/test/resources/sessions.js @@ -0,0 +1,137 @@ +const lab = exports.lab = require('lab').script() +const expect = require('code').expect +const co = require('co') +const request = require('supertest') + +const server = require('../../lib/server') +const db = require('../../lib/db') +const fixtures = require('../fixtures') + +const ENDPOINT = '/sessions' + +lab.describe('sessions', () => { + lab.beforeEach(() => db.truncate()) + + lab.describe(`POST ${ENDPOINT}`, () => { + lab.test('deve criar uma sessão', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'alanhoff', + password: 'password' + }) + + expect(req.statusCode).to.equal(200) + expect(req.headers['set-cookie']).to.have.length(2) + })) + + lab.test('deve validar os dados', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'alanhoff', + password: '123' + }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.error).to.equal('ValidationError') + })) + + lab.test('Não deve aceitar usuários logados', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .post(ENDPOINT) + .set('Cookie', yield fixtures.sessions.cookie('alanhoff', 'password')) + .send({ + username: 'alanhoff', + password: 'password' + }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.message).to.equal('Você já está logado') + })) + + lab.test('Não deve aceitar usuários que não existem', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'alanhoff2', + password: 'password' + }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.message).to.equal('Não foi possível encontrar o usuário') + })) + + lab.test('Não deve aceitar senhas inválidas', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'alanhoff', + password: 'invalid_pwd' + }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.message).to.equal('Sua senha está incorreta') + })) + }) + + lab.describe(`GET ${ENDPOINT}`, () => { + lab.test('deve retornar a sessão', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .get(ENDPOINT) + .set('Cookie', yield fixtures.sessions.cookie('alanhoff', 'password')) + + expect(req.statusCode).to.equal(200) + expect(req.res.body.password).to.not.exist() + expect(req.res.body).to.contain({ + username: 'alanhoff', + id: '212dd279-129f-474a-beb2-a1cac605cf48' + }) + })) + + lab.test('não deve retornar a sessão para usuários não autenticados', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .get(ENDPOINT) + + expect(req.statusCode).to.equal(401) + expect(req.res.body.error).to.equal('Unauthorized') + })) + }) + + lab.describe(`DELETE ${ENDPOINT}`, () => { + lab.test('deve remover a sessão sessão', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .delete(ENDPOINT) + .set('Cookie', yield fixtures.sessions.cookie('alanhoff', 'password')) + + expect(req.headers['set-cookie'][0].split(';')[0]).to.equal('session=') + })) + + lab.test('não deve remover a sessão de usuários não autenticados', co.wrap(function * () { + yield fixtures.users.insertMultiple() + + const req = yield request(server) + .delete(ENDPOINT) + + expect(req.statusCode).to.equal(401) + expect(req.res.body.error).to.equal('Unauthorized') + })) + }) +}) + diff --git a/test/resources/users.js b/test/resources/users.js new file mode 100644 index 0000000..7fedec8 --- /dev/null +++ b/test/resources/users.js @@ -0,0 +1,70 @@ +const lab = exports.lab = require('lab').script() +const expect = require('code').expect +const co = require('co') +const request = require('supertest') + +const server = require('../../lib/server') +const db = require('../../lib/db') +const Model = db.model('User') + +const ENDPOINT = '/usuarios' + +lab.describe('users', () => { + lab.beforeEach(() => db.truncate()) + + lab.describe(`POST ${ENDPOINT}`, () => { + lab.test('não deve aceitar um payload que não esteja de acordo com o schema', co.wrap(function * () { + const req = yield request(server) + .post(ENDPOINT) + .send({ username: 'p' }) + + expect(req.statusCode).to.equal(422) + expect(req.res.body.error).to.equal('ValidationError') + })) + + lab.test('deve criar uma nova linha no banco de dados', co.wrap(function * () { + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'mark', + password: 'awesomePass' + }) + + expect(req.statusCode).to.equal(200) + expect(req.res.body.success).to.equal(true) + + const data = yield Model.findAll() + expect(data).to.have.length(1) + expect(data.at(0).toJSON()).to.contain({ + username: 'mark' + }) + })) + + lab.test('retorna um erro quando o usuário já existir', co.wrap(function * () { + // first request + yield request(server) + .post(ENDPOINT) + .send({ + username: 'foo', + password: 'foobarbaz' + }) + + const req = yield request(server) + .post(ENDPOINT) + .send({ + username: 'foo', + password: 'foobarbaz' + }) + + expect(req.statusCode).to.equal(400) + expect(req.res.body.error).to.equal('Este usuário já existe') + + const data = yield Model.findAll() + expect(data).to.have.length(1) + expect(data.at(0).toJSON()).to.contain({ + username: 'foo' + }) + })) + }) +}) +