diff --git a/.babelrc.karma b/.babelrc.karma index 9083ccf..9c5d378 100644 --- a/.babelrc.karma +++ b/.babelrc.karma @@ -1,5 +1,5 @@ { - "presets": [ "es2015" ], + "presets": ["es2015"], "plugins": [ ["istanbul", { "exclude": [ diff --git a/.eslintrc.json b/.eslintrc.json index 86aed71..c91f86c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,12 +14,21 @@ "sourceType": "commonjs" }, "rules": { + "arrow-spacing": "error", "comma-dangle": "error", "comma-style": ["error", "last"], + "comma-spacing": ["error", { + "before": false, + "after": true + }], "complexity": "warn", "curly": "error", "eol-last": "error", "indent": ["error", 2], + "key-spacing": ["error", { + "beforeColon": false, + "afterColon": true + }], "keyword-spacing": "error", "max-len": ["error", 120], "no-console": "off", @@ -28,11 +37,17 @@ "no-trailing-spaces": "error", "no-var": "error", "no-warning-comments": "warn", + "object-curly-spacing": ["error", "always"], "object-shorthand": "error", "prefer-arrow-callback": "error", "prefer-const": "warn", "semi": "error", + "semi-spacing": ["error", { + "before": false, + "after": true + }], "space-before-blocks": "error", + "template-curly-spacing": ["error", "never"], "quotes": ["error", "single"] } } diff --git a/build_signature_loader.js b/build_signature_loader.js index 8b7a9b4..3270684 100644 --- a/build_signature_loader.js +++ b/build_signature_loader.js @@ -5,12 +5,12 @@ const path = require('path'); const cwd = path.join(__dirname, '.'); const exec = Promise.promisify(require('child_process').exec); -function inspectRepository() { - function revParse(args) { - return exec(`git rev-parse ${args}`, { cwd }) - .then((result) => result.trim()); - } +function revParse(args) { + return exec(`git rev-parse ${args}`, { cwd }) + .then((result) => result.trim()); +} +function inspectRepository() { return Promise.all([ revParse('--abbrev-ref HEAD'), revParse('HEAD') diff --git a/gulpfile.js b/gulpfile.js index 4fd4389..6761584 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -74,6 +74,8 @@ gulp.task('tdd', () => { }); gulp.task('server:test', (done) => { + process.env.NODE_ENV = 'test'; + const istanbul = require('gulp-istanbul'); const mocha = require('gulp-mocha'); @@ -84,7 +86,8 @@ gulp.task('server:test', (done) => { gulp.src(['server/**/*.spec.js'], { read: false }) .pipe(mocha({ ui: 'bdd', - reporter: 'dot' + reporter: 'dot', + timeout: 250 })) .pipe(istanbul.writeReports({ dir: 'artifacts/server/coverage', diff --git a/karma.conf.js b/karma.conf.js index e70709a..4263a3d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -46,7 +46,7 @@ module.exports = function(config) { coverageReporter: { - dir : 'artifacts/client/coverage', + dir: 'artifacts/client/coverage', subdir: (browser) => { return browser.toLowerCase().split(/[ /-]/)[0]; }, diff --git a/package.json b/package.json index ccebd6a..225dbf3 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "devDependencies": { "angular-mocks": "^1.5.8", "babel-core": "^6.18.0", - "babel-loader": "^6.2.5", + "babel-loader": "^6.2.7", "babel-plugin-istanbul": "^2.0.3", "babel-preset-es2015": "^6.18.0", "chai": "^3.5.0", @@ -32,7 +32,7 @@ "eslint-plugin-angular": "^1.4.1", "eslint-plugin-jasmine": "^1.8.1", "eslint-plugin-mocha": "^4.7.0", - "eslint-plugin-promise": "^3.1.0", + "eslint-plugin-promise": "^3.3.0", "eslint-plugin-protractor": "^1.28.0", "extract-text-webpack-plugin": "^1.0.1", "favicons-webpack-plugin": "0.0.7", @@ -63,16 +63,16 @@ "nodemon": "^1.11.0", "null-loader": "^0.1.1", "progress-bar-webpack-plugin": "^1.9.0", - "protractor": "^4.0.9", + "protractor": "^4.0.10", "request": "^2.76.0", "run-sequence": "^1.2.2", "sass-loader": "^4.0.2", "sinon": "^1.17.6", "style-loader": "^0.13.1", - "supertest": "^2.0.0", + "supertest": "^2.0.1", "url-loader": "^0.5.7", - "webdriver-manager": "^10.2.5", - "webpack": "^1.13.2", + "webdriver-manager": "^10.2.6", + "webpack": "^1.13.3", "webpack-combine-loaders": "^2.0.0", "webpack-dev-server": "^1.16.2" }, @@ -85,12 +85,15 @@ "angular-resource": "^1.5.8", "angular-toastr": "^2.1.1", "angular-ui-router": "^1.0.0-beta.3", + "bcrypt": "^0.8.7", "bluebird": "^3.4.6", "body-parser": "^1.15.2", "bootstrap-sass": "^3.3.7", "express": "^4.14.0", "faker": "^3.1.0", "jquery": "^3.1.1", - "lodash": "^4.16.4" + "jsonwebtoken": "^7.1.9", + "lodash": "^4.16.4", + "morgan": "^1.7.0" } } diff --git a/server.js b/server.js index b22c5e1..02b3267 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,7 @@ const app = require('./server/app'); const db = require('./server/db'); -db.seed().then((contacts) => { - console.log(`Database populated with ${contacts.length} contacts`); - +db.seed().then(() => { const port = process.env.PORT || 9090; app.listen(port, () => { console.log('Server is running on port', port); diff --git a/server/api/authentication.js b/server/api/authentication.js new file mode 100644 index 0000000..f129de8 --- /dev/null +++ b/server/api/authentication.js @@ -0,0 +1,41 @@ +const Promise = require('bluebird'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const router = require('express').Router(); +const _ = require('lodash'); + +const config = require('../config'); +const db = require('../db'); + +function checkPassword(password, passwordHash) { + const compare = Promise.promisify(bcrypt.compare); + return compare(password, passwordHash).then((success) => { + if (success) { + return Promise.resolve(true); + } else { + return Promise.reject(true); + } + }); +} + +router.post('/', (req, res) => { + const { email, password } = req.body; + + db.users.findOne({ email }).then((user) => { + const { passwordHash } = user; + + return checkPassword(password, passwordHash).then(() => { + const data = _.pick(user, ['id', 'email']); + + const token = jwt.sign(data, config.secret, { + expiresIn: '7 days' + }); + + res.json({ token }); + }); + }).catch(() => { + res.sendStatus(422); + }); +}); + +module.exports = router; diff --git a/server/api/authentication.spec.js b/server/api/authentication.spec.js new file mode 100644 index 0000000..68ed9f7 --- /dev/null +++ b/server/api/authentication.spec.js @@ -0,0 +1,59 @@ +const expect = require('chai').expect; +const jwt = require('jsonwebtoken'); +const request = require('supertest'); + +const app = require('../app'); +const config = require('../config'); +const db = require('../db'); + +describe('authentication API', () => { + + beforeEach(() => { + return db.seed(); + }); + + describe('POST /api/authentication', () => { + + describe('with valid credentials', () => { + + const email = 'demo@email.com'; + const password = 'password'; + + it('creates a contact', (done) => { + request(app) + .post('/api/authentication') + .send({ email, password }) + .expect(200) + .expect((res) => { + const { token } = res.body; + const payload = jwt.verify(token, config.secret); + + expect(payload).to.have.property('id'); + expect(payload).to.have.property('email', email); + expect(payload).to.not.have.property('passwordHash'); + expect(payload).to.have.property('iat'); + expect(payload).to.have.property('exp'); + }) + .end(done); + }); + + }); + + describe('with invalid credentials', () => { + + const email = 'demo@email.com'; + const password = 'wrong'; + + it('creates a contact', (done) => { + request(app) + .post('/api/authentication') + .send({ email, password }) + .expect(422) + .end(done); + }); + + }); + + }); + +}); diff --git a/server/api/contacts.js b/server/api/contacts.js index 8bb550a..bc4f09d 100644 --- a/server/api/contacts.js +++ b/server/api/contacts.js @@ -1,5 +1,9 @@ const router = require('express').Router(); + const db = require('../db'); +const { requireAuthorization } = require('../middlewares'); + +router.use(requireAuthorization); router.param('id', (req, res, next, id) => { id = parseInt(id); diff --git a/server/api/contacts.spec.js b/server/api/contacts.spec.js index 9d329e9..935884e 100644 --- a/server/api/contacts.spec.js +++ b/server/api/contacts.spec.js @@ -1,26 +1,50 @@ +const _ = require('lodash'); const expect = require('chai').expect; +const jwt = require('jsonwebtoken'); const request = require('supertest'); const app = require('../app'); +const config = require('../config'); const db = require('../db'); -function itRespondsWith404(cb) { - it('responds with 404', (done) => { - cb(request(app)) - .set('Accept', 'application/json') - .expect(404) - .end(done); +describe('contacts API', () => { + + beforeEach(() => { + return db.seed(); }); -} -describe('app', () => { + let token; + // Authenticate beforeEach(() => { - return db.seed(); + return db.users.findOne({ email: 'demo@email.com' }).then((user) => { + const data = _.pick(user, ['id', 'email']); + + token = jwt.sign(data, config.secret, { + expiresIn: '1 minute' + }); + }); }); - afterEach(() => { - return db.drop(); + function itRespondsWith404(cb) { + it('responds with 404', (done) => { + cb(request(app)) + .set('x-access-token', token) + .expect(404) + .end(done); + }); + } + + describe('with invalid token', () => { + + it('responds with `401` error', (done) => { + request(app) + .get('/api/contacts') + .set('x-access-token', 'invalid token') + .expect(401) + .end(done); + }); + }); describe('GET /api/contacts', () => { @@ -28,8 +52,7 @@ describe('app', () => { it('respond with json', (done) => { request(app) .get('/api/contacts') - .set('Accept', 'application/json') - .expect('Content-Type', /json/) + .set('x-access-token', token) .expect(200) .expect((res) => { const { contacts } = res.body; @@ -53,9 +76,8 @@ describe('app', () => { request(app) .post('/api/contacts') - .set('Accept', 'application/json') + .set('x-access-token', token) .send({ firstName, lastName, email }) - .expect('Content-Type', /json/) .expect(200) .expect((res) => { const { body: contact } = res; @@ -76,9 +98,13 @@ describe('app', () => { describe('when an email is not taken', () => { + const email = 'luke@rebel.org'; + it('responds with `{ taken: false }`', (done) => { request(app) - .get('/api/contacts/validate-email?email=luke@rebel.org') + .get('/api/contacts/validate-email') + .query({ email }) + .set('x-access-token', token) .expect(200) .expect((res) => { expect(res.body).to.have.property('taken', false); @@ -98,7 +124,9 @@ describe('app', () => { it('responds with `{ taken: true }`', (done) => { request(app) - .get(`/api/contacts/validate-email?email=${email}`) + .get('/api/contacts/validate-email') + .query({ email }) + .set('x-access-token', token) .expect(200) .expect((res) => { expect(res.body).to.have.property('taken', true); @@ -125,7 +153,9 @@ describe('app', () => { it('responds with `{ taken: false }`', (done) => { request(app) - .get(`/api/contacts/validate-email?id=${id}email=${email}`) + .get('/api/contacts/validate-email') + .query({ id, email }) + .set('x-access-token', token) .expect(200) .expect((res) => { expect(res.body).to.have.property('taken', false); @@ -135,7 +165,9 @@ describe('app', () => { it('responds with `{ taken: false }`', (done) => { request(app) - .get(`/api/contacts/validate-email?id=${id}&email=tarkin@empire.com`) + .get('/api/contacts/validate-email') + .query({ id, email: 'tarkin@empire.com' }) + .set('x-access-token', token) .expect(200) .expect((res) => { expect(res.body).to.have.property('taken', false); @@ -156,6 +188,7 @@ describe('app', () => { it('responds with `{ taken: true }`', (done) => { request(app) .get(`/api/contacts/validate-email?id=${id}&email=${otherEmail}`) + .set('x-access-token', token) .expect(200) .expect((res) => { expect(res.body).to.have.property('taken', true); @@ -176,8 +209,7 @@ describe('app', () => { it('respond with json', (done) => { request(app) .get('/api/contacts/3') - .set('Accept', 'application/json') - .expect('Content-Type', /json/) + .set('x-access-token', token) .expect(200) .expect((res) => { const { body: contact } = res; @@ -217,9 +249,8 @@ describe('app', () => { it('updates the contact', (done) => { request(app) .put(`/api/contacts/${id}`) - .set('Accept', 'application/json') + .set('x-access-token', token) .send({ firstName: 'Luke' }) - .expect('Content-Type', /json/) .expect(200) .expect((res) => { const { body: contact } = res; @@ -260,7 +291,7 @@ describe('app', () => { request(app) .delete(`/api/contacts/${id}`) - .set('Accept', 'application/json') + .set('x-access-token', token) .expect(200) .end(() => { db.contacts.findOne({ id }).catch(() => { diff --git a/server/api/seed.spec.js b/server/api/seed.spec.js index 8bb7373..c9b0b24 100644 --- a/server/api/seed.spec.js +++ b/server/api/seed.spec.js @@ -4,7 +4,7 @@ const request = require('supertest'); const app = require('../app'); const db = require('../db'); -describe('app', () => { +describe('seed API', () => { describe('POST /api/seed', () => { diff --git a/server/app.js b/server/app.js index 0cbea13..8950bbc 100644 --- a/server/app.js +++ b/server/app.js @@ -1,15 +1,24 @@ const express = require('express'); +const bodyParser = require('body-parser'); +const logger = require('morgan'); const path = require('path'); +const config = require('./config'); const app = express(); app.use(express.static(path.join(__dirname, '..', 'build'))); -app.use(require('body-parser').json()); +app.use(bodyParser.json()); -if (process.env.NODE_ENV !== 'production') { +// TODO use `app.get('env')` +if (config.env !== 'test') { + app.use(logger('short')); +} + +if (config.env !== 'production') { app.use('/api/seed', require('./api/seed')); } +app.use('/api/authentication', require('./api/authentication')); app.use('/api/contacts', require('./api/contacts')); app.all('*', (req, res) => { diff --git a/server/app.spec.js b/server/app.spec.js deleted file mode 100644 index cc04de6..0000000 --- a/server/app.spec.js +++ /dev/null @@ -1,288 +0,0 @@ -const expect = require('chai').expect; -const request = require('supertest'); - -const app = require('./app'); -const db = require('./db'); - -describe('app', () => { - - beforeEach(() => { - return db.seed(); - }); - - afterEach(() => { - return db.drop(); - }); - - describe('GET /api/contacts', () => { - - it('respond with json', (done) => { - request(app) - .get('/api/contacts') - .set('Accept', 'application/json') - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - const { contacts } = res.body; - expect(contacts).to.have.length(20); - - expect(contacts[0]).to.have.property('firstName', 'Wallace'); - expect(contacts[0]).to.have.property('lastName', 'Rath'); - expect(contacts[0]).to.have.property('email', 'Tessie_Carter16@gmail.com'); - }) - .end(done); - }); - - }); - - describe('GET /api/contacts/validate-email', () => { - - describe('for non persisted contact', () => { - - describe('when an email is not taken', () => { - - it('responds with `{ taken: false }`', (done) => { - request(app) - .get('/api/contacts/validate-email?email=luke@rebel.org') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('taken', false); - }) - .end(done); - }); - - }); - - describe('when an email is already taken', () => { - - const email = 'anakin@republic.org'; - - beforeEach(() => { - return db.contacts.insertOne({ email }); - }); - - it('responds with `{ taken: true }`', (done) => { - request(app) - .get(`/api/contacts/validate-email?email=${email}`) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('taken', true); - }) - .end(done); - }); - - }); - - }); - - describe('for persisted contact', () => { - - let id; - const email = 'vader@empire.com'; - - beforeEach(() => { - return db.contacts.insertOne({ email }).then((contact) => { - id = contact.id; - }); - }); - - describe('when an email is not taken', () => { - - it('responds with `{ taken: false }`', (done) => { - request(app) - .get(`/api/contacts/validate-email?id=${id}email=${email}`) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('taken', false); - }) - .end(done); - }); - - it('responds with `{ taken: false }`', (done) => { - request(app) - .get(`/api/contacts/validate-email?id=${id}&email=tarkin@empire.com`) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('taken', false); - }) - .end(done); - }); - - }); - - describe('when an email is taken', () => { - - const otherEmail = 'tarkin@empire.com'; - - beforeEach(() => { - return db.contacts.insertOne({ email: otherEmail }); - }); - - it('responds with `{ taken: true }`', (done) => { - request(app) - .get(`/api/contacts/validate-email?id=${id}&email=${otherEmail}`) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('taken', true); - }) - .end(done); - }); - - }); - - }); - - }); - - describe('POST /api/contacts', () => { - - it('creates a contact', (done) => { - const firstName = 'Luke'; - const lastName = 'Skywalker'; - const email = 'luke@rebel.org'; - - request(app) - .post('/api/contacts') - .set('Accept', 'application/json') - .send({ firstName, lastName, email }) - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - const { body: contact } = res; - - expect(contact).to.have.property('id', 21); - expect(contact).to.have.property('firstName', firstName); - expect(contact).to.have.property('lastName', lastName); - expect(contact).to.have.property('email', email); - }) - .end(done); - }); - - }); - - describe('GET /api/contacts/:id', () => { - - describe('when a contact can be found', () => { - - it('respond with json', (done) => { - request(app) - .get('/api/contacts/3') - .set('Accept', 'application/json') - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - const { body: contact } = res; - - expect(contact).to.have.property('id', 3); - expect(contact).to.have.property('firstName', 'Caterina'); - expect(contact).to.have.property('lastName', 'Hackett'); - expect(contact).to.have.property('email', 'Destin.Kassulke80@hotmail.com'); - }) - .end(done); - }); - - }); - - describe('when a contact cannot be found', () => { - - it('responds with 404', (done) => { - request(app) - .get('/api/contacts/21') - .set('Accept', 'application/json') - .expect(404) - .end(done); - }); - - }); - - }); - - describe('PUT /api/contacts/:id', () => { - - describe('when a contact can be found', () => { - - let id; - - beforeEach(() => { - return db.contacts.insertOne({ firstName: 'Anakin', lastName: 'Skywalker' }).then((contact) => { - id = contact.id; - }); - }); - - it('updates the contact', (done) => { - request(app) - .put(`/api/contacts/${id}`) - .set('Accept', 'application/json') - .send({ firstName: 'Luke' }) - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - const { body: contact } = res; - - expect(contact).to.have.property('id', id); - expect(contact).to.have.property('firstName', 'Luke'); - expect(contact).to.have.property('lastName', 'Skywalker'); - }) - .end(done); - }); - - }); - - describe('when a contact cannot be found', () => { - - it('responds with 404', (done) => { - request(app) - .put('/api/contacts/21') - .send({}) - .set('Accept', 'application/json') - .expect(404) - .end(done); - }); - - }); - - }); - - describe('DELETE /api/contacts/:id', () => { - - describe('when a contact can be found', () => { - - let contact; - - beforeEach(() => { - return db.contacts.insertOne({ firstName: 'Luke' }).then((createdContact) => { - contact = createdContact; - }); - }); - - it('deletes the contact', (done) => { - const { id } = contact; - - request(app) - .delete(`/api/contacts/${id}`) - .set('Accept', 'application/json') - .expect(200) - .end(() => { - db.contacts.findOne({ id }).catch(() => { - done(); - }); - }); - }); - - }); - - describe('when a contact cannot be found', () => { - - it('responds with 404', (done) => { - request(app) - .delete('/api/contacts/21') - .set('Accept', 'application/json') - .expect(404) - .end(done); - }); - - }); - - }); - -}); diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..e501061 --- /dev/null +++ b/server/config.js @@ -0,0 +1,6 @@ +const { env } = process; + +module.exports = Object.freeze({ + env: env['NODE_ENV'] || 'development', + secret: env['SECRET'] || 'I am a vegan' +}); diff --git a/server/db.js b/server/db.js index b12aec2..c5618f0 100644 --- a/server/db.js +++ b/server/db.js @@ -1,43 +1,68 @@ const Promise = require('bluebird'); +const bcrypt = require('bcrypt'); const faker = require('faker'); const _ = require('lodash'); -const Collection = require('./collection'); +const Collection = require('./db/collection'); class Db { constructor() { + this.users = new Collection(); this.contacts = new Collection(); } - seed(n = 20) { + seed() { faker.seed(667); return this.drop().then(() => { - return Promise.all(_.times(n, () => { - const address = { - country: faker.address.country(), - town: faker.address.city(), - zipCode: faker.address.zipCode(), - street: faker.address.streetAddress(), - location: { - lon: faker.address.longitude(), - lat: faker.address.latitude() - } - }; - - return this.contacts.insertOne({ - favourite: faker.random.boolean(), - firstName: faker.name.firstName(), - lastName: faker.name.lastName(), - email: faker.internet.email(), - phone: faker.phone.phoneNumber(), - address - }); - })); + return Promise.all([ + this._seedUsers(), + this._seedContacts() + ]); }); } + _seedUsers() { + const hashPassword = (password) => { + const salt = bcrypt.genSaltSync(10); + return Promise.promisify(bcrypt.hash)(password, salt); + }; + + return hashPassword('password').then((passwordHash) => { + return this.users.insertOne({ + firstName: 'Admin', + lastName: 'Adminowsky', + email: 'demo@email.com', + passwordHash + }); + }); + } + + _seedContacts(n = 20) { + return Promise.all(_.times(n, () => { + const address = { + country: faker.address.country(), + town: faker.address.city(), + zipCode: faker.address.zipCode(), + street: faker.address.streetAddress(), + location: { + lon: faker.address.longitude(), + lat: faker.address.latitude() + } + }; + + return this.contacts.insertOne({ + favourite: faker.random.boolean(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email(), + phone: faker.phone.phoneNumber(), + address + }); + })); + } + drop() { return Promise.all([ this.contacts.drop() diff --git a/server/db.spec.js b/server/db.spec.js index 99db570..bb90dbc 100644 --- a/server/db.spec.js +++ b/server/db.spec.js @@ -5,10 +5,12 @@ describe('db', () => { describe('seed', () => { - it('seed the fake database', () => { - return db.seed().then(() => { - return db.contacts.find(); - }).then((contacts) => { + beforeEach(() => { + return db.seed(); + }); + + it('seeds contacts', () => { + return db.contacts.find().then((contacts) => { expect(contacts).to.have.length(20); expect(contacts[0]).to.have.property('id', 1); @@ -17,15 +19,23 @@ describe('db', () => { }); }); + it('seeds users', () => { + return db.users.findOne({ email: 'demo@email.com' }).then((user) => { + expect(user).to.have.property('id'); + expect(user).to.have.property('email', 'demo@email.com'); + expect(user).to.have.property('passwordHash'); + }); + }); + }); - describe('.drop', () =>{ + describe('.drop', () => { - beforeEach(() =>{ + beforeEach(() => { return db.seed(); }); - it('removes all collections', () =>{ + it('removes all collections', () => { return db.drop().then(() => { return db.contacts.find(); }) diff --git a/server/collection.js b/server/db/collection.js similarity index 100% rename from server/collection.js rename to server/db/collection.js diff --git a/server/collection.spec.js b/server/db/collection.spec.js similarity index 100% rename from server/collection.spec.js rename to server/db/collection.spec.js diff --git a/server/middlewares.js b/server/middlewares.js new file mode 100644 index 0000000..d00bebf --- /dev/null +++ b/server/middlewares.js @@ -0,0 +1,25 @@ +const Promise = require('bluebird'); +const jwt = require('jsonwebtoken'); + +const config = require('./config'); +const db = require('./db'); + +function verityToken(token) { + return Promise.promisify(jwt.verify)(token, config.secret) + .then((decoded) => { + const { id, email } = decoded; + return db.users.findOne({ id, email }); + }); +} + +// TODO write decent tests for this middleware +module.exports.requireAuthorization = function(req, res, next) { + const token = req.headers['x-access-token']; + + verityToken(token).then((user) => { + req.currentUser = user; + next(); + }).catch(() => { + res.status(401).send({}); + }); +}; diff --git a/src/app/about/about.module.js b/src/app/about/about.module.js index 0bf8abe..3859b81 100644 --- a/src/app/about/about.module.js +++ b/src/app/about/about.module.js @@ -1,6 +1,10 @@ +import appCommonsModule from '../commons/commons.module'; import { states } from './about.config'; import uiRouter from 'angular-ui-router'; -export default angular.module('app.about', [uiRouter]) +export default angular.module('app.about', [ + uiRouter, + appCommonsModule +]) .config(states) .name; diff --git a/src/app/about/index/index.state.js b/src/app/about/index/index.state.js index 361b00c..37dc692 100644 --- a/src/app/about/index/index.state.js +++ b/src/app/about/index/index.state.js @@ -3,6 +3,8 @@ import template from './index.state.html'; export const name = 'about'; export default { + parent: 'app', + name, url: '/about', template diff --git a/src/app/about/index/index.state.spec.js b/src/app/about/index/index.state.spec.js index 556e480..e248d32 100644 --- a/src/app/about/index/index.state.spec.js +++ b/src/app/about/index/index.state.spec.js @@ -4,9 +4,7 @@ import { name } from './index.state'; describe(`module: ${appAboutModule}`, () => { - beforeEach(() => { - angular.mock.module(appAboutModule); - }); + beforeEach(angular.mock.module(appAboutModule)); describe(`state: ${name}`, () => { diff --git a/src/app/app.module.js b/src/app/app.module.js index df200f3..3b51186 100644 --- a/src/app/app.module.js +++ b/src/app/app.module.js @@ -1,7 +1,9 @@ import { anchorScroll, html5Mode, notFoundState, router } from './app.config'; +import _ from 'lodash'; import angularAnimate from 'angular-animate'; import angularLoadingBar from 'angular-loading-bar'; import appAboutModule from './about/about.module'; +import appAuthenticationModule from './authentication/authentication.module'; import appCommonsModule from './commons/commons.module'; import appContactsModule from './contacts/contacts.module'; import appHomeModule from './home/home.module'; @@ -28,6 +30,7 @@ export default angular.module('app', [ angularLoadingBar, appCommonsModule, + appAuthenticationModule, appHomeModule, appContactsModule, appAboutModule @@ -36,6 +39,30 @@ export default angular.module('app', [ .config(router) .config(anchorScroll) .config(notFoundState) + .run(stateErrorsHandler) .run(logBuildSignature) + + .run(($state, $transitions, auth) => { + const publicState = (state) => { + return _.get(state, 'data.publicState'); + }; + + const nonPublicState = (state) => { + return !_.get(state, 'data.publicState'); + }; + + $transitions.onBefore({ to: publicState }, () => { + if (auth.isAuthenticated()) { + return $state.target('home'); + } + }); + + $transitions.onBefore({ to: nonPublicState }, () => { + if (!auth.isAuthenticated()) { + return $state.target('login'); + } + }); + }) + .name; diff --git a/src/app/app.module.spec.js b/src/app/app.module.spec.js index 29c4e4b..ccdb9ce 100644 --- a/src/app/app.module.spec.js +++ b/src/app/app.module.spec.js @@ -4,12 +4,18 @@ import sinon from 'sinon'; describe(`module: ${appModule}`, () => { - beforeEach(() => { - angular.mock.module(appModule); - }); + beforeEach(angular.mock.module(appModule, ($provide) => { + $provide.value('auth', { + isAuthenticated: sinon.stub() + }); + })); describe('navigating to unknown url', () => { + beforeEach(inject((auth) => { + auth.isAuthenticated.returns(true); + })); + it('changes the state to `404`', inject(($location, $rootScope, $state) => { $location.url('/unknown/url'); $rootScope.$digest(); @@ -38,4 +44,72 @@ describe(`module: ${appModule}`, () => { }); + describe('$transitions', () => { + + describe('to public state', () => { + + describe('when authenticated', () => { + + beforeEach(inject((auth) => { + auth.isAuthenticated.returns(true); + })); + + it('redirects to `home`', inject(($rootScope, $state) => { + $state.go('login'); + $rootScope.$digest(); + expect($state.current.name).to.eq('home'); + })); + + }); + + describe('when not authenticated', () => { + + beforeEach(inject((auth) => { + auth.isAuthenticated.returns(false); + })); + + it('does nothing', inject(($rootScope, $state) => { + $state.go('login'); + $rootScope.$digest(); + expect($state.current.name).to.eq('login'); + })); + + }); + + }); + + describe('to the protected state', () => { + + describe('when authenticated', () => { + + beforeEach(inject((auth) => { + auth.isAuthenticated.returns(true); + })); + + it('does nothing', inject(($rootScope, $state) => { + $state.go('home'); + $rootScope.$digest(); + expect($state.current.name).to.eq('home'); + })); + + }); + + describe('when not authenticated', () => { + + beforeEach(inject((auth) => { + auth.isAuthenticated.returns(false); + })); + + it('redirects to `login`', inject(($rootScope, $state) => { + $state.go('home'); + $rootScope.$digest(); + expect($state.current.name).to.eq('login'); + })); + + }); + + }); + + }); + }); diff --git a/src/app/authentication/authentication.config.js b/src/app/authentication/authentication.config.js new file mode 100644 index 0000000..5cf50c8 --- /dev/null +++ b/src/app/authentication/authentication.config.js @@ -0,0 +1,8 @@ +import loginState from './login/login.state'; + +export function states($stateProvider) { + 'ngInject'; + + $stateProvider + .state(loginState); +} diff --git a/src/app/authentication/authentication.module.js b/src/app/authentication/authentication.module.js new file mode 100644 index 0000000..542f395 --- /dev/null +++ b/src/app/authentication/authentication.module.js @@ -0,0 +1,10 @@ +import appCommonsModule from '../commons/commons.module'; +import { states } from './authentication.config'; +import uiRouter from 'angular-ui-router'; + +export default angular.module('app.authentication', [ + uiRouter, + appCommonsModule +]) + .config(states) + .name; diff --git a/src/app/authentication/login/login.controller.js b/src/app/authentication/login/login.controller.js new file mode 100644 index 0000000..41536d2 --- /dev/null +++ b/src/app/authentication/login/login.controller.js @@ -0,0 +1,21 @@ +export default class { + + constructor($state, auth) { + 'ngInject'; + + this.$state = $state; + this.auth = auth; + + this.credentials = { + email: 'demo@email.com', + password: 'password' + }; + } + + login() { + return this.auth.authenticate(this.credentials).then(() => { + return this.$state.go('home'); + }); + } + +} diff --git a/src/app/authentication/login/login.controller.spec.js b/src/app/authentication/login/login.controller.spec.js new file mode 100644 index 0000000..04cfc96 --- /dev/null +++ b/src/app/authentication/login/login.controller.spec.js @@ -0,0 +1,72 @@ +import appContactsModule from '../authentication.module'; +import { expect } from 'chai'; +import { name } from './login.state'; +import sinon from 'sinon'; + +describe(`module: ${appContactsModule}`, () => { + + beforeEach(() => { + angular.mock.module(appContactsModule); + }); + + describe(`controller: ${name}`, () => { + + let ctrl; + + beforeEach(inject(($controller, $state, auth) => { + const Controller = $state.get(name).controller; + + ctrl = $controller(Controller, { + $state: sinon.stub($state), + auth: sinon.stub(auth) + }); + })); + + it('is defined', () => { + expect(ctrl).to.not.be.undefined; + }); + + it('has predefined credentials', () => { + expect(ctrl.credentials).to.have.property('email'); + expect(ctrl.credentials).to.have.property('password'); + }); + + describe('.login', () => { + + describe('on success', () => { + + beforeEach(inject(($q) => { + ctrl.auth.authenticate.returns($q.resolve()); + })); + + it('redirects to the `home` page', inject(($rootScope) => { + ctrl.login(); + $rootScope.$digest(); + + expect(ctrl.auth.authenticate.calledWith('demo@email.com', 'password')) + .to.be.true; + expect(ctrl.$state.go.calledWith('home')).to.be.true; + })); + + }); + + describe('on error', () => { + + beforeEach(inject(($q) => { + ctrl.auth.authenticate.returns($q.reject()); + })); + + it('does not redirect', inject(($rootScope) => { + ctrl.login(); + $rootScope.$digest(); + + expect(ctrl.$state.go.calledWith('home')).to.be.false; + })); + + }); + + }); + + }); + +}); diff --git a/src/app/authentication/login/login.state.html b/src/app/authentication/login/login.state.html new file mode 100644 index 0000000..136094e --- /dev/null +++ b/src/app/authentication/login/login.state.html @@ -0,0 +1,27 @@ +