├── .gitignore ├── src └── server │ ├── session.js │ ├── db │ ├── connection.js │ ├── migrations │ │ ├── 20171231115201_users.js │ │ └── 20170817152841_movies.js │ ├── queries │ │ ├── users.js │ │ └── movies.js │ └── seeds │ │ ├── movies_seed.js │ │ └── users.js │ ├── views │ ├── admin.html │ ├── status.html │ ├── login.html │ └── register.html │ ├── routes │ ├── index.js │ ├── _helpers.js │ ├── auth.js │ └── movies.js │ ├── index.js │ └── auth.js ├── test ├── unit │ └── sample.test.js └── integration │ ├── routes.index.test.js │ ├── routes.auth.test.js │ ├── routes.auth.stub.test.js │ └── routes.movies.test.js ├── .travis.yml ├── knexfile.js ├── package.json ├── README.md ├── .jshintrc └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /src/server/session.js: -------------------------------------------------------------------------------- 1 | const RedisStore = require('koa-redis'); 2 | 3 | module.exports = new RedisStore(); 4 | -------------------------------------------------------------------------------- /src/server/db/connection.js: -------------------------------------------------------------------------------- 1 | const environment = process.env.NODE_ENV || 'development'; 2 | const config = require('../../../knexfile.js')[environment]; 3 | 4 | module.exports = require('knex')(config); 5 | -------------------------------------------------------------------------------- /src/server/views/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Admin 6 | 7 | 8 |

You are an admin!

9 |

Logout?

10 | 11 | 12 | -------------------------------------------------------------------------------- /src/server/views/status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Status 6 | 7 | 8 |

You are authenticated.

9 |

Logout?

10 | 11 | 12 | -------------------------------------------------------------------------------- /src/server/routes/index.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const router = new Router(); 3 | 4 | router.get('/', async (ctx) => { 5 | ctx.body = { 6 | status: 'success', 7 | message: 'hello, world!' 8 | }; 9 | }) 10 | 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /test/unit/sample.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const should = chai.should(); 5 | 6 | describe('Sample Test', () => { 7 | it('should pass', (done) => { 8 | const sum = 1 + 2; 9 | sum.should.eql(3); 10 | sum.should.not.eql(4); 11 | done(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: 2 | - node_js 3 | 4 | node_js: 5 | - '9' 6 | - '8' 7 | - '7' 8 | 9 | services: 10 | - postgresql 11 | - redis-server 12 | 13 | addons: 14 | postgresql: '9.4' 15 | 16 | env: 17 | global: 18 | - NODE_ENV=test 19 | - export DATABASE_TEST_URL=postgresql://localhost/koa_api_test 20 | 21 | before_script: 22 | - psql -c 'create database koa_api_test;' -U postgres 23 | 24 | script: 25 | - npm run test 26 | -------------------------------------------------------------------------------- /src/server/db/migrations/20171231115201_users.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex, Promise) => { 2 | return knex.schema.createTable('users', (table) => { 3 | table.increments(); 4 | table.string('username').unique().notNullable(); 5 | table.string('password').notNullable(); 6 | table.boolean('admin').notNullable().defaultTo(false); 7 | }); 8 | }; 9 | 10 | exports.down = (knex, Promise) => { 11 | return knex.schema.dropTable('users'); 12 | }; 13 | -------------------------------------------------------------------------------- /src/server/views/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login 6 | 7 | 8 |

Login

9 |
10 |

11 |

12 |

13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/server/db/migrations/20170817152841_movies.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex, Promise) => { 2 | return knex.schema.createTable('movies', (table) => { 3 | table.increments(); 4 | table.string('name').notNullable().unique(); 5 | table.string('genre').notNullable(); 6 | table.integer('rating').notNullable(); 7 | table.boolean('explicit').notNullable(); 8 | }); 9 | }; 10 | 11 | exports.down = (knex, Promise) => { 12 | return knex.schema.dropTable('movies'); 13 | }; 14 | -------------------------------------------------------------------------------- /src/server/views/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Register 6 | 7 | 8 |

Register

9 |
10 |

11 |

12 |

13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/server/db/queries/users.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs'); 2 | const knex = require('../connection'); 3 | 4 | function getSingleUser(id) { 5 | return knex('users') 6 | .select('*') 7 | .where({ id: parseInt(id) }); 8 | } 9 | 10 | function addUser(user) { 11 | const salt = bcrypt.genSaltSync(); 12 | const hash = bcrypt.hashSync(user.password, salt); 13 | return knex('users') 14 | .insert({ 15 | username: user.username, 16 | password: hash, 17 | }) 18 | .returning('*'); 19 | } 20 | 21 | module.exports = { 22 | getSingleUser, 23 | addUser, 24 | }; 25 | -------------------------------------------------------------------------------- /src/server/routes/_helpers.js: -------------------------------------------------------------------------------- 1 | const queries = require('../db/queries/users'); 2 | 3 | function ensureAuthenticated(context) { 4 | return context.isAuthenticated(); 5 | } 6 | 7 | function ensureAdmin(context) { 8 | return new Promise((resolve, reject) => { 9 | if (context.isAuthenticated()) { 10 | queries.getSingleUser(context.state.user.id) 11 | .then((user) => { 12 | if (user && user[0].admin) resolve(true); 13 | resolve(false); 14 | }) 15 | .catch((err) => { reject(false); }); 16 | } 17 | return false; 18 | }); 19 | } 20 | 21 | module.exports = { 22 | ensureAuthenticated, 23 | ensureAdmin 24 | }; 25 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const BASE_PATH = path.join(__dirname, 'src', 'server', 'db'); 4 | 5 | module.exports = { 6 | test: { 7 | client: 'pg', 8 | connection: 'postgres://localhost:5432/koa_api_test', 9 | migrations: { 10 | directory: path.join(BASE_PATH, 'migrations') 11 | }, 12 | seeds: { 13 | directory: path.join(BASE_PATH, 'seeds') 14 | } 15 | }, 16 | development: { 17 | client: 'pg', 18 | connection: 'postgres://localhost:5432/koa_api', 19 | migrations: { 20 | directory: path.join(BASE_PATH, 'migrations') 21 | }, 22 | seeds: { 23 | directory: path.join(BASE_PATH, 'seeds') 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/server/db/seeds/movies_seed.js: -------------------------------------------------------------------------------- 1 | exports.seed = (knex, Promise) => { 2 | return knex('movies').del() 3 | .then(() => { 4 | return knex('movies').insert({ 5 | name: 'The Land Before Time', 6 | genre: 'Fantasy', 7 | rating: 7, 8 | explicit: false 9 | }); 10 | }) 11 | .then(() => { 12 | return knex('movies').insert({ 13 | name: 'Jurassic Park', 14 | genre: 'Science Fiction', 15 | rating: 9, 16 | explicit: true 17 | }); 18 | }) 19 | .then(() => { 20 | return knex('movies').insert({ 21 | name: 'Ice Age: Dawn of the Dinosaurs', 22 | genre: 'Action/Romance', 23 | rating: 5, 24 | explicit: false 25 | }); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/server/db/seeds/users.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs'); 2 | 3 | exports.seed = (knex, Promise) => { 4 | return knex('users').del() 5 | .then(() => { 6 | const salt = bcrypt.genSaltSync(); 7 | const hash = bcrypt.hashSync('johnson', salt); 8 | return Promise.join( 9 | knex('users').insert({ 10 | username: 'jeremy', 11 | password: hash, 12 | admin: false 13 | }) 14 | ); 15 | }) 16 | .then(() => { 17 | const salt = bcrypt.genSaltSync(); 18 | const hash = bcrypt.hashSync('88776655443322', salt); 19 | return Promise.join( 20 | knex('users').insert({ 21 | username: 'hank', 22 | password: hash, 23 | admin: true 24 | }) 25 | ); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /test/integration/routes.index.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const should = chai.should(); 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | const server = require('../../src/server/index'); 9 | 10 | describe('routes : index', () => { 11 | 12 | describe('GET /', () => { 13 | it('should return json', (done) => { 14 | chai.request(server) 15 | .get('/') 16 | .end((err, res) => { 17 | should.not.exist(err); 18 | res.status.should.eql(200); 19 | res.type.should.eql('application/json'); 20 | res.body.status.should.equal('success'); 21 | res.body.message.should.eql('hello, world!'); 22 | done(); 23 | }); 24 | }); 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-koa-api", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./src/server/index.js", 7 | "test": "./node_modules/mocha/bin/_mocha ./test/**/*.test.js" 8 | }, 9 | "keywords": [], 10 | "author": "Michael Herman", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "chai": "4.1.1", 14 | "chai-http": "^3.0.0", 15 | "mocha": "3.5.0", 16 | "sinon": "^4.1.5" 17 | }, 18 | "dependencies": { 19 | "bcryptjs": "^2.4.3", 20 | "knex": "^0.13.0", 21 | "koa": "^2.3.0", 22 | "koa-bodyparser": "^4.2.0", 23 | "koa-passport": "^4.0.1", 24 | "koa-redis": "^3.1.1", 25 | "koa-router": "^7.2.1", 26 | "koa-session": "^5.5.1", 27 | "passport-local": "^1.0.0", 28 | "pg": "^7.1.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/db/queries/movies.js: -------------------------------------------------------------------------------- 1 | const knex = require('../connection'); 2 | 3 | function getAllMovies() { 4 | return knex('movies') 5 | .select('*'); 6 | } 7 | 8 | function getSingleMovie(id) { 9 | return knex('movies') 10 | .select('*') 11 | .where({ id: parseInt(id) }); 12 | } 13 | 14 | function addMovie(movie) { 15 | return knex('movies') 16 | .insert(movie) 17 | .returning('*'); 18 | } 19 | 20 | function updateMovie(id, movie) { 21 | return knex('movies') 22 | .update(movie) 23 | .where({ id: parseInt(id) }) 24 | .returning('*'); 25 | } 26 | 27 | function deleteMovie(id) { 28 | return knex('movies') 29 | .del() 30 | .where({ id: parseInt(id) }) 31 | .returning('*'); 32 | } 33 | 34 | module.exports = { 35 | getAllMovies, 36 | getSingleMovie, 37 | addMovie, 38 | updateMovie, 39 | deleteMovie 40 | }; 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RESTful API with Koa, Passport, Redis, and Postgres 2 | 3 | [![Build Status](https://travis-ci.org/mjhea0/node-koa-api.svg?branch=master)](https://travis-ci.org/mjhea0/node-koa-api) 4 | 5 | ## Want to learn how to build this project? 6 | 7 | Check out the following blog posts: 8 | 9 | 1. [Building a RESTful API with Koa and Postgres](http://mherman.org/blog/2017/08/23/building-a-restful-api-with-koa-and-postgres) 10 | 1. [User Authentication with Passport and Koa](http://mherman.org/blog/2018/01/02/user-authentication-with-passport-and-koa) 11 | 12 | ## Want to use this project? 13 | 14 | 1. Fork/Clone 15 | 1. Install dependencies - `npm install` 16 | 1. Fire up Postgres and Redis on the default ports 17 | 1. Create two local Postgres databases - `koa_api` and `koa_api_test` 18 | 1. Migrate - `knex migrate:latest --env development` 19 | 1. Seed - `knex seed:run --env development` 20 | 1. Sanity check - `npm start` 21 | 1. Test - `npm test` 22 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "esnext": true, 4 | "jasmine": false, 5 | "spyOn": false, 6 | "it": false, 7 | "console": false, 8 | "describe": false, 9 | "expect": false, 10 | "beforeEach": false, 11 | "afterEach": false, 12 | "waits": false, 13 | "waitsFor": false, 14 | "runs": false, 15 | "$": false, 16 | "confirm": false 17 | }, 18 | "esnext": true, 19 | "node" : true, 20 | "browser" : true, 21 | "boss" : false, 22 | "curly": false, 23 | "debug": false, 24 | "devel": false, 25 | "eqeqeq": true, 26 | "evil": true, 27 | "forin": false, 28 | "immed": true, 29 | "laxbreak": false, 30 | "newcap": true, 31 | "noarg": true, 32 | "noempty": false, 33 | "nonew": false, 34 | "nomen": false, 35 | "onevar": true, 36 | "plusplus": false, 37 | "regexp": false, 38 | "undef": true, 39 | "sub": true, 40 | "strict": false, 41 | "white": true, 42 | "unused": false 43 | } 44 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const bodyParser = require('koa-bodyparser'); 3 | const session = require('koa-session'); 4 | const passport = require('koa-passport'); 5 | 6 | const indexRoutes = require('./routes/index'); 7 | const movieRoutes = require('./routes/movies'); 8 | const authRoutes = require('./routes/auth'); 9 | const store = require('./session'); 10 | 11 | const app = new Koa(); 12 | const PORT = process.env.PORT || 1337; 13 | 14 | // sessions 15 | app.keys = ['super-secret-key']; 16 | app.use(session({ store }, app)); 17 | 18 | // body parser 19 | app.use(bodyParser()); 20 | 21 | // authentication 22 | require('./auth'); 23 | app.use(passport.initialize()); 24 | app.use(passport.session()); 25 | 26 | // routes 27 | app.use(indexRoutes.routes()); 28 | app.use(movieRoutes.routes()); 29 | app.use(authRoutes.routes()); 30 | 31 | // server 32 | const server = app.listen(PORT, () => { 33 | console.log(`Server listening on port: ${PORT}`); 34 | }); 35 | 36 | module.exports = server; 37 | -------------------------------------------------------------------------------- /src/server/auth.js: -------------------------------------------------------------------------------- 1 | const passport = require('koa-passport'); 2 | const LocalStrategy = require('passport-local').Strategy; 3 | const bcrypt = require('bcryptjs'); 4 | 5 | const knex = require('./db/connection'); 6 | 7 | const options = {}; 8 | 9 | function comparePass(userPassword, databasePassword) { 10 | return bcrypt.compareSync(userPassword, databasePassword); 11 | } 12 | 13 | passport.serializeUser((user, done) => { done(null, user.id); }); 14 | 15 | passport.deserializeUser((id, done) => { 16 | return knex('users').where({id}).first() 17 | .then((user) => { done(null, user); }) 18 | .catch((err) => { done(err,null); }); 19 | }); 20 | 21 | passport.use(new LocalStrategy(options, (username, password, done) => { 22 | knex('users').where({ username }).first() 23 | .then((user) => { 24 | if (!user) return done(null, false); 25 | if (!comparePass(password, user.password)) { 26 | return done(null, false); 27 | } else { 28 | return done(null, user); 29 | } 30 | }) 31 | .catch((err) => { return done(err); }); 32 | })); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michael Herman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server/routes/auth.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const passport = require('koa-passport'); 3 | const fs = require('fs'); 4 | 5 | const queries = require('../db/queries/users'); 6 | const helpers = require('./_helpers'); 7 | 8 | const router = new Router(); 9 | 10 | router.get('/auth/register', async (ctx) => { 11 | ctx.type = 'html'; 12 | ctx.body = fs.createReadStream('./src/server/views/register.html'); 13 | }); 14 | 15 | router.post('/auth/register', async (ctx) => { 16 | const user = await queries.addUser(ctx.request.body); 17 | return passport.authenticate('local', (err, user, info, status) => { 18 | if (user) { 19 | ctx.login(user); 20 | ctx.redirect('/auth/status'); 21 | } else { 22 | ctx.status = 400; 23 | ctx.body = { status: 'error' }; 24 | } 25 | })(ctx); 26 | }); 27 | 28 | router.get('/auth/login', async (ctx) => { 29 | if (!helpers.ensureAuthenticated(ctx)) { 30 | ctx.type = 'html'; 31 | ctx.body = fs.createReadStream('./src/server/views/login.html'); 32 | } else { 33 | ctx.redirect('/auth/status'); 34 | } 35 | }); 36 | 37 | router.post('/auth/login', async (ctx) => { 38 | return passport.authenticate('local', (err, user, info, status) => { 39 | if (user) { 40 | ctx.login(user); 41 | ctx.redirect('/auth/status'); 42 | } else { 43 | ctx.status = 400; 44 | ctx.body = { status: 'error' }; 45 | } 46 | })(ctx); 47 | }); 48 | 49 | router.get('/auth/logout', async (ctx) => { 50 | if (helpers.ensureAuthenticated(ctx)) { 51 | ctx.logout(); 52 | ctx.redirect('/auth/login'); 53 | } else { 54 | ctx.body = { success: false }; 55 | ctx.throw(401); 56 | } 57 | }); 58 | 59 | router.get('/auth/status', async (ctx) => { 60 | if (helpers.ensureAuthenticated(ctx)) { 61 | ctx.type = 'html'; 62 | ctx.body = fs.createReadStream('./src/server/views/status.html'); 63 | } else { 64 | ctx.redirect('/auth/login'); 65 | } 66 | }); 67 | 68 | router.get('/auth/admin', async (ctx) => { 69 | if (await helpers.ensureAdmin(ctx)) { 70 | ctx.type = 'html'; 71 | ctx.body = fs.createReadStream('./src/server/views/admin.html'); 72 | } else { 73 | ctx.redirect('/auth/login'); 74 | } 75 | }); 76 | 77 | module.exports = router; 78 | -------------------------------------------------------------------------------- /test/integration/routes.auth.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const should = chai.should(); 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | const server = require('../../src/server/index'); 9 | const knex = require('../../src/server/db/connection'); 10 | 11 | describe('routes : auth', () => { 12 | 13 | beforeEach(() => { 14 | return knex.migrate.rollback() 15 | .then(() => { return knex.migrate.latest(); }) 16 | .then(() => { return knex.seed.run(); }); 17 | }); 18 | 19 | afterEach(() => { 20 | return knex.migrate.rollback(); 21 | }); 22 | 23 | describe('GET /auth/register', () => { 24 | it('should render the register view', (done) => { 25 | chai.request(server) 26 | .get('/auth/register') 27 | .end((err, res) => { 28 | should.not.exist(err); 29 | res.redirects.length.should.eql(0); 30 | res.status.should.eql(200); 31 | res.type.should.eql('text/html'); 32 | res.text.should.contain('

Register

'); 33 | res.text.should.contain( 34 | '

'); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('POST /auth/register', () => { 41 | it('should register a new user', (done) => { 42 | chai.request(server) 43 | .post('/auth/register') 44 | .send({ 45 | username: 'michael', 46 | password: 'herman' 47 | }) 48 | .end((err, res) => { 49 | should.not.exist(err); 50 | res.redirects[0].should.contain('/auth/status'); 51 | done(); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('GET /auth/login', () => { 57 | it('should render the login view', (done) => { 58 | chai.request(server) 59 | .get('/auth/login') 60 | .end((err, res) => { 61 | should.not.exist(err); 62 | res.redirects.length.should.eql(0); 63 | res.status.should.eql(200); 64 | res.type.should.eql('text/html'); 65 | res.text.should.contain('

Login

'); 66 | res.text.should.contain( 67 | '

'); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | 73 | describe('POST /auth/login', () => { 74 | it('should login a user', (done) => { 75 | chai.request(server) 76 | .post('/auth/login') 77 | .send({ 78 | username: 'jeremy', 79 | password: 'johnson' 80 | }) 81 | .end((err, res) => { 82 | res.redirects[0].should.contain('/auth/status'); 83 | done(); 84 | }); 85 | }); 86 | }); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /src/server/routes/movies.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | const queries = require('../db/queries/movies'); 3 | 4 | const router = new Router(); 5 | const BASE_URL = `/api/v1/movies`; 6 | 7 | router.get(BASE_URL, async (ctx) => { 8 | try { 9 | const movies = await queries.getAllMovies(); 10 | ctx.body = { 11 | status: 'success', 12 | data: movies 13 | }; 14 | } catch (err) { 15 | console.log(err) 16 | } 17 | }) 18 | 19 | router.get(`${BASE_URL}/:id`, async (ctx) => { 20 | try { 21 | const movie = await queries.getSingleMovie(ctx.params.id); 22 | if (movie.length) { 23 | ctx.body = { 24 | status: 'success', 25 | data: movie 26 | }; 27 | } else { 28 | ctx.status = 404; 29 | ctx.body = { 30 | status: 'error', 31 | message: 'That movie does not exist.' 32 | }; 33 | } 34 | } catch (err) { 35 | console.log(err) 36 | } 37 | }) 38 | 39 | router.post(`${BASE_URL}`, async (ctx) => { 40 | try { 41 | const movie = await queries.addMovie(ctx.request.body); 42 | if (movie.length) { 43 | ctx.status = 201; 44 | ctx.body = { 45 | status: 'success', 46 | data: movie 47 | }; 48 | } else { 49 | ctx.status = 400; 50 | ctx.body = { 51 | status: 'error', 52 | message: 'Something went wrong.' 53 | }; 54 | } 55 | } catch (err) { 56 | ctx.status = 400; 57 | ctx.body = { 58 | status: 'error', 59 | message: err.message || 'Sorry, an error has occurred.' 60 | }; 61 | } 62 | }) 63 | 64 | router.put(`${BASE_URL}/:id`, async (ctx) => { 65 | try { 66 | const movie = await queries.updateMovie(ctx.params.id, ctx.request.body); 67 | if (movie.length) { 68 | ctx.status = 200; 69 | ctx.body = { 70 | status: 'success', 71 | data: movie 72 | }; 73 | } else { 74 | ctx.status = 404; 75 | ctx.body = { 76 | status: 'error', 77 | message: 'That movie does not exist.' 78 | }; 79 | } 80 | } catch (err) { 81 | ctx.status = 400; 82 | ctx.body = { 83 | status: 'error', 84 | message: err.message || 'Sorry, an error has occurred.' 85 | }; 86 | } 87 | }) 88 | 89 | router.delete(`${BASE_URL}/:id`, async (ctx) => { 90 | try { 91 | const movie = await queries.deleteMovie(ctx.params.id); 92 | if (movie.length) { 93 | ctx.status = 200; 94 | ctx.body = { 95 | status: 'success', 96 | data: movie 97 | }; 98 | } else { 99 | ctx.status = 404; 100 | ctx.body = { 101 | status: 'error', 102 | message: 'That movie does not exist.' 103 | }; 104 | } 105 | } catch (err) { 106 | ctx.status = 400; 107 | ctx.body = { 108 | status: 'error', 109 | message: err.message || 'Sorry, an error has occurred.' 110 | }; 111 | } 112 | }) 113 | 114 | module.exports = router; 115 | -------------------------------------------------------------------------------- /test/integration/routes.auth.stub.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const passport = require('koa-passport'); 5 | const should = chai.should(); 6 | const chaiHttp = require('chai-http'); 7 | const sinon = require('sinon'); 8 | chai.use(chaiHttp); 9 | 10 | const server = require('../../src/server/index'); 11 | const knex = require('../../src/server/db/connection'); 12 | const queries = require('../../src/server/db/queries/users'); 13 | const store = require('../../src/server/session'); 14 | const helpers = require('../../src/server/routes/_helpers'); 15 | 16 | describe('routes : auth - stubbed', () => { 17 | 18 | beforeEach(() => { 19 | this.ensureAuthenticated = sinon.stub( 20 | helpers, 'ensureAuthenticated' 21 | ).returns(() => {}); 22 | this.ensureAdmin = sinon.stub(helpers, 'ensureAdmin'); 23 | this.store = sinon.stub(store, 'set'); 24 | this.authenticate = sinon.stub(passport, 'authenticate').returns(() => {}); 25 | this.serialize = sinon.stub(passport, 'serializeUser').returns(() => {}); 26 | this.deserialize = sinon.stub(passport, 'deserializeUser').returns( 27 | () => {}); 28 | }); 29 | 30 | afterEach(() => { 31 | this.authenticate.restore(); 32 | this.serialize.restore(); 33 | this.deserialize.restore(); 34 | this.store.restore(); 35 | this.ensureAuthenticated.restore(); 36 | this.ensureAdmin.restore(); 37 | }); 38 | 39 | describe('GET /auth/register', () => { 40 | it('should render the register view', (done) => { 41 | chai.request(server) 42 | .get('/auth/register') 43 | .end((err, res) => { 44 | should.not.exist(err); 45 | res.redirects.length.should.eql(0); 46 | res.status.should.eql(200); 47 | res.type.should.eql('text/html'); 48 | res.text.should.contain('

Register

'); 49 | res.text.should.contain( 50 | '

'); 51 | done(); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('POST /auth/register', () => { 57 | beforeEach(() => { 58 | const user = [ 59 | { 60 | id: 1, 61 | username: 'michael', 62 | password: 'something' 63 | } 64 | ]; 65 | this.query = sinon.stub(queries, 'addUser').resolves(user); 66 | this.authenticate.yields(null, { id: 1 }); 67 | this.serialize.yields(null, { id: 1 }); 68 | this.deserialize.yields(null, { id: 1 }); 69 | }); 70 | afterEach(() => { 71 | this.query.restore(); 72 | }); 73 | it('should register a new user', (done) => { 74 | chai.request(server) 75 | .post('/auth/register') 76 | .send({ 77 | username: 'michael', 78 | password: 'herman' 79 | }) 80 | .end((err, res) => { 81 | res.redirects[0].should.contain('/auth/status'); 82 | done(); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('GET /auth/login', () => { 88 | beforeEach(() => { 89 | this.ensureAuthenticated.returns(false); 90 | }); 91 | it('should render the login view if a user is not logged in', (done) => { 92 | chai.request(server) 93 | .get('/auth/login') 94 | .end((err, res) => { 95 | should.not.exist(err); 96 | res.redirects.length.should.eql(0); 97 | res.status.should.eql(200); 98 | res.type.should.eql('text/html'); 99 | res.text.should.contain('

Login

'); 100 | res.text.should.contain( 101 | '

'); 102 | done(); 103 | }); 104 | }); 105 | }); 106 | 107 | describe('GET /auth/login', () => { 108 | beforeEach(() => { 109 | this.ensureAuthenticated.returns(true); 110 | }); 111 | it('should render the status view if the user is logged in', (done) => { 112 | chai.request(server) 113 | .get('/auth/login') 114 | .end((err, res) => { 115 | should.not.exist(err); 116 | res.redirects.length.should.eql(1); 117 | res.redirects[0].should.contain('/auth/status'); 118 | res.status.should.eql(200); 119 | res.type.should.eql('text/html'); 120 | res.text.should.contain('

You are authenticated.

'); 121 | done(); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('POST /auth/login', () => { 127 | beforeEach(() => { 128 | this.authenticate.yields(null, { id: 1 }); 129 | this.serialize.yields(null, { id: 1 }); 130 | this.deserialize.yields(null, { id: 1 }); 131 | }); 132 | it('should login a user', (done) => { 133 | chai.request(server) 134 | .post('/auth/login') 135 | .send({ 136 | username: 'jeremy', 137 | password: 'johnson' 138 | }) 139 | .end((err, res) => { 140 | res.redirects[0].should.contain('/auth/status'); 141 | done(); 142 | }); 143 | }); 144 | }); 145 | 146 | describe('POST /auth/login', () => { 147 | beforeEach(() => { 148 | this.authenticate.yields(null, false); 149 | }); 150 | it('should not login a user if the password is incorrect', (done) => { 151 | chai.request(server) 152 | .post('/auth/login') 153 | .send({ 154 | username: 'jeremy', 155 | password: 'notcorrect' 156 | }) 157 | .end((err, res) => { 158 | should.exist(err); 159 | res.redirects.length.should.eql(0); 160 | res.status.should.eql(400); 161 | res.type.should.eql('application/json'); 162 | res.body.status.should.eql('error'); 163 | done(); 164 | }); 165 | }); 166 | }); 167 | 168 | describe('GET /auth/status', () => { 169 | beforeEach(() => { 170 | this.ensureAuthenticated.returns(true); 171 | }); 172 | it('should render the status view', (done) => { 173 | chai.request(server) 174 | .get('/auth/status') 175 | .end((err, res) => { 176 | should.not.exist(err); 177 | res.redirects.length.should.eql(0); 178 | res.status.should.eql(200); 179 | res.type.should.eql('text/html'); 180 | res.text.should.contain('

You are authenticated.

'); 181 | done(); 182 | }); 183 | }); 184 | }); 185 | 186 | describe('GET /auth/admin', () => { 187 | beforeEach(() => { 188 | this.ensureAdmin.resolves(true); 189 | }); 190 | it('should render the admin view if an admin user is logged in', (done) => { 191 | chai.request(server) 192 | .get('/auth/admin') 193 | .end((err, res) => { 194 | should.not.exist(err); 195 | res.redirects.length.should.eql(0); 196 | res.status.should.eql(200); 197 | res.type.should.eql('text/html'); 198 | res.text.should.contain('

You are an admin!

'); 199 | done(); 200 | }); 201 | }); 202 | }); 203 | 204 | describe('GET /auth/admin', () => { 205 | beforeEach(() => { 206 | this.ensureAdmin.resolves(false); 207 | this.ensureAuthenticated.returns(false); 208 | }); 209 | it('should redirect to /auth/login if a user is not logged in', (done) => { 210 | chai.request(server) 211 | .get('/auth/admin') 212 | .end((err, res) => { 213 | should.not.exist(err); 214 | res.redirects.length.should.eql(1); 215 | res.redirects[0].should.contain('/auth/login'); 216 | res.status.should.eql(200); 217 | res.type.should.eql('text/html'); 218 | res.text.should.contain('

Login

'); 219 | res.text.should.contain( 220 | '

'); 221 | done(); 222 | }); 223 | }); 224 | }); 225 | 226 | describe('GET /auth/admin', () => { 227 | beforeEach(() => { 228 | this.ensureAdmin.resolves(false); 229 | this.ensureAuthenticated.returns(true); 230 | }); 231 | it('should redirect to /auth/status if a user is logged in but not an admin', (done) => { 232 | chai.request(server) 233 | .get('/auth/admin') 234 | .end((err, res) => { 235 | should.not.exist(err); 236 | res.redirects.length.should.eql(2); 237 | res.redirects[0].should.contain('/auth/login'); 238 | res.redirects[1].should.contain('/auth/status'); 239 | res.status.should.eql(200); 240 | res.type.should.eql('text/html'); 241 | res.text.should.contain('

You are authenticated.

'); 242 | done(); 243 | }); 244 | }); 245 | }); 246 | 247 | }); 248 | -------------------------------------------------------------------------------- /test/integration/routes.movies.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const should = chai.should(); 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | const server = require('../../src/server/index'); 9 | const knex = require('../../src/server/db/connection'); 10 | 11 | describe('routes : movies', () => { 12 | 13 | beforeEach(() => { 14 | return knex.migrate.rollback() 15 | .then(() => { return knex.migrate.latest(); }) 16 | .then(() => { return knex.seed.run(); }); 17 | }); 18 | 19 | afterEach(() => { 20 | return knex.migrate.rollback(); 21 | }); 22 | 23 | describe('GET /api/v1/movies', () => { 24 | it('should return all movies', (done) => { 25 | chai.request(server) 26 | .get('/api/v1/movies') 27 | .end((err, res) => { 28 | // there should be no errors 29 | should.not.exist(err); 30 | // there should be a 200 status code 31 | res.status.should.equal(200); 32 | // the response should be JSON 33 | res.type.should.equal('application/json'); 34 | // the JSON response body should have a 35 | // key-value pair of {"status": "success"} 36 | res.body.status.should.eql('success'); 37 | // the JSON response body should have a 38 | // key-value pair of {"data": [3 movie objects]} 39 | res.body.data.length.should.eql(3); 40 | // the first object in the data array should 41 | // have the right keys 42 | res.body.data[0].should.include.keys( 43 | 'id', 'name', 'genre', 'rating', 'explicit' 44 | ); 45 | done(); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('GET /api/v1/movies/:id', () => { 51 | it('should respond with a single movie', (done) => { 52 | chai.request(server) 53 | .get('/api/v1/movies/1') 54 | .end((err, res) => { 55 | // there should be no errors 56 | should.not.exist(err); 57 | // there should be a 200 status code 58 | res.status.should.equal(200); 59 | // the response should be JSON 60 | res.type.should.equal('application/json'); 61 | // the JSON response body should have a 62 | // key-value pair of {"status": "success"} 63 | res.body.status.should.eql('success'); 64 | // the JSON response body should have a 65 | // key-value pair of {"data": 1 movie object} 66 | res.body.data[0].should.include.keys( 67 | 'id', 'name', 'genre', 'rating', 'explicit' 68 | ); 69 | done(); 70 | }); 71 | }); 72 | it('should throw an error if the movie does not exist', (done) => { 73 | chai.request(server) 74 | .get('/api/v1/movies/9999999') 75 | .end((err, res) => { 76 | // there should an error 77 | should.exist(err); 78 | // there should be a 404 status code 79 | res.status.should.equal(404); 80 | // the response should be JSON 81 | res.type.should.equal('application/json'); 82 | // the JSON response body should have a 83 | // key-value pair of {"status": "error"} 84 | res.body.status.should.eql('error'); 85 | // the JSON response body should have a 86 | // key-value pair of {"message": "That movie does not exist."} 87 | res.body.message.should.eql('That movie does not exist.'); 88 | done(); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('POST /api/v1/movies', () => { 94 | it('should return the movie that was added', (done) => { 95 | chai.request(server) 96 | .post('/api/v1/movies') 97 | .send({ 98 | name: 'Titanic', 99 | genre: 'Drama', 100 | rating: 8, 101 | explicit: true 102 | }) 103 | .end((err, res) => { 104 | // there should be no errors 105 | should.not.exist(err); 106 | // there should be a 201 status code 107 | // (indicating that something was "created") 108 | res.status.should.equal(201); 109 | // the response should be JSON 110 | res.type.should.equal('application/json'); 111 | // the JSON response body should have a 112 | // key-value pair of {"status": "success"} 113 | res.body.status.should.eql('success'); 114 | // the JSON response body should have a 115 | // key-value pair of {"data": 1 movie object} 116 | res.body.data[0].should.include.keys( 117 | 'id', 'name', 'genre', 'rating', 'explicit' 118 | ); 119 | done(); 120 | }); 121 | }); 122 | it('should throw an error if the payload is malformed', (done) => { 123 | chai.request(server) 124 | .post('/api/v1/movies') 125 | .send({ 126 | name: 'Titanic' 127 | }) 128 | .end((err, res) => { 129 | // there should an error 130 | should.exist(err); 131 | // there should be a 400 status code 132 | res.status.should.equal(400); 133 | // the response should be JSON 134 | res.type.should.equal('application/json'); 135 | // the JSON response body should have a 136 | // key-value pair of {"status": "error"} 137 | res.body.status.should.eql('error'); 138 | // the JSON response body should have a message key 139 | should.exist(res.body.message); 140 | done(); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('PUT /api/v1/movies', () => { 146 | it('should return the movie that was updated', (done) => { 147 | knex('movies') 148 | .select('*') 149 | .then((movie) => { 150 | const movieObject = movie[0]; 151 | chai.request(server) 152 | .put(`/api/v1/movies/${movieObject.id}`) 153 | .send({ 154 | rating: 9 155 | }) 156 | .end((err, res) => { 157 | // there should be no errors 158 | should.not.exist(err); 159 | // there should be a 200 status code 160 | res.status.should.equal(200); 161 | // the response should be JSON 162 | res.type.should.equal('application/json'); 163 | // the JSON response body should have a 164 | // key-value pair of {"status": "success"} 165 | res.body.status.should.eql('success'); 166 | // the JSON response body should have a 167 | // key-value pair of {"data": 1 movie object} 168 | res.body.data[0].should.include.keys( 169 | 'id', 'name', 'genre', 'rating', 'explicit' 170 | ); 171 | // ensure the movie was in fact updated 172 | const newMovieObject = res.body.data[0]; 173 | newMovieObject.rating.should.not.eql(movieObject.rating); 174 | done(); 175 | }); 176 | }); 177 | }); 178 | it('should throw an error if the movie does not exist', (done) => { 179 | chai.request(server) 180 | .put('/api/v1/movies/9999999') 181 | .send({ 182 | rating: 9 183 | }) 184 | .end((err, res) => { 185 | // there should an error 186 | should.exist(err); 187 | // there should be a 404 status code 188 | res.status.should.equal(404); 189 | // the response should be JSON 190 | res.type.should.equal('application/json'); 191 | // the JSON response body should have a 192 | // key-value pair of {"status": "error"} 193 | res.body.status.should.eql('error'); 194 | // the JSON response body should have a 195 | // key-value pair of {"message": "That movie does not exist."} 196 | res.body.message.should.eql('That movie does not exist.'); 197 | done(); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('DELETE /api/v1/movies/:id', () => { 203 | it('should return the movie that was deleted', (done) => { 204 | knex('movies') 205 | .select('*') 206 | .then((movies) => { 207 | const movieObject = movies[0]; 208 | const lengthBeforeDelete = movies.length; 209 | chai.request(server) 210 | .delete(`/api/v1/movies/${movieObject.id}`) 211 | .end((err, res) => { 212 | // there should be no errors 213 | should.not.exist(err); 214 | // there should be a 200 status code 215 | res.status.should.equal(200); 216 | // the response should be JSON 217 | res.type.should.equal('application/json'); 218 | // the JSON response body should have a 219 | // key-value pair of {"status": "success"} 220 | res.body.status.should.eql('success'); 221 | // the JSON response body should have a 222 | // key-value pair of {"data": 1 movie object} 223 | res.body.data[0].should.include.keys( 224 | 'id', 'name', 'genre', 'rating', 'explicit' 225 | ); 226 | // ensure the movie was in fact deleted 227 | knex('movies').select('*') 228 | .then((updatedMovies) => { 229 | updatedMovies.length.should.eql(lengthBeforeDelete - 1); 230 | done(); 231 | }); 232 | }); 233 | }); 234 | }); 235 | it('should throw an error if the movie does not exist', (done) => { 236 | chai.request(server) 237 | .delete('/api/v1/movies/9999999') 238 | .end((err, res) => { 239 | // there should an error 240 | should.exist(err); 241 | // there should be a 404 status code 242 | res.status.should.equal(404); 243 | // the response should be JSON 244 | res.type.should.equal('application/json'); 245 | // the JSON response body should have a 246 | // key-value pair of {"status": "error"} 247 | res.body.status.should.eql('error'); 248 | // the JSON response body should have a 249 | // key-value pair of {"message": "That movie does not exist."} 250 | res.body.message.should.eql('That movie does not exist.'); 251 | done(); 252 | }); 253 | }); 254 | }); 255 | 256 | }); 257 | --------------------------------------------------------------------------------