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 @@ +
+
+
+

+ Please login to the demo app +

+ +
+
+ + +
+ +
+ + +
+ + +
+
+
+
diff --git a/src/app/authentication/login/login.state.js b/src/app/authentication/login/login.state.js new file mode 100644 index 0000000..6ca1bee --- /dev/null +++ b/src/app/authentication/login/login.state.js @@ -0,0 +1,16 @@ +import controller from './login.controller'; +import template from './login.state.html'; + +export const name = 'login'; + +export default { + name, + url: '/login', + template, + controller, + controllerAs: 'ctrl', + + data: { + publicState: true + } +}; diff --git a/src/app/authentication/login/login.state.spec.js b/src/app/authentication/login/login.state.spec.js new file mode 100644 index 0000000..8862178 --- /dev/null +++ b/src/app/authentication/login/login.state.spec.js @@ -0,0 +1,23 @@ +import appAuthenticationModule from '../authentication.module'; +import { expect } from 'chai'; +import { name } from './login.state'; + +describe(`module: ${appAuthenticationModule}`, () => { + + beforeEach(angular.mock.module(appAuthenticationModule)); + + describe(`state: ${name}`, () => { + + let state; + + beforeEach(inject(($state) => { + state = $state.get(name); + })); + + it('has valid url', inject(($state) => { + expect($state.href(state)).to.eq('#/login'); + })); + + }); + +}); diff --git a/src/app/commons/app/app.state.html b/src/app/commons/app/app.state.html new file mode 100644 index 0000000..bcb87c9 --- /dev/null +++ b/src/app/commons/app/app.state.html @@ -0,0 +1,8 @@ + + +
+
+
+
+ + diff --git a/src/app/commons/app/app.state.js b/src/app/commons/app/app.state.js new file mode 100644 index 0000000..db6560f --- /dev/null +++ b/src/app/commons/app/app.state.js @@ -0,0 +1,14 @@ +import template from './app.state.html'; + +export const name = 'app'; + +// TODO find better place / solution / research 1.0.0 API +export default { + name, + template, + abstract: true, + + data: { + publicState: false + } +}; diff --git a/src/app/commons/commons.module.js b/src/app/commons/commons.module.js index e54f33f..39f399a 100644 --- a/src/app/commons/commons.module.js +++ b/src/app/commons/commons.module.js @@ -1,24 +1,32 @@ import 'angular-breadcrumb'; -import breadcrumbTemplate from './breadcrumb.template.html'; +import accessTokenInterceptor from './config/http-interceptors/access-token-interceptor'; +import appState from './app/app.state'; +import breadcrumb from './config/breadcrumb/breadcrumb.config'; import components from './components/components.config'; import filters from './filters/filters.config'; import services from './services/services.config'; import toastr from 'angular-toastr'; +import uiRouter from 'angular-ui-router'; +import unauthorizedAccessInterceptor from './config/http-interceptors/unauthorized-access-interceptor'; export default angular.module('app.commons', [ + uiRouter, toastr, 'ncy-angular-breadcrumb' ]) + .service('localStorage', ($window) => $window.localStorage) + .config(components) .config(services) .config(filters) - .config(($breadcrumbProvider) => { - 'ngInject'; - - $breadcrumbProvider.setOptions({ - template: breadcrumbTemplate - }); + .config(($stateProvider) => { + $stateProvider + .state(appState); }) + .config(breadcrumb) + .config(accessTokenInterceptor) + .config(unauthorizedAccessInterceptor) + .name; diff --git a/src/app/commons/components/navigation/navigation.component.html b/src/app/commons/components/navigation/navigation.component.html index 84affee..b93a718 100644 --- a/src/app/commons/components/navigation/navigation.component.html +++ b/src/app/commons/components/navigation/navigation.component.html @@ -17,6 +17,12 @@ About + + diff --git a/src/app/commons/components/navigation/navigation.component.js b/src/app/commons/components/navigation/navigation.component.js index 6ddb0cd..c3f005b 100644 --- a/src/app/commons/components/navigation/navigation.component.js +++ b/src/app/commons/components/navigation/navigation.component.js @@ -1,5 +1,22 @@ import template from './navigation.component.html'; +class Controller { + + constructor($state, auth) { + 'ngInject'; + + this.$state = $state; + this.auth = auth; + } + + logout() { + this.auth.logout(); + this.$state.go('login'); + } + +} + export default { - template + template, + controller: Controller }; diff --git a/src/app/commons/components/navigation/navigation.component.spec.js b/src/app/commons/components/navigation/navigation.component.spec.js new file mode 100644 index 0000000..6b31594 --- /dev/null +++ b/src/app/commons/components/navigation/navigation.component.spec.js @@ -0,0 +1,55 @@ +import appCommonsModule from '../../commons.module'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe(`module: ${appCommonsModule}`, () => { + + beforeEach(angular.mock.module(appCommonsModule)); + + describe('component: favouriteButton', () => { + + let element, scope; + + beforeEach(inject(($compile, $rootScope) => { + scope = $rootScope.$new(); + + element = angular.element(` + + `); + + $compile(element)(scope); + $rootScope.$digest(); + })); + + }); + + describe('controller: appNavigation', () => { + + let ctrl; + + beforeEach(inject(($componentController) => { + ctrl = $componentController('appNavigation', { + $state: { go: sinon.stub() }, + auth: { logout: sinon.stub() } + }); + })); + + describe('.logout', () => { + + beforeEach(() => { + ctrl.logout(); + }); + + it('log out a user', () => { + expect(ctrl.auth.logout.called).to.be.true; + }); + + it('redirects to the `login` page', () => { + expect(ctrl.$state.go.calledWith('login')).to.be.true; + }); + + }); + + }); + +}); diff --git a/src/app/commons/components/unique-email/unique-email.directive.spec.js b/src/app/commons/components/unique-email/unique-email.directive.spec.js index 1905945..2246a0f 100644 --- a/src/app/commons/components/unique-email/unique-email.directive.spec.js +++ b/src/app/commons/components/unique-email/unique-email.directive.spec.js @@ -3,9 +3,7 @@ import { expect } from 'chai'; describe(`module: ${appCommonsModule}`, () => { - beforeEach(() => { - angular.mock.module(appCommonsModule); - }); + beforeEach(angular.mock.module(appCommonsModule)); describe('directive: appUniqueEmail', () => { diff --git a/src/app/commons/config/breadcrumb/breadcrumb.config.js b/src/app/commons/config/breadcrumb/breadcrumb.config.js new file mode 100644 index 0000000..56c8dd4 --- /dev/null +++ b/src/app/commons/config/breadcrumb/breadcrumb.config.js @@ -0,0 +1,9 @@ +import template from './breadcrumb.template.html'; + +export default function($breadcrumbProvider) { + 'ngInject'; + + $breadcrumbProvider.setOptions({ + template + }); +} diff --git a/src/app/commons/breadcrumb.template.html b/src/app/commons/config/breadcrumb/breadcrumb.template.html similarity index 100% rename from src/app/commons/breadcrumb.template.html rename to src/app/commons/config/breadcrumb/breadcrumb.template.html diff --git a/src/app/commons/config/http-interceptors/access-token-interceptor.js b/src/app/commons/config/http-interceptors/access-token-interceptor.js new file mode 100644 index 0000000..2d54c1b --- /dev/null +++ b/src/app/commons/config/http-interceptors/access-token-interceptor.js @@ -0,0 +1,20 @@ +export default function($httpProvider) { + 'ngInject'; + + $httpProvider.interceptors.push(($injector, $q, session) => { + return { + request(config) { + const token = session.getToken(); + + if (token) { + angular.extend(config.headers, { + 'x-access-token': token + }); + } + + return config; + } + }; + }); + +} diff --git a/src/app/commons/config/http-interceptors/access-token-interceptor.spec.js b/src/app/commons/config/http-interceptors/access-token-interceptor.spec.js new file mode 100644 index 0000000..d17a90f --- /dev/null +++ b/src/app/commons/config/http-interceptors/access-token-interceptor.spec.js @@ -0,0 +1,67 @@ +import appCommonsModule from '../../commons.module'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe(`module: ${appCommonsModule}`, () => { + + beforeEach(angular.mock.module(appCommonsModule, ($provide) => { + $provide.value('session', { + getToken: sinon.stub(), + removeToken: sinon.stub() + }); + })); + + let $http, $httpBackend; + + beforeEach(inject(($injector) => { + $http = $injector.get('$http'); + $httpBackend = $injector.get('$httpBackend'); + })); + + describe('access token interceptor', () => { + + describe('when the token is set', () => { + + beforeEach(inject((session) => { + session.getToken.returns('the token'); + })); + + it('sends http with valid header', (done) => { + $httpBackend + .expectGET('/api/test', (headers) => { + expect(headers).to.have.property('x-access-token', 'the token'); + done(); + }) + .respond(200); + + $http.get('/api/test'); + + $httpBackend.flush(); + }); + + }); + + describe('when the token is not set', () => { + + beforeEach(inject((session) => { + session.getToken.returns(undefined); + })); + + it('sends http with valid header', (done) => { + $httpBackend + .expectGET('/api/test', (headers) => { + expect(headers).to.not.have.property('x-access-token'); + done(); + }) + .respond(200); + + $http.get('/api/test'); + + $httpBackend.flush(); + }); + + }); + + }); + +}); diff --git a/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.js b/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.js new file mode 100644 index 0000000..eaa4a09 --- /dev/null +++ b/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.js @@ -0,0 +1,23 @@ +export default function($httpProvider) { + 'ngInject'; + + const HTTP_UNAUTHORIZED = 401; + + $httpProvider.interceptors.push(($injector, $q, $state, session) => { + return { + responseError(rejection) { + if (rejection.status === HTTP_UNAUTHORIZED) { + // Workaround for circular dependency (toastr <- $http) + const toastr = $injector.get('toastr'); + toastr.error('Unauthorized'); + + session.removeToken(); + $state.go('login'); + } + + return $q.reject(rejection); + } + }; + }); + +} diff --git a/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.spec.js b/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.spec.js new file mode 100644 index 0000000..56fc7ae --- /dev/null +++ b/src/app/commons/config/http-interceptors/unauthorized-access-interceptor.spec.js @@ -0,0 +1,69 @@ +import appCommonsModule from '../../commons.module'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe(`module: ${appCommonsModule}`, () => { + + beforeEach(angular.mock.module(appCommonsModule, ($provide) => { + $provide.value('session', { + getToken: sinon.stub(), + removeToken: sinon.stub() + }); + })); + + let $http, $httpBackend; + + beforeEach(inject(($injector) => { + $http = $injector.get('$http'); + $httpBackend = $injector.get('$httpBackend'); + })); + + describe('unauthorized access interceptor', () => { + + describe('on `401` http error', () => { + + beforeEach(inject((toastr) => { + // Given + sinon.stub(toastr, 'error'); + + $httpBackend + .expectGET('/api/test') + .respond(401); + + // When + $http.get('/api/test'); + $httpBackend.flush(); + })); + + it('removes a token from the session', inject((session) => { + expect(session.removeToken.called).to.be.true; + })); + + it('displays an error', inject((toastr) => { + expect(toastr.error.calledWith('Unauthorized')).to.be.true; + })); + + }); + + describe('on non `401` http error', () => { + + beforeEach(() => { + // Given + $httpBackend + .expectGET('/api/test') + .respond(422); + + // When + $http.get('/api/test'); + $httpBackend.flush(); + }); + + it('does not remove a token from the session', inject((session) => { + expect(session.removeToken.called).to.be.false; + })); + + }); + + }); + +}); diff --git a/src/app/commons/filters/checkmark/checkmark.filter.spec.js b/src/app/commons/filters/checkmark/checkmark.filter.spec.js index 188efb6..7934741 100644 --- a/src/app/commons/filters/checkmark/checkmark.filter.spec.js +++ b/src/app/commons/filters/checkmark/checkmark.filter.spec.js @@ -3,9 +3,7 @@ import { expect } from 'chai'; describe(`module: ${appCommonsModule}`, () => { - beforeEach(() => { - angular.mock.module(appCommonsModule); - }); + beforeEach(angular.mock.module(appCommonsModule)); describe('filter: appCheckmark', () => { diff --git a/src/app/commons/services/alert/alert.service.spec.js b/src/app/commons/services/alert/alert.service.spec.js index 78ab875..451dd2b 100644 --- a/src/app/commons/services/alert/alert.service.spec.js +++ b/src/app/commons/services/alert/alert.service.spec.js @@ -4,11 +4,9 @@ import sinon from 'sinon'; describe(`module: ${appCommonsModule}`, () => { - beforeEach(() => { - angular.mock.module(appCommonsModule, ($provide) => { - $provide.value('$window', { alert: sinon.stub() }); - }); - }); + beforeEach(angular.mock.module(appCommonsModule, ($provide) => { + $provide.value('$window', { alert: sinon.stub() }); + })); describe('service: alert', () => { diff --git a/src/app/commons/services/auth/auth.service.js b/src/app/commons/services/auth/auth.service.js new file mode 100644 index 0000000..2f9e417 --- /dev/null +++ b/src/app/commons/services/auth/auth.service.js @@ -0,0 +1,23 @@ +export default function($http, session) { + 'ngInject'; + + return { + + isAuthenticated() { + return Boolean(session.getToken()); + }, + + authenticate({ email, password }) { + return $http.post('/api/authentication', { email, password }).then((response) => { + const { token } = response.data; + session.setToken(token); + }); + }, + + logout() { + session.removeToken(); + } + + }; + +} diff --git a/src/app/commons/services/auth/auth.service.spec.js b/src/app/commons/services/auth/auth.service.spec.js new file mode 100644 index 0000000..a9de4e3 --- /dev/null +++ b/src/app/commons/services/auth/auth.service.spec.js @@ -0,0 +1,119 @@ +import appCommonsModule from '../../commons.module'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe(`module: ${appCommonsModule}`, () => { + + beforeEach(angular.mock.module(appCommonsModule, ($provide) => { + $provide.decorator('session', ($delegate) => { + 'ngInject'; + return sinon.stub($delegate); + }); + })); + + describe('service: auth', () => { + + let auth; + + beforeEach(inject(($injector) => { + auth = $injector.get('auth'); + })); + + describe('.isAuthenticated', () => { + + describe('when the token is present', () => { + + beforeEach(inject((session) => { + session.getToken.returns('the token'); + })); + + it('returns true', () => { + expect(auth.isAuthenticated()).to.be.true; + }); + + }); + + describe('when the token is not present', () => { + + beforeEach(inject((session) => { + session.getToken.returns(null); + })); + + it('returns false', () => { + expect(auth.isAuthenticated()).to.be.false; + }); + + }); + + }); + + describe('.authenticate', () => { + + const credentials = { + email: 'test@email.com', + password: 'password' + }; + + let request; + + beforeEach(inject(($httpBackend) => { + request = $httpBackend.expectPOST('/api/authentication', credentials); + })); + + it('returns a promise', () => { + expect(auth.authenticate(credentials)).to.be.a.promise; + }); + + describe('on success', () => { + + beforeEach(() => { + request.respond(200, { token: 'the token' }); + }); + + it('stores the token in the session', inject(($httpBackend, session) => { + // When + auth.authenticate(credentials); + $httpBackend.flush(); + + // Then + expect(session.setToken.calledWith('the token')) + .to.be.true; + })); + + }); + + describe('on error', () => { + + beforeEach(() => { + request.respond(422); + }); + + it('does not store the token in the session', inject(($httpBackend, session) => { + // When + auth.authenticate(credentials); + $httpBackend.flush(); + + // Then + expect(session.setToken.calledWith('the token')) + .to.be.false; + })); + + }); + + }); + + describe('.logout', () => { + + it('removes a token from the session', inject((session) => { + // When + auth.logout(); + + // Then + expect(session.removeToken.called).to.be.true; + })); + + }); + + }); + +}); diff --git a/src/app/commons/services/confirm/confirm.service.spec.js b/src/app/commons/services/confirm/confirm.service.spec.js index 8b626af..41a2e18 100644 --- a/src/app/commons/services/confirm/confirm.service.spec.js +++ b/src/app/commons/services/confirm/confirm.service.spec.js @@ -4,11 +4,9 @@ import sinon from 'sinon'; describe(`module: ${appCommonsModule}`, () => { - beforeEach(() => { - angular.mock.module(appCommonsModule, ($provide) => { - $provide.value('$window', { confirm: sinon.stub() }); - }); - }); + beforeEach(angular.mock.module(appCommonsModule, ($provide) => { + $provide.value('$window', { confirm: sinon.stub() }); + })); describe('service: confirm', () => { diff --git a/src/app/commons/services/services.config.js b/src/app/commons/services/services.config.js index 98ad361..599c5c3 100644 --- a/src/app/commons/services/services.config.js +++ b/src/app/commons/services/services.config.js @@ -1,9 +1,13 @@ import alert from './alert/alert.service'; +import auth from './auth/auth.service'; import confirm from './confirm/confirm.service'; +import session from './session/session.service'; export default function($provide) { 'ngInject'; $provide.service('alert', alert); + $provide.service('auth', auth); $provide.service('confirm', confirm); + $provide.service('session', session); } diff --git a/src/app/commons/services/session/session.service.js b/src/app/commons/services/session/session.service.js new file mode 100644 index 0000000..23b38a5 --- /dev/null +++ b/src/app/commons/services/session/session.service.js @@ -0,0 +1,20 @@ +export default function(localStorage) { + 'ngInject'; + + const TOKEN = 'token'; + + return { + setToken(token) { + localStorage.setItem(TOKEN, token); + }, + + getToken() { + return localStorage.getItem(TOKEN); + }, + + removeToken() { + localStorage.removeItem(TOKEN); + } + }; + +} diff --git a/src/app/commons/services/session/session.service.spec.js b/src/app/commons/services/session/session.service.spec.js new file mode 100644 index 0000000..350a0ad --- /dev/null +++ b/src/app/commons/services/session/session.service.spec.js @@ -0,0 +1,64 @@ +import appCommonsModule from '../../commons.module'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +describe(`module: ${appCommonsModule}`, () => { + + beforeEach(angular.mock.module(appCommonsModule)); + + describe('service: session', () => { + + let session; + + beforeEach(inject(($injector) => { + session = $injector.get('session'); + })); + + describe('.setToken', () => { + + it('sets a token in the local storage', inject((localStorage) => { + // Given + sinon.stub(localStorage, 'setItem'); + + // When + session.setToken('the token'); + + // Then + expect(localStorage.setItem.calledWith('token', 'the token')).to.be.true; + })); + + }); + + describe('.getToken', () => { + + it('retrieves a token from the local storage', inject((localStorage) => { + // Given + sinon.stub(localStorage, 'getItem').returns('the token'); + + // When + expect(session.getToken()).to.eq('the token'); + + // Then + expect(localStorage.getItem.calledWith('token')).to.be.true; + })); + + }); + + describe('.removeToken', () => { + + it('removes a token from the local storage', inject((localStorage) => { + // Given + sinon.stub(localStorage, 'removeItem'); + + // When + session.removeToken(); + + // Then + expect(localStorage.removeItem.calledWith('token')).to.be.true; + })); + + }); + + }); + +}); diff --git a/src/app/contacts/components/contact-form/contact-form.component.spec.js b/src/app/contacts/components/contact-form/contact-form.component.spec.js index 60795fd..fce96c0 100644 --- a/src/app/contacts/components/contact-form/contact-form.component.spec.js +++ b/src/app/contacts/components/contact-form/contact-form.component.spec.js @@ -4,9 +4,7 @@ import sinon from 'sinon'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe('component: contactForm', () => { diff --git a/src/app/contacts/components/favourite-button/favourite-button.component.spec.js b/src/app/contacts/components/favourite-button/favourite-button.component.spec.js index e6bbb45..8c6bc9e 100644 --- a/src/app/contacts/components/favourite-button/favourite-button.component.spec.js +++ b/src/app/contacts/components/favourite-button/favourite-button.component.spec.js @@ -4,9 +4,7 @@ import sinon from 'sinon'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe('component: favouriteButton', () => { diff --git a/src/app/contacts/contacts.config.js b/src/app/contacts/contacts.config.js index cd860b2..79c29e4 100644 --- a/src/app/contacts/contacts.config.js +++ b/src/app/contacts/contacts.config.js @@ -12,6 +12,7 @@ export function states($stateProvider) { $stateProvider .state({ + parent: 'app', name: 'contacts', abstract: true, url: '/contacts', diff --git a/src/app/contacts/list/list.controller.spec.js b/src/app/contacts/list/list.controller.spec.js index f8c1258..b4aee9d 100644 --- a/src/app/contacts/list/list.controller.spec.js +++ b/src/app/contacts/list/list.controller.spec.js @@ -4,9 +4,7 @@ import { name } from './list.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`controller: ${name}`, () => { diff --git a/src/app/contacts/list/list.state.spec.js b/src/app/contacts/list/list.state.spec.js index 091c00e..30adfdf 100644 --- a/src/app/contacts/list/list.state.spec.js +++ b/src/app/contacts/list/list.state.spec.js @@ -5,9 +5,7 @@ import sinon from 'sinon'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/new/new.state.spec.js b/src/app/contacts/new/new.state.spec.js index 5da3792..06fff73 100644 --- a/src/app/contacts/new/new.state.spec.js +++ b/src/app/contacts/new/new.state.spec.js @@ -4,9 +4,7 @@ import { name } from './new.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/one/address/address.controller.spec.js b/src/app/contacts/one/address/address.controller.spec.js index 58b7e2d..e6df079 100644 --- a/src/app/contacts/one/address/address.controller.spec.js +++ b/src/app/contacts/one/address/address.controller.spec.js @@ -4,9 +4,7 @@ import { name } from './address.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`controller: ${name}`, () => { diff --git a/src/app/contacts/one/address/edit/edit.state.spec.js b/src/app/contacts/one/address/edit/edit.state.spec.js index f0c5ec7..0ebe98f 100644 --- a/src/app/contacts/one/address/edit/edit.state.spec.js +++ b/src/app/contacts/one/address/edit/edit.state.spec.js @@ -4,9 +4,7 @@ import { name } from './edit.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/one/address/show/show.state.spec.js b/src/app/contacts/one/address/show/show.state.spec.js index db949ae..02951d0 100644 --- a/src/app/contacts/one/address/show/show.state.spec.js +++ b/src/app/contacts/one/address/show/show.state.spec.js @@ -4,9 +4,7 @@ import { name } from './show.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/one/edit/edit.state.spec.js b/src/app/contacts/one/edit/edit.state.spec.js index 147664e..f8f830b 100644 --- a/src/app/contacts/one/edit/edit.state.spec.js +++ b/src/app/contacts/one/edit/edit.state.spec.js @@ -4,9 +4,7 @@ import { name } from './edit.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/one/one.state.spec.js b/src/app/contacts/one/one.state.spec.js index 2d4cc9f..8624a82 100644 --- a/src/app/contacts/one/one.state.spec.js +++ b/src/app/contacts/one/one.state.spec.js @@ -5,9 +5,7 @@ import sinon from 'sinon'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/one/show/show.state.spec.js b/src/app/contacts/one/show/show.state.spec.js index d9551d1..2f216ac 100644 --- a/src/app/contacts/one/show/show.state.spec.js +++ b/src/app/contacts/one/show/show.state.spec.js @@ -4,9 +4,7 @@ import { name } from './show.state'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe(`state: ${name}`, () => { diff --git a/src/app/contacts/services/contact/contact.factory.spec.js b/src/app/contacts/services/contact/contact.factory.spec.js index ca88e6b..6ff9c35 100644 --- a/src/app/contacts/services/contact/contact.factory.spec.js +++ b/src/app/contacts/services/contact/contact.factory.spec.js @@ -3,9 +3,7 @@ import { expect } from 'chai'; describe(`module: ${appContactsModule}`, () => { - beforeEach(() => { - angular.mock.module(appContactsModule); - }); + beforeEach(angular.mock.module(appContactsModule)); describe('resource: Contact', () => { diff --git a/src/app/home/index/index.controller.spec.js b/src/app/home/index/index.controller.spec.js index fd1fa92..bccda47 100644 --- a/src/app/home/index/index.controller.spec.js +++ b/src/app/home/index/index.controller.spec.js @@ -5,9 +5,7 @@ import sinon from 'sinon'; describe(`module: ${appHomeModule}`, () => { - beforeEach(() => { - angular.mock.module(appHomeModule); - }); + beforeEach(angular.mock.module(appHomeModule)); describe(`controller: ${name}`, () => { diff --git a/src/app/home/index/index.state.js b/src/app/home/index/index.state.js index eac659c..c679b4c 100644 --- a/src/app/home/index/index.state.js +++ b/src/app/home/index/index.state.js @@ -4,6 +4,8 @@ import template from './index.state.html'; export const name = 'home'; export default { + parent: 'app', + name, url: '/', template, diff --git a/src/app/home/index/index.state.spec.js b/src/app/home/index/index.state.spec.js index aebde4d..a18b5f9 100644 --- a/src/app/home/index/index.state.spec.js +++ b/src/app/home/index/index.state.spec.js @@ -4,9 +4,7 @@ import { name } from './index.state'; describe(`module: ${appHomeModule}`, () => { - beforeEach(() => { - angular.mock.module(appHomeModule); - }); + beforeEach(angular.mock.module(appHomeModule)); describe(`state: ${name}`, () => { diff --git a/src/index.html b/src/index.html index 68323c0..173f5da 100644 --- a/src/index.html +++ b/src/index.html @@ -2,8 +2,8 @@ - - + + <body> - <app-navigation></app-navigation> - - <div class="container"> - <div ncy-breadcrumb></div> - <div ui-view autoscroll="true"></div> - </div> - - <app-footer></app-footer> +<div ui-view autoscroll="true"></div> </body> </html> diff --git a/webpack.config.js b/webpack.config.js index b62574b..19b4d33 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,7 +12,6 @@ module.exports = { entry: { vendor: [ 'jquery', - 'lodash', 'angular', 'angular-animate', 'angular-messages', @@ -109,5 +108,5 @@ module.exports = { } }, - devtool: 'eval-source-map ' + devtool: 'eval-source-map' };