From e6fc0be0ed04b87a7731ad6831313776c956e97c Mon Sep 17 00:00:00 2001 From: febbweiss Date: Fri, 14 Aug 2015 08:04:31 +0000 Subject: [PATCH] Feature: First version of the REST API done --- .bowerrc | 3 + .gitignore | 5 + README.md | 1 + app.js | 3 + app/controllers/accounts.js | 227 ++++++ app/controllers/users.js | 79 ++ app/events/listeners.js | 35 + app/helpers/handler.js | 18 + app/models/account.js | 25 + app/models/categories.js | 1514 +++++++++++++++++++++++++++++++++++ app/models/entry.js | 20 + app/models/user.js | 87 ++ app/routes.js | 16 + app/routes/accounts.js | 22 + app/routes/users.js | 13 + app/security/passport.js | 40 + bower.json | 11 + config/db.js | 11 + config/security.js | 7 + config/server.js | 17 + package.json | 30 + public/views/index.html | 39 + public/views/user.html | 0 server.js | 64 ++ test/accounts.js | 543 +++++++++++++ test/db.js | 119 +++ test/server.js | 23 + test/users.js | 198 +++++ 28 files changed, 3170 insertions(+) create mode 100644 .bowerrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 app/controllers/accounts.js create mode 100644 app/controllers/users.js create mode 100644 app/events/listeners.js create mode 100644 app/helpers/handler.js create mode 100644 app/models/account.js create mode 100644 app/models/categories.js create mode 100644 app/models/entry.js create mode 100644 app/models/user.js create mode 100644 app/routes.js create mode 100644 app/routes/accounts.js create mode 100644 app/routes/users.js create mode 100644 app/security/passport.js create mode 100644 bower.json create mode 100644 config/db.js create mode 100644 config/security.js create mode 100644 config/server.js create mode 100644 package.json create mode 100644 public/views/index.html create mode 100644 public/views/user.html create mode 100644 server.js create mode 100644 test/accounts.js create mode 100644 test/db.js create mode 100644 test/server.js create mode 100644 test/users.js diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000..7507f30 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "public/libs" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca47d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +logs/* +data/* +mongod* +public/libs/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..01ee763 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Work in progress \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..d428655 --- /dev/null +++ b/app.js @@ -0,0 +1,3 @@ +var server = require('./server.js'); + +server.listen(); diff --git a/app/controllers/accounts.js b/app/controllers/accounts.js new file mode 100644 index 0000000..e24d39f --- /dev/null +++ b/app/controllers/accounts.js @@ -0,0 +1,227 @@ +var mongoose = require('mongoose'), + ObjectId = mongoose.Schema.Types.ObjectId, + Account = mongoose.model('Account'), + Entry = mongoose.model('Entry'), + Handler = require('../helpers/handler'), + categories = require('../models/categories'); + +var load_categories = function(language, callback) { + var catz = {}; + categories.forEach(function(category) { + var selected_category = category[language], + key = category.en.categorygroup.toLowerCase().replace(/ /g, '_'), + cat = catz[key] || {key: key, label: selected_category.categorygroup, sub_categories: []}; + + if( !selected_category.category ) { + cat.sub_categories.push({label: selected_category.subcategory, key: selected_category.subcategory.toLowerCase().replace(/ /g, '_')}); + } + + catz[key] = cat; + }); + + callback(null, catz); +}; + +var check_account = function(request, response, callback) { + Account.findById(request.params.account_id, function(errors, account) { + if( errors ) { + return Handler.errorHandler(errors, 404, response); + } + + if( !account ) { + return response.status(404).json({message: 'Unknown account'}); + } + + if( !account.user_id.equals(request.user.id) ) { + return response.status(401).end(); + } + + return callback(null, account); + }); +}; + +var delete_account = function(account, callback) { + Entry.find({account_id: account.id}).remove(function(errors) { + if( errors ) { + if( callback ) { + return callback(errors); + } + + return; + } + + account.remove(function(errors) { + if( errors ) { + if( callback ) { + return callback(errors); + } + return; + } + + if( callback ) { + return callback(); + } + }); + }); +}; + +module.exports = { + create : function(request, response) { + var user = request.user, + account = new Account({ + name: request.body.name, + reference: request.body.reference, + user_id: user.id + }); + + load_categories(user.language, function(error, result) { + for( var key in result ) { + account.categories.push( result[key] ); + } + + account.save(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 400, response); + } + + return response.status(201).json(account); + }); + }); + }, + + modify : function(request, response) { + return check_account(request, response, function(error, account) { + account.name = request.body.name; + account.reference = request.body.reference; + + account.save(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 400, response); + } + + return response.json(account); + }); + }); + }, + + delete : function(request, response) { + return check_account(request, response, function(error, account) { + return delete_account(account, function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 500, response); + } + + return response.status(204).end(); + + }); + }); + }, + + delete_account : delete_account, + + get : function(request, response) { + return check_account(request, response, function(error, account) { + return response.json(account); + }); + }, + + add_entry : function(request, response) { + return check_account(request, response, function(error, account) { + var data = request.body, + entry = new Entry({ + account_id: account.id, + category: data.category ? new ObjectId(data.category) : undefined, + sub_category: data.sub_category ? new ObjectId(data.sub_category) : undefined, + label: data.label, + amount: data.amount, + date: new Date(data.date), + type: data.amount >= 0 ? 'DEPOSIT' : 'BILL' + }); + + entry.save(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 400, response); + } + + response.status(201).json(entry); + }) + }); + }, + + modify_entry : function(request, response) { + return check_account(request, response, function(error, account) { + Entry.findById(request.params.entry_id, function(errors, entry) { + if( errors ) { + return Handler.errorHandler(errors, 404, response); + } + + if( !entry ) { + return response.status(404).end(); + } + + if( !entry.account_id.equals( account.id ) ) { + return response.status(401).end(); + } + + var data = request.body; + + entry.category = data.category ? new ObjectId(data.category) : undefined; + entry.sub_category = data.sub_category ? new ObjectId(data.sub_category) : undefined; + entry.label = data.label; + entry.amount = data.amount; + entry.date = new Date(data.date); + entry.type = data.amount >= 0 ? 'DEPOSIT' : 'BILL'; + + entry.save(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 400, response ); + } + + return response.json(entry); + }); + }); + }); + }, + + delete_entry : function(request, response) { + return check_account(request, response, function(errors, account) { + Entry.findById(request.params.entry_id, function(errors, entry) { + if( errors ) { + return Handler.errorHandler(errors, 404, response); + } + + if( !entry ) { + return response.status(404).end(); + } + + if( !entry.account_id.equals( account.id ) ) { + return response.status(401).end(); + } + + entry.remove(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 500, response); + } + + return response.status(204).end(); + }); + }); + }); + }, + + list_entries : function(request, response) { + return check_account(request, response, function(errors, account) { + Entry.find({ + account_id: account.id + }) + .sort('-date') + .exec(function(errors, entries) { + if( errors ) { + return Handler.errorHandler(errors, 500, response); + } + + return response.json(entries); + }); + }); + } +} \ No newline at end of file diff --git a/app/controllers/users.js b/app/controllers/users.js new file mode 100644 index 0000000..127b605 --- /dev/null +++ b/app/controllers/users.js @@ -0,0 +1,79 @@ +var mongoose = require('mongoose'), + User = mongoose.model('User'), + jwt = require('jsonwebtoken'), + security = require('../../config/security'), + Handler = require('../helpers/handler'), + EventEmitter = require('../events/listeners'); + +module.exports = { + login : function(request, response) { + var user = request.user; + if( !user ) { + return response.status(401).json({message: 'Authentication failed'}); + } + + return response.json( + { + username: user.username, + token: jwt.sign( + { + user_id: user.id + }, security.jwt.secretOrKey) + + }); + }, + + logout : function(request, response) { + return response.status(200).end(); + }, + + subscribe : function(request, response) { + var registered = new User({username: request.body.username, password: request.body.password}); + registered.validate(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 400, response); + } + + User.findOne({username: request.body.username}, function(error, user) { + if( error ) { + return response.send(error); + } + if( !user ) { + registered.save(function(errors) { + if( errors ) { + return Handler.errorHandler(errors, 500, response); + } + + return response.status(201).json({ + username: registered.username, + token: jwt.sign( + { + user_id: registered.id + }, security.jwt.secretOrKey) + }); + }); + } else { + return response.status(409).json({message: 'Account already exists'}); + } + }); + }); + }, + + unsubscribe : function(request, response) { + var user = request.user; + + if( !user ) { + return response.status(401).json({message: 'Authentication failed'}); + } + + User.remove({username: user.username}, function(error) { + if( error ) { + return response.status(500).send(error); + } + + EventEmitter.eventEmitter.emit(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, user.id); + + return response.status(204).end(); + }); + } +} \ No newline at end of file diff --git a/app/events/listeners.js b/app/events/listeners.js new file mode 100644 index 0000000..922b92c --- /dev/null +++ b/app/events/listeners.js @@ -0,0 +1,35 @@ +var mongoose = require('mongoose'), + Account = mongoose.model('Account'), + EventEmitter = require('events').EventEmitter, + AccountController = require('../controllers/accounts'); + + +var eventEmitter = new EventEmitter(), + ACCOUNTS_DELETE_BY_USER_ID_EVT = 'accounts.delete.by.user.id', + ENTRIES_DELETE_BY_ACCOUNT_EVT = 'entries.delete.by.account'; + +eventEmitter.on(ACCOUNTS_DELETE_BY_USER_ID_EVT, function(user_id) { + Account.find({user_id: user_id}, function(errors, accounts) { + if( errors ) { + console.error('An error occurs during accounts deletion for user ' + user_id, errors); + return; + } + + if( !accounts ) { + console.log('No accounts'); + return; + } + for( var index in accounts ) { + eventEmitter.emit(ENTRIES_DELETE_BY_ACCOUNT_EVT, accounts[index]); + } + }); +}); +eventEmitter.on(ENTRIES_DELETE_BY_ACCOUNT_EVT, AccountController.delete_account); + +module.exports = { + events : { + ACCOUNTS_DELETE_BY_USER_ID_EVT: ACCOUNTS_DELETE_BY_USER_ID_EVT, + ENTRIES_DELETE_BY_ACCOUNT_EVT: ENTRIES_DELETE_BY_ACCOUNT_EVT + }, + eventEmitter: eventEmitter +} \ No newline at end of file diff --git a/app/helpers/handler.js b/app/helpers/handler.js new file mode 100644 index 0000000..be7a3a6 --- /dev/null +++ b/app/helpers/handler.js @@ -0,0 +1,18 @@ +module.exports = { + errorHandler : function(errors, status, response) { + var message = [] + if( errors.errors) { + Object.keys(errors.errors).forEach(function (field) { + var error = errors.errors[field]; + message.push({ + field: error.path, + rule: error.kind, + message: error.message + }); + }); + return response.status(status).json(message); + } else { + return response.status(status).end(); + } + } +} \ No newline at end of file diff --git a/app/models/account.js b/app/models/account.js new file mode 100644 index 0000000..5703548 --- /dev/null +++ b/app/models/account.js @@ -0,0 +1,25 @@ +var mongoose = require('mongoose'), + Schema = mongoose.Schema, + ObjectId = Schema.Types.ObjectId; + +var CategorySchema = new Schema({ + label: {type: String, required:true}, + key: {type: String, required: true, index: {unique: false} }, + sub_categories: [{ + label: {type: String, required:true}, + key: {type: String, required: true, index: {unique: false} }, + }] +}); + +var AccountSchema = new Schema({ + name: {type: String, required: true}, + reference: {type: String, required: false}, + categories: {type: [CategorySchema], required: true}, + user_id: {type: ObjectId, ref: 'User', required: true}, + created_at: {type: Date, default: Date.now} +}); + +var Account = mongoose.model('Account', AccountSchema); +var Category = mongoose.model('Category', CategorySchema); + +module.exports = Account; \ No newline at end of file diff --git a/app/models/categories.js b/app/models/categories.js new file mode 100644 index 0000000..fde298b --- /dev/null +++ b/app/models/categories.js @@ -0,0 +1,1514 @@ +module.exports = [ + { + "en": { + "category": "Alimony", + "categorygroup": "Alimony Payments", + "subcategory": undefined + }, + "fr": { + "category": "Pension", + "categorygroup": "Les paiements de pension alimentaire", + "subcategory": undefined + } + }, + { + "en": { + "category": "Automobile", + "categorygroup": "Automobile Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Automobile", + "categorygroup": "Frais d'automobile", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Automobile Expenses", + "subcategory": "Car Payment" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais d'automobile", + "subcategory": "Paiement de voiture" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Automobile Expenses", + "subcategory": "Gasoline" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais d'automobile", + "subcategory": "Essence" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Automobile Expenses", + "subcategory": "Maintenance" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais d'automobile", + "subcategory": "Entretien" + } + }, + { + "en": { + "category": "Bank Charges", + "categorygroup": "Bank Charges", + "subcategory": undefined + }, + "fr": { + "category": "Frais bancaires", + "categorygroup": "Frais bancaires", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Bank Charges", + "subcategory": "Interest Paid" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais bancaires", + "subcategory": "Intérêts Payés" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Bank Charges", + "subcategory": "Service Charge" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais bancaires", + "subcategory": "Frais de service" + } + }, + { + "en": { + "category": "Bills", + "categorygroup": "Other Bills", + "subcategory": undefined + }, + "fr": { + "category": "Factures", + "categorygroup": "Autres factures", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Cable Bill", + "subcategory": "Cable/Satellite Television" + }, + "fr": { + "category": undefined, + "categorygroup": "Bill câble", + "subcategory": "Câble / Télévision par satellite" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Telephone Bill", + "subcategory": "Cell Phone" + }, + "fr": { + "category": undefined, + "categorygroup": "Facture De Téléphone", + "subcategory": "Téléphone cellulaire" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Telephone Bill", + "subcategory": "Cellular" + }, + "fr": { + "category": undefined, + "categorygroup": "Facture De Téléphone", + "subcategory": "Cellulaire" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Gas & Electric Bill", + "subcategory": "Electricity" + }, + "fr": { + "category": undefined, + "categorygroup": "Gas & Electric Bill", + "subcategory": "Électricité" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Garbage/Recycle Bill", + "subcategory": "Garbage & Recycle" + }, + "fr": { + "category": undefined, + "categorygroup": "Déchets / Recyclage projet de loi", + "subcategory": "Déchets et recyclage" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Health Club" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Club de santé" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Homeowner's Dues" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Cotisations d'propriétaires" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Membership Fees" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Frais d'adhésion" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Mortgage Payment" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "de paiement d'hypothèque" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Gas & Electric Bill", + "subcategory": "Natural Gas/Oil" + }, + "fr": { + "category": undefined, + "categorygroup": "Gas & Electric Bill", + "subcategory": "Gaz naturel / huile" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Newspaper" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Journal" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Online/Internet Service" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Service en ligne / Internet" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Other Loan Payment" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Autres conditions de prêt" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Rent Bill", + "subcategory": "Rent" + }, + "fr": { + "category": undefined, + "categorygroup": "Louer le projet de loi", + "subcategory": "Location" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Bills", + "subcategory": "Student Loan Payment" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres factures", + "subcategory": "Paiement de prêts aux étudiants" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Telephone Bill", + "subcategory": "Telephone" + }, + "fr": { + "category": undefined, + "categorygroup": "Facture De Téléphone", + "subcategory": "Téléphone" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Water & Sewer Bill", + "subcategory": "Water & Sewer" + }, + "fr": { + "category": undefined, + "categorygroup": "Bill eau et d'égout", + "subcategory": "Eau et d'égout" + } + }, + { + "en": { + "category": "Cash Withdrawal", + "categorygroup": "Cash Withdrawal", + "subcategory": undefined + }, + "fr": { + "category": "Retrait D'Argent", + "categorygroup": "Retrait D'Argent", + "subcategory": undefined + } + }, + { + "en": { + "category": "Charitable Donations", + "categorygroup": "Charitable Donations", + "subcategory": undefined + }, + "fr": { + "category": "Dons de bienfaisance", + "categorygroup": "Dons de bienfaisance", + "subcategory": undefined + } + }, + { + "en": { + "category": "Childcare", + "categorygroup": "Childcare Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Garde d'enfants", + "categorygroup": "Frais de garde d'enfants", + "subcategory": undefined + } + }, + { + "en": { + "category": "Children/Toys", + "categorygroup": "Childcare Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Enfants / Jouets", + "categorygroup": "Frais de garde d'enfants", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Childcare Expenses", + "subcategory": "Child Support" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais de garde d'enfants", + "subcategory": "Pensions alimentaires pour enfants" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Childcare Expenses", + "subcategory": "Daycare" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais de garde d'enfants", + "subcategory": "Garderie" + } + }, + { + "en": { + "category": "Clothing", + "categorygroup": "Clothing Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Vêtements", + "categorygroup": "les frais d'habillement", + "subcategory": undefined + } + }, + { + "en": { + "category": "Credit Card Payments/Transfers", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Carte de crédit Paiements / Transferts", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Dining Out", + "categorygroup": "Dining Out", + "subcategory": undefined + }, + "fr": { + "category": "Dîner À L'Extérieur", + "categorygroup": "Dîner À L'Extérieur", + "subcategory": undefined + } + }, + { + "en": { + "category": "Education", + "categorygroup": "Education", + "subcategory": undefined + }, + "fr": { + "category": "Éducation", + "categorygroup": "Éducation", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Education", + "subcategory": "Books" + }, + "fr": { + "category": undefined, + "categorygroup": "Éducation", + "subcategory": "Livres" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Education", + "subcategory": "Fees" + }, + "fr": { + "category": undefined, + "categorygroup": "Éducation", + "subcategory": "Honoraires" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Education", + "subcategory": "Tuition" + }, + "fr": { + "category": undefined, + "categorygroup": "Éducation", + "subcategory": "Cours" + } + }, + { + "en": { + "category": "Entertainment", + "categorygroup": "Entertainment", + "subcategory": undefined + }, + "fr": { + "category": "Divertissement", + "categorygroup": "Divertissement", + "subcategory": undefined + } + }, + { + "en": { + "category": "Fees", + "categorygroup": "Bank Charges", + "subcategory": undefined + }, + "fr": { + "category": "Honoraires", + "categorygroup": "Frais bancaires", + "subcategory": undefined + } + }, + { + "en": { + "category": "Food", + "categorygroup": "Grocery Costs", + "subcategory": undefined + }, + "fr": { + "category": "Aliments", + "categorygroup": "Coûts d'épicerie", + "subcategory": undefined + } + }, + { + "en": { + "category": "Gifts", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Cadeaux", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Groceries", + "categorygroup": "Grocery Costs", + "subcategory": undefined + }, + "fr": { + "category": "Épicerie", + "categorygroup": "Coûts d'épicerie", + "subcategory": undefined + } + }, + { + "en": { + "category": "Healthcare", + "categorygroup": "Medical/Dental Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Soins De Santé", + "categorygroup": "Frais médicaux / dentaires", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Dental" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Dentaire" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Eyecare" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Soin Des Yeux" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Hospital" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Hôpital" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Physician" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Médecin" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Prescriptions" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Prescriptions" + } + }, + { + "en": { + "category": "Hobbies/Leisure", + "categorygroup": "Entertainment", + "subcategory": undefined + }, + "fr": { + "category": "Loisirs / Loisirs", + "categorygroup": "Divertissement", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Books & Magazines" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Livres et revues" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Cultural Events" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Événements Culturels" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Entertaining" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Divertissant" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Movies & Video Rentals" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Cinéma et la location de vidéos" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Sporting Events" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Événements Sportifs" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Sporting Goods" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Sporting Goods" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Tapes & CDs" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Tapes & CD" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Toys & Games" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Jouets & Jeux" + } + }, + { + "en": { + "category": "Home Improvement", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Rénovations", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Household", + "categorygroup": "Household Expenses", + "subcategory": undefined + }, + "fr": { + "category": "Ménage", + "categorygroup": "Dépenses des ménages", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Household Expenses", + "subcategory": "Furnishings" + }, + "fr": { + "category": undefined, + "categorygroup": "Dépenses des ménages", + "subcategory": "Ameublement" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Household Expenses", + "subcategory": "House Cleaning" + }, + "fr": { + "category": undefined, + "categorygroup": "Dépenses des ménages", + "subcategory": "Nettoyage De Maison" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Household Expenses", + "subcategory": "Yard Service" + }, + "fr": { + "category": undefined, + "categorygroup": "Dépenses des ménages", + "subcategory": "Service de manœuvre" + } + }, + { + "en": { + "category": "Insurance", + "categorygroup": "Life Insurance", + "subcategory": undefined + }, + "fr": { + "category": "Assurance", + "categorygroup": "Assurance Vie", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Automobile Insurance", + "subcategory": "Automobile" + }, + "fr": { + "category": undefined, + "categorygroup": "Assurance automobile", + "subcategory": "Automobile" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medical/Dental Expenses", + "subcategory": "Health" + }, + "fr": { + "category": undefined, + "categorygroup": "Frais médicaux / dentaires", + "subcategory": "Santé" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Home/Rent Insurance", + "subcategory": "Homeowner's/Renter's" + }, + "fr": { + "category": undefined, + "categorygroup": "Accueil / Louer Assurances", + "subcategory": "De propriétaire / locataire de" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Life Insurance", + "subcategory": "Life" + }, + "fr": { + "category": undefined, + "categorygroup": "Assurance Vie", + "subcategory": "Vie" + } + }, + { + "en": { + "category": "Job Expense", + "categorygroup": "Non-Reimb. Job Exp.", + "subcategory": undefined + }, + "fr": { + "category": "Dépenses d'emploi", + "categorygroup": "Non-Reimb. ", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Non-Reimb. Job Exp.", + "subcategory": "Non-Reimbursed" + }, + "fr": { + "category": undefined, + "categorygroup": "Non-Reimb. ", + "subcategory": "Non remboursés" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Reimbursed Job Exp.", + "subcategory": "Reimbursed" + }, + "fr": { + "category": undefined, + "categorygroup": "Remboursé emploi Exp.", + "subcategory": "Remboursé" + } + }, + { + "en": { + "category": "Loan", + "categorygroup": "Other Interest", + "subcategory": undefined + }, + "fr": { + "category": "Prêt", + "categorygroup": "Autres intérêts", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Interest", + "subcategory": "Loan Interest" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres intérêts", + "subcategory": "Intérêts d'emprunt" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Mortgage Interest", + "subcategory": "Mortgage Interest" + }, + "fr": { + "category": undefined, + "categorygroup": "Intérêts hypothécaires", + "subcategory": "Intérêts hypothécaires" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Interest", + "subcategory": "Student Loan Interest" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres intérêts", + "subcategory": "Intérêts sur les prêts étudiants" + } + }, + { + "en": { + "category": "Miscellaneous", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Divers", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Mortgage/Rent", + "categorygroup": "Rent Bill", + "subcategory": undefined + }, + "fr": { + "category": "Hypothèque / Loyer", + "categorygroup": "Louer le projet de loi", + "subcategory": undefined + } + }, + { + "en": { + "category": "Personal Care", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Soins personnels", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Pet Care", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "S'occuper D'Un Animal", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Expense", + "subcategory": "Food" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres charges", + "subcategory": "Aliments" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Expense", + "subcategory": "Supplies" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres charges", + "subcategory": "Provisions" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Expense", + "subcategory": "Veterinarian" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres charges", + "subcategory": "Vétérinaire" + } + }, + { + "en": { + "category": "Phone/Wireless", + "categorygroup": "Telephone Bill", + "subcategory": undefined + }, + "fr": { + "category": "Téléphone / sans fil", + "categorygroup": "Facture De Téléphone", + "subcategory": undefined + } + }, + { + "en": { + "category": "Services/Memberships", + "categorygroup": "Other Expense", + "subcategory": undefined + }, + "fr": { + "category": "Services / Adhésion", + "categorygroup": "Autres charges", + "subcategory": undefined + } + }, + { + "en": { + "category": "Taxes", + "categorygroup": "Other Tax Payments", + "subcategory": undefined + }, + "fr": { + "category": "Impôts", + "categorygroup": "Autres paiements d'impôt", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Federal Taxes", + "subcategory": "Federal Income Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Impôts fédéraux", + "subcategory": "Impôt sur le revenu fédéral" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Expense", + "subcategory": "Federal Income Tax-Previous Year" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres charges", + "subcategory": "Année d'impôt fédéral précédente" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Tax Payments", + "subcategory": "Local Income Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres paiements d'impôt", + "subcategory": "Impôt sur le revenu local" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Medicare Taxes", + "subcategory": "Medicare Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Medicare impôts", + "subcategory": "Impôt Medicare" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Tax Payments", + "subcategory": "Other Taxes" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres paiements d'impôt", + "subcategory": "Autres taxes" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Real Estate Taxes", + "subcategory": "Real Estate Taxes" + }, + "fr": { + "category": undefined, + "categorygroup": "Immobilier Impôts", + "subcategory": "Immobilier Impôts" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Tax Payments", + "subcategory": "Sales Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Autres paiements d'impôt", + "subcategory": "la taxe de vente" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Social Security Taxes", + "subcategory": "Social Security Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Impôts de sécurité sociale", + "subcategory": "L'impôt sur la sécurité sociale" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "State Tax Payments", + "subcategory": "State Income Tax" + }, + "fr": { + "category": undefined, + "categorygroup": "Paiements d'impôts étatiques", + "subcategory": "Impôt sur le revenu État" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "State Tax Payments", + "subcategory": "State/Provincial" + }, + "fr": { + "category": undefined, + "categorygroup": "Paiements d'impôts étatiques", + "subcategory": "État / provincial" + } + }, + { + "en": { + "category": "Travel/Vacation", + "categorygroup": "Entertainment", + "subcategory": undefined + }, + "fr": { + "category": "Voyage / vacances", + "categorygroup": "Divertissement", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Lodging" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Hébergement" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Entertainment", + "subcategory": "Travel" + }, + "fr": { + "category": undefined, + "categorygroup": "Divertissement", + "subcategory": "Voyage" + } + }, + { + "en": { + "category": "Utilities", + "categorygroup": "Other Bills", + "subcategory": undefined + }, + "fr": { + "category": "Utilitaires", + "categorygroup": "Autres factures", + "subcategory": undefined + } + }, + { + "en": { + "category": "Income/Interest", + "categorygroup": "Salary Income", + "subcategory": undefined + }, + "fr": { + "category": "Revenu / intérêt", + "categorygroup": "Revenu Salaire", + "subcategory": undefined + } + }, + { + "en": { + "category": "Investment Income", + "categorygroup": "Interest & Dividends", + "subcategory": undefined + }, + "fr": { + "category": "Revenu de placement", + "categorygroup": "Intérêts et dividendes", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Interest & Dividends", + "subcategory": "Capital Gains" + }, + "fr": { + "category": undefined, + "categorygroup": "Intérêts et dividendes", + "subcategory": "Gains en capital" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Interest & Dividends", + "subcategory": "Dividends" + }, + "fr": { + "category": undefined, + "categorygroup": "Intérêts et dividendes", + "subcategory": "Dividendes" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Interest & Dividends", + "subcategory": "Interest" + }, + "fr": { + "category": undefined, + "categorygroup": "Intérêts et dividendes", + "subcategory": "Intérêt" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Tax-Exempt Income", + "subcategory": "Tax-Exempt Interest" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu exonéré d'impôt", + "subcategory": "Les intérêts exonérés d'impôt" + } + }, + { + "en": { + "category": "Not an Expense", + "categorygroup": "Other Income", + "subcategory": undefined + }, + "fr": { + "category": "Pas une dépense", + "categorygroup": "Autre Revenu", + "subcategory": undefined + } + }, + { + "en": { + "category": "Other Income", + "categorygroup": "Other Income", + "subcategory": undefined + }, + "fr": { + "category": "Autre Revenu", + "categorygroup": "Autre Revenu", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Child Support Received", + "subcategory": "Child Support Received" + }, + "fr": { + "category": undefined, + "categorygroup": "Child Support Reçues", + "subcategory": "Child Support Reçues" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Periodic Income", + "subcategory": "Employee Stock Option" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu périodique", + "subcategory": "Employee Stock Option" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Gifts Received", + "subcategory": "Gifts Received" + }, + "fr": { + "category": undefined, + "categorygroup": "Cadeaux reçus", + "subcategory": "Cadeaux reçus" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Tax-Exempt Income", + "subcategory": "Loan Principal Received" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu exonéré d'impôt", + "subcategory": "Principal du prêt Reçues" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Other Income", + "subcategory": "Lotteries" + }, + "fr": { + "category": undefined, + "categorygroup": "Autre Revenu", + "subcategory": "Loteries" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "State/Local Tax Refund", + "subcategory": "State & Local Tax Refund" + }, + "fr": { + "category": undefined, + "categorygroup": "Etat / local Remboursement de la taxe", + "subcategory": "State & Local Remboursement de la taxe" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Unemployment Income", + "subcategory": "Unemployment Compensation" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu de chômage", + "subcategory": "indemnisation du chômage" + } + }, + { + "en": { + "category": "Retirement Income", + "categorygroup": "Other Income", + "subcategory": undefined + }, + "fr": { + "category": "De revenu de retraite", + "categorygroup": "Autre Revenu", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "IRA/Pension Income", + "subcategory": "IRA Distributions" + }, + "fr": { + "category": undefined, + "categorygroup": "IRA / revenu de pension", + "subcategory": "IRA Distributions" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "IRA/Pension Income", + "subcategory": "Pensions & Annuities" + }, + "fr": { + "category": undefined, + "categorygroup": "IRA / revenu de pension", + "subcategory": "Pensions et rentes" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Social Security Income", + "subcategory": "Social Security Benefits" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu de la sécurité sociale", + "subcategory": "Prestations sociales" + } + }, + { + "en": { + "category": "Wages & Salary", + "categorygroup": "Salary Income", + "subcategory": undefined + }, + "fr": { + "category": "Salaires et Salaire", + "categorygroup": "Revenu Salaire", + "subcategory": undefined + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Periodic Income", + "subcategory": "Bonus" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu périodique", + "subcategory": "Prime" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Periodic Income", + "subcategory": "Commission" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu périodique", + "subcategory": "Commission" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Employer Matching", + "subcategory": "Employer Matching" + }, + "fr": { + "category": undefined, + "categorygroup": "Employeur Matching", + "subcategory": "Employeur Matching" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Salary Income", + "subcategory": "Gross Pay" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu Salaire", + "subcategory": "Salaire Brut" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Salary Income", + "subcategory": "Net Pay" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu Salaire", + "subcategory": "Salaire Net" + } + }, + { + "en": { + "category": undefined, + "categorygroup": "Periodic Income", + "subcategory": "Overtime" + }, + "fr": { + "category": undefined, + "categorygroup": "Revenu périodique", + "subcategory": "Avec Le Temps" + } + } +] \ No newline at end of file diff --git a/app/models/entry.js b/app/models/entry.js new file mode 100644 index 0000000..acea4d2 --- /dev/null +++ b/app/models/entry.js @@ -0,0 +1,20 @@ +var mongoose = require('mongoose'), + Schema = mongoose.Schema, + ObjectId = Schema.Types.ObjectId; + +var DEBIT = 'D', + BILL = 'B'; + +var EntrySchema = new Schema({ + account_id: {type: ObjectId, ref: 'Category', required: true}, + category: {type: ObjectId, ref: 'Account', required: false}, + sub_category: {type: ObjectId, required: false}, + label: {type: String, required:false}, + type: {type: String, required: true}, + amount: {type: Number, required: true}, + date: {type: Date, required: true}, + created_at: {type: Date, default: Date.now} +}); + +var Entry = mongoose.model('Entry', EntrySchema); +module.exports = Entry; \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js new file mode 100644 index 0000000..612aaf9 --- /dev/null +++ b/app/models/user.js @@ -0,0 +1,87 @@ +var mongoose = require('mongoose'), + Schema = mongoose.Schema, + bcrypt = require('bcrypt'), + SALT_WORK_FACTOR = 10; + +var UserSchema = new Schema({ + username: { type: String, required: true, index: { unique: true } }, + password: { type: String, required: true }, + language: {type: String, required: true, default: 'en'}, + created_at: {type: Date, 'default': Date.now} +}); + +UserSchema.statics.getAuthenticated = function(username, password, callback) { + this.findOne({ username: username }, function(error, user) { + if (error) { + console.error(error); + return callback(error); + } + // make sure the user exists + if (!user) { + return callback(null, null, 404); + } + + user.comparePassword(password, function(error, isMatch) { + if (isMatch) { + return callback(null, user); + } + + return callback(null, null, 401); + }); + + }); +}; + + +UserSchema.pre('save', function(next) { + var user = this; + + // only hash the password if it has been modified (or is new) + if (!user.isModified('password')) { + return next(); + } + + // generate a salt + bcrypt.genSalt(SALT_WORK_FACTOR, function(error, salt) { + if (error) { + console.log(error); + return next(error); + } + + // hash the password using our new salt + bcrypt.hash(user.password, salt, function(error, hash) { + if (error) { + return next(error); + } + + // override the cleartext password with the hashed one + user.password = hash; + next(); + }); + }); +}); + +UserSchema.methods.comparePassword = function(candidatePassword, callback) { + bcrypt.compare(candidatePassword, this.password, function(error, isMatch) { + if (error) { + return callback(error); + } + callback(null, isMatch); + }); +}; + +var User = mongoose.model('User', UserSchema); + +User.schema.path('username').validate(function (username) { + return username.length; +}, 'Username cannot be blank'); + +User.schema.path('password').validate(function(password) { + return password.length; +}, 'Password cannot be blank'); + +User.schema.path('language').validate(function(language) { + return /en|fr/i.test(language); +}, 'Unknown language ("en" or "fr" only)') + +module.exports = User; \ No newline at end of file diff --git a/app/routes.js b/app/routes.js new file mode 100644 index 0000000..de06f20 --- /dev/null +++ b/app/routes.js @@ -0,0 +1,16 @@ +var fs = require('fs'); + +module.exports = function(app) { + + var routes_path = __dirname + '/routes' + fs.readdirSync(routes_path).forEach(function (file) { + if (~file.indexOf('.js')) { + var route = require(routes_path + '/' + file); + route(app); + } + }) + + app.get('*', function(req, res) { + res.sendfile('./public/views/index.html'); + }); +}; \ No newline at end of file diff --git a/app/routes/accounts.js b/app/routes/accounts.js new file mode 100644 index 0000000..46088fc --- /dev/null +++ b/app/routes/accounts.js @@ -0,0 +1,22 @@ +var passport = require('../security/passport'), + AccountController = require('../controllers/accounts'); + +module.exports = function(app) { + + app.post('/api/accounts', passport.jwt, AccountController.create); + + app.delete('/api/accounts/:account_id', passport.jwt, AccountController.delete); + + app.get('/api/accounts/:account_id', passport.jwt, AccountController.get); + + app.put('/api/accounts/:account_id', passport.jwt, AccountController.modify); + + app.post('/api/accounts/:account_id/entries', passport.jwt, AccountController.add_entry); + + app.put('/api/accounts/:account_id/entries/:entry_id', passport.jwt, AccountController.modify_entry); + + app.delete('/api/accounts/:account_id/entries/:entry_id', passport.jwt, AccountController.delete_entry); + + app.get('/api/accounts/:account_id/entries', passport.jwt, AccountController.list_entries); + +}; \ No newline at end of file diff --git a/app/routes/users.js b/app/routes/users.js new file mode 100644 index 0000000..830bdb8 --- /dev/null +++ b/app/routes/users.js @@ -0,0 +1,13 @@ +var passport = require('../security/passport'), + UserController = require('../controllers/users'); + +module.exports = function(app) { + + app.post('/api/users/login', passport.local, UserController.login); + + app.delete('/api/users/login', UserController.logout); + + app.post('/api/users', UserController.subscribe); + + app.delete('/api/users', passport.jwt, UserController.unsubscribe); +}; \ No newline at end of file diff --git a/app/security/passport.js b/app/security/passport.js new file mode 100644 index 0000000..16f7fe5 --- /dev/null +++ b/app/security/passport.js @@ -0,0 +1,40 @@ +var mongoose = require('mongoose'), + User = mongoose.model('User'), + passport = require('passport'), + LocalStrategy = require('passport-local'), + JwtStrategy = require('passport-jwt').Strategy, + security = require('../../config/security'); + +passport.use( new LocalStrategy( + function(username, password, done) { + User.getAuthenticated(username, password, function(error, user, errorStatus) { + if( error ) { + return done(error, null); + } + + if( !user ) { + return done(null, false); + } + + return done(null, user); + }); + } +)); + +passport.use( new JwtStrategy(security.jwt, function(jwt_payload, done) { + User.findById(jwt_payload.user_id, function(error, user) { + if( error ) { + return done(error, null); + } + if( user ) { + return done(null, user); + } else { + return done(null, false); + } + }); +})); + +module.exports = { + jwt: passport.authenticate('jwt', {session: false}), + local: passport.authenticate('local', {session: false}) +} \ No newline at end of file diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..c3be526 --- /dev/null +++ b/bower.json @@ -0,0 +1,11 @@ +{ + "name": "cloud-budget", + "version": "0.0.1-SNAPSHOT", + "dependencies": { + "bootstrap": "~3.3.5", + "font-awesome": "~4.3.0", + "animate.css": "~3.3.0", + "angular": "~1.4.1", + "angular-route": "~1.4.1" + } +} \ No newline at end of file diff --git a/config/db.js b/config/db.js new file mode 100644 index 0000000..c24512b --- /dev/null +++ b/config/db.js @@ -0,0 +1,11 @@ +module.exports = { + development: { + url: 'mongodb://' + process.env.IP + ':27017/cloudbudget_dev' + }, + test: { + url: 'mongodb://localhost:27017/cloudbudget_test' + }, + production: { + url: 'mongodb://' + process.env.IP + ':27017/cloudbudget' + }, +} \ No newline at end of file diff --git a/config/security.js b/config/security.js new file mode 100644 index 0000000..5a5f2c6 --- /dev/null +++ b/config/security.js @@ -0,0 +1,7 @@ +module.exports = { + jwt : { + secretOrKey : 's3cr3t', + issuer : undefined, // accounts.examplesoft.com + audience : undefined // yoursite.net + } +} \ No newline at end of file diff --git a/config/server.js b/config/server.js new file mode 100644 index 0000000..957080f --- /dev/null +++ b/config/server.js @@ -0,0 +1,17 @@ +module.exports = { + development: { + port : process.env.PORT || 3000, + server : process.env.IP || '0.0.0.0', + errorHandlerOptions: {"dumpExceptions": true, "showStack": true} + }, + test: { + port : 3000, + server : 'localhost', + errorHandlerOptions: {"dumpExceptions": false, "showStack": false} + }, + production: { + port : process.env.PORT || 3000, + server : process.env.IP || '0.0.0.0', + errorHandlerOptions: {"dumpExceptions": false, "showStack": false} + }, +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..d10a094 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "cloud-budget", + "main": "app.js", + "dependencies": { + "express": "~4.5.1", + "mongoose": "~4.0.8", + "body-parser": "~1.4.2", + "method-override": "~2.0.2", + "morgan": "~1.6.0", + "file-stream-rotator": "~0.0.6", + "errorhandler": "~1.4.1", + + "jsonwebtoken": "~5.0.4", + "bcrypt": "~0.8.3", + + "passport": "~0.2.2", + "passport-local": "~1.0.0", + "passport-jwt": "~1.1.0" + }, + "devDependencies": { + "mocha": "~2.2.5", + "supertest": "~1.0.1", + "should": "~7.0.2", + "sinon": "~1.15.4" + }, + "scripts": { + "test": "NODE_ENV=test mocha", + "start": "app.js" + } +} \ No newline at end of file diff --git a/public/views/index.html b/public/views/index.html new file mode 100644 index 0000000..dcc323b --- /dev/null +++ b/public/views/index.html @@ -0,0 +1,39 @@ + + + + + + + MEAN Demo Single Page Application + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + \ No newline at end of file diff --git a/public/views/user.html b/public/views/user.html new file mode 100644 index 0000000..e69de29 diff --git a/server.js b/server.js new file mode 100644 index 0000000..5a15536 --- /dev/null +++ b/server.js @@ -0,0 +1,64 @@ +// modules +var express = require('express'), + app = express(), + bodyParser = require('body-parser'), + methodOverride = require('method-override'), + morgan = require('morgan'), + errorHandler = require('errorhandler'), + FileStreamRotator = require('file-stream-rotator'), + fs = require('fs'), + mongoose = require('mongoose'); + +//config +var db = require('./config/db')[process.env.NODE_ENV], + server = require('./config/server')[process.env.NODE_ENV], + logDir = __dirname + '/logs'; + +fs.existsSync(logDir) || fs.mkdirSync(logDir); + +var accessLogStream = FileStreamRotator.getStream({ + filename : logDir + '/access-%DATE%.log', + frequency : 'daily', + verbose : false, + date_format: 'YYYY-MM-DD' + }); + +mongoose.connect(db.url); + +/** Hack to load Models before routing **/ +var models_path = __dirname + '/app/models' +fs.readdirSync(models_path).forEach(function (file) { + if (~file.indexOf('.js')) require(models_path + '/' + file) +}) + +switch(process.env.NODE_ENV) { + case 'development' : + app.use(morgan('dev')); + break; + case 'production' : + app.use(morgan('combined', {stream: accessLogStream})); + break; +} + +app.use(bodyParser.json()); +app.use(bodyParser.json({type: 'application/vnd.api+json'})); +app.use(bodyParser.urlencoded({extended: true})); +app.use(methodOverride('X-HTTP-Method-Override')); +app.use(express.static(__dirname + '/public')); +app.use(errorHandler(server.errorHandlerOptions)); + +require('./app/routes')(app); + +this.app = app; +this.server = server; + +exports.listen = function () { + if( process.env.NODE_ENV !== 'test' ) { + console.log('Server running in ' + process.env.NODE_ENV + ' mode on port ' + this.server.port ); + } + return this.app.listen.apply(this.app, [this.server.port]); +}; + +exports.close = function (callback) { + this.app.close.apply(callback); +}; \ No newline at end of file diff --git a/test/accounts.js b/test/accounts.js new file mode 100644 index 0000000..0221ce9 --- /dev/null +++ b/test/accounts.js @@ -0,0 +1,543 @@ +var should = require('should'), + request = require('supertest'), + app = require('../server.js'), + Db = require('./db.js'), + globalServer, token, hacker_token, account_id; + +describe('API /accounts', function() { + + before( function(done) { + globalServer = app.listen(); + token = Db.get_user_token(); + hacker_token = Db.get_hacker_token(); + account_id = Db.ACCOUNT_ID; + Db.init(done); + }); + + after( function() { + globalServer.close(); + }); + + describe('* Creation', function() { + it('should create an account', function(done) { + request(globalServer) + .post('/api/accounts') + .send({ + name: 'Home', + reference: '1234567890' + }) + .set('Authorization', 'JWT ' + token) + .set('Accept', 'application/json') + .expect(201) + .expect('Content-Type', /json/) + .end( function(error, result) { + should.not.exist(error); + var account = result.body; + should.exist(account); + account.name.should.be.equal('Home'); + account.reference.should.be.equal('1234567890'); + done(); + }); + }); + + it('should fail to create account without params', function(done) { + request(globalServer) + .post('/api/accounts') + .set('Authorization', 'JWT ' + token) + .set('Accept', 'application/json') + .expect(400) + .expect('Content-Type', /json/) + .end( function(error, result) { + var errors = result.body; + should.exist(errors); + errors.should.be.instanceof(Array).and.have.lengthOf(1); + var error = errors[0]; + error.field.should.be.equal('name'); + done(); + }); + }); + + it('should fail to create account without valid token', function(done) { + request(globalServer) + .post('/api/accounts') + .send({ + name: 'Home', + reference: '1234567890' + }) + .set('Authorization', 'JWT fake') + .expect(401, done); + }); + + it('should fail to create account without token', function(done) { + request(globalServer) + .post('/api/accounts') + .send({ + name: 'Home', + reference: '1234567890' + }) + .expect(401, done); + }); + }); + + describe('* Deletion', function() { + it('should delete the given account', function(done) { + request(globalServer) + .post('/api/accounts') + .send({ + name: 'Todelete', + reference: '0987654321' + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var account_to_delete_id = result.body._id; + + request(globalServer) + .delete('/api/accounts/' + account_to_delete_id) + .set('Authorization', 'JWT ' + token) + .set('Accept', 'application/json') + .expect(204, done); + }); + }); + + it('should fail to delete unknown account', function(done) { + request(globalServer) + .delete('/api/accounts/4fc67871349bb7bf6a000002') + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to delete invalid account', function(done) { + request(globalServer) + .delete('/api/accounts/1') + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to delete account for another user', function(done) { + request(globalServer) + .post('/api/accounts') + .send({ + name: 'Todelete', + reference: '0987654321' + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var account_to_delete_id = result.body._id; + request(globalServer) + .delete('/api/accounts/' + account_to_delete_id) + .set('Authorization', 'JWT ' + hacker_token) + .expect(401, done); + }); + }); + }); + + describe('* Retrieve', function() { + it('should retrieve the given account', function(done) { + request(globalServer) + .get('/api/accounts/' + account_id) + .set('Authorization', 'JWT ' + token) + .expect(200) + .expect('Content-Type', /json/) + .end( function(error, result) { + should.not.exist(error); + + var account = result.body; + should.exist(account); + account.name.should.be.equal('Default'); + account.reference.should.be.equal('1234567890'); + done(); + }) + }); + + it('should fail to retrieve an unknown account', function(done) { + request(globalServer) + .get('/api/accounts/4fc67871349bb7bf6a000002') + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to retrieve an invalid account', function(done) { + request(globalServer) + .get('/api/accounts/1') + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to retrieve the account for another user', function(done) { + request(globalServer) + .get('/api/accounts/' + account_id) + .set('Authorization', 'JWT ' + hacker_token) + .expect(401, done); + }); + }); + + describe('* Modify', function() { + it('should modify the given account', function(done) { + request(globalServer) + .put('/api/accounts/' + account_id) + .send( { + name: 'Home 2', + reference: '0987654321' + }) + .set('Authorization', 'JWT ' + token) + .expect(200) + .expect('Content-Type', /json/) + .end(function(error, result) { + should.not.exist(error); + + var account = result.body; + should.exist(account); + account.name.should.be.equal('Home 2'); + account.reference.should.be.equal('0987654321'); + + done(); + }); + }); + + it('should fail to modify without arguments', function(done) { + request(globalServer) + .put('/api/accounts/' + account_id) + .set('Authorization', 'JWT ' + token) + .expect(400, done) + }); + + it('should fail to modify missing arguments', function(done) { + request(globalServer) + .put('/api/accounts/' + account_id) + .send({reference: 'AZERTY'}) + .set('Authorization', 'JWT ' + token) + .expect(400, done); + }); + + it('should fail to modify invalid account', function(done) { + request(globalServer) + .put('/api/accounts/1') + .set('Authorization', 'JWT ' + token) + .expect(404, done) + }); + + it('should fail to modify account for another user', function(done) { + request(globalServer) + .put('/api/accounts/' + account_id) + .set('Authorization', 'JWT ' + hacker_token) + .expect(401, done) + }); + }); + + describe('* Entries', function() { + describe('* Creation', function() { + it('should create an entry with minimal data (DEPOSIT)' , function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + amount: 1000, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .expect(201) + .expect('Content-Type', /json/) + .end(function(error, result) { + should.not.exist(error); + + var entry = result.body; + should.exist(entry); + entry.amount.should.be.equal(1000); + new Date(entry.date).should.eql(new Date(2014, 11, 8)); + entry.type.should.be.equal('DEPOSIT'); + should.not.exist(entry.category); + should.not.exist(entry.sub_category); + done(); + }); + }); + it('should create an entry with minimal data (BILL)' , function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: -1000, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .expect(201) + .expect('Content-Type', /json/) + .end(function(error, result) { + should.not.exist(error); + + var entry = result.body; + should.exist(entry); + entry.amount.should.be.equal(-1000); + new Date(entry.date).should.eql(new Date(2014, 11, 8)); + entry.type.should.be.equal('BILL'); + should.not.exist(entry.category); + should.not.exist(entry.sub_category); + done(); + }); + }); + + it('should fail to create entry without data', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .set('Authorization', 'JWT ' + token) + .expect(400, done); + }); + + it('should fail to create entry for not owned account', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .set('Authorization', 'JWT ' + hacker_token) + .send({ + label: 'test', + amount: -1000, + date: new Date('2014-12-08') + }) + .expect(401, done); + }); + + it('should fail to create entry for not valid account', function(done) { + request(globalServer) + .post('/api/accounts/1/entries') + .send({ + label: 'test', + amount: -1000, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to create entry for unknown account', function(done) { + request(globalServer) + .post('/api/accounts/' + token + '/entries') + .send({ + label: 'test', + amount: -1000, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + }); + + describe('* Modify', function() { + it('should modify the given entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + account_id + '/entries/' + entry_id) + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + token) + .expect(200) + .expect('Content-Type', /json/) + .end( function(errors, result) { + should.not.exist(errors); + + var entry = result.body; + should.exist(entry); + entry.label.should.be.equal('modified'); + entry.amount.should.be.equal(55); + new Date(entry.date).should.eql(new Date(2014,11,9)); + + done(); + }); + }); + }); + + it('should fail to modify the given entry without data', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + account_id + '/entries/' + entry_id) + .set('Authorization', 'JWT ' + token) + .expect(400, done); + }); + }); + + it('should fail to modify unknown entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + account_id + '/entries/' + token) + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + }); + + it('should fail to modify invalid entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + account_id + '/entries/1') + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + }); + + it('should fail to modify the given entry for unknown account', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + token + '/entries/' + entry_id) + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + }); + + it('should fail to modify the given entry for invalid account', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/1/entries/' + entry_id) + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + }); + + it('should fail to modify the given not owned entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .put('/api/accounts/' + account_id + '/entries/' + entry_id) + .send({ + label: 'modified', + amount: 55, + date: new Date('2014-12-09') + }) + .set('Authorization', 'JWT ' + hacker_token) + .expect(401, done); + }); + }); + }); + + describe('* Deletion', function() { + it('should delete the given entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .delete('/api/accounts/' + account_id + '/entries/' + entry_id) + .set('Authorization', 'JWT ' + token) + .expect(204, done); + }); + }); + + it('should fail to delete an unknown entry', function(done) { + request(globalServer) + .delete('/api/accounts/' + account_id + '/entries/' + token) + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to delete an invalid entry', function(done) { + request(globalServer) + .delete('/api/accounts/' + account_id + '/entries/1') + .set('Authorization', 'JWT ' + token) + .expect(404, done); + }); + + it('should fail to delete the not owned given entry', function(done) { + request(globalServer) + .post('/api/accounts/' + account_id + '/entries') + .send({ + label: 'test', + amount: 50, + date: new Date('2014-12-08') + }) + .set('Authorization', 'JWT ' + token) + .end(function(error, result) { + var entry_id = result.body._id; + request(globalServer) + .delete('/api/accounts/' + account_id + '/entries/' + entry_id) + .set('Authorization', 'JWT ' + hacker_token) + .expect(401, done); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/db.js b/test/db.js new file mode 100644 index 0000000..11fe873 --- /dev/null +++ b/test/db.js @@ -0,0 +1,119 @@ +var mongoose = require('mongoose'), + jwt = require('jsonwebtoken'), + security = require('../config/security'); + + +var USER_ID = '55c9e2e3d300cc798928cc87', + HACKER_ID = '55c9e2e4d300cc798928cc88', + ACCOUNT_ID = '55c9e2fcd300cc798928cc8b'; + +var DATA = { + User: [ + { + _id: USER_ID, + username: 'test', + password: 's3cr3t' + }, + { + _id: HACKER_ID, + username: 'hacker', + password: 'bl4ckh4t' + } + ], + Account: [ + { + _id: ACCOUNT_ID, + name: 'Default', + reference: '1234567890', + user_id: USER_ID, + categories: [{key: 'test', label: 'Test', sub_categories: []}] + } + ], + Entry: [ + { + account_id: ACCOUNT_ID, + label: 'Test bill', + type: 'BILL', + amount: -100, + date: '2015-08-13', + } + ] + }, + + process_collection = function(collection, data, done) { + mongoose.connection.base.models[collection].find({}).remove(function(err) { + if (err) { + console.log('Can\'t delete collection ' + collection, err ); + } + var res = []; + for( var item in data ) { + mongoose.connection.base.models[collection].create(data[item], function(err, newItem) { + res.push(err); + if( err ) { + console.log('Can\'t insert document', err); + } + if (res.length === data.length) { + return done(); + } + + newItem.save(function(error) { + res.push(err); + if (res.length === data.length) { + return done(); + } + }); + }); + } + }); + }, + + drop_collection = function(collection, data, done) { + mongoose.connection.base.models[collection].find({}).remove(function(err) { + if (err) { + console.log('Can\'t delete collection ' + collection, err ); + } + return done(); + }); + }; + +module.exports = { + USER_ID: USER_ID, + HACKER_ID: HACKER_ID, + ACCOUNT_ID: ACCOUNT_ID, + + init : function(done) { + var collections_to_process = Object.keys(DATA).length, + collectionsDone = 0; + + for( var collection in DATA ) { + process_collection(collection, DATA[collection], function() { + collectionsDone++; + if( collectionsDone === collections_to_process ) { + done(); + } + }) + } + }, + + drop : function(done) { + var collections_to_process = Object.keys(DATA).length, + collectionsDone = 0; + + for( var collection in DATA ) { + drop_collection(collection, DATA[collection], function() { + collectionsDone++; + if( collectionsDone === collections_to_process ) { + done(); + } + }) + } + }, + + get_user_token: function() { + return jwt.sign( { user_id: USER_ID}, security.jwt.secretOrKey); + }, + + get_hacker_token: function() { + return jwt.sign( { user_id: HACKER_ID}, security.jwt.secretOrKey); + } +} \ No newline at end of file diff --git a/test/server.js b/test/server.js new file mode 100644 index 0000000..6b5c6f0 --- /dev/null +++ b/test/server.js @@ -0,0 +1,23 @@ +var should = require('should'), + request = require('supertest'), + app = require('../server.js'), + globalServer; + +describe('Static resources', function(){ + + before(function () { + globalServer = app.listen(); + }); + + after(function () { + globalServer.close(); + }); + + it('should send index.html', function(done){ + request(globalServer) + .get('/') + .set('Accept', 'text/html') + .expect('Content-Type', /html/) + .expect(200, done); + }) +}) \ No newline at end of file diff --git a/test/users.js b/test/users.js new file mode 100644 index 0000000..cad7dd7 --- /dev/null +++ b/test/users.js @@ -0,0 +1,198 @@ +var should = require('should'), + request = require('supertest'), + app = require('../server.js'), + mongoose = require('mongoose'), + User = mongoose.model('User'), + Db = require('./db.js'), + sinon = require('sinon'), + EventEmitter = require('../app/events/listeners'), + globalServer, token, hacker_token, account_id, user_id; + +describe('API /users', function() { + + before(function(done) { + globalServer = app.listen(); + token = Db.get_user_token(); + hacker_token = Db.get_hacker_token(); + account_id = Db.ACCOUNT_ID; + user_id = Db.USER_ID; + Db.init(done); + }); + + after( function() { + globalServer.close(); + }); + + describe('* Login', function() { + + it('should log successfully', function(done) { + request(globalServer) + .post('/api/users/login') + .send({ + username: 'test', + password: 's3cr3t' + }) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end( function(error, result) { + should.not.exist(error); + + var user = result.body; + should.exist(user); + user.username.should.be.equal('test'); + should.exist(user.token); + done(); + }); + }); + + it('should fail login', function(done) { + request(globalServer) + .post('/api/users/login') + .send({ + username: 'test', + password: 'secret' + }) + .set('Accept', 'application/json') + .expect(401, done); + }); + + it('should logout', function(done) { + request(globalServer) + .delete('/api/users/login') + .expect(200, done); + }); + }); + + describe('* Registration', function() { + + it('should fail without any params', function(done) { + request(globalServer) + .post('/api/users') + .set('Accept', 'application/json') + .expect(400) + .end(function(err, result) { + var errors = result.body; + should.exist(errors); + errors.should.be.instanceof(Array).and.have.lengthOf(2); + done(); + }); + }); + + it('should fail without a password', function(done) { + request(globalServer) + .post('/api/users') + .send( { username: 'registration'}) + .expect(400, done); + }); + + it('should fail without an username', function(done) { + request(globalServer) + .post('/api/users') + .send({password: 'secret'}) + .set('Accept', 'application/json') + .expect(400, done); + }); + + it('should fail on duplicate account', function(done) { + request(globalServer) + .post('/api/users') + .send({ + username: 'test', + password: 'secret' + }) + .set('Accept', 'application/json') + .expect(409, done); + }); + + it('should register successfully', function(done) { + request(globalServer) + .post('/api/users') + .send({ + username: 'registration', + password: 'secret' + }) + .set('Accept', 'application/json') + .expect(201) + .end(function(error, result) { + + should.not.exist(error); + var user = result.body; + should.exist(user); + user.username.should.be.equal('registration'); + should.exist(user.token); + User.getAuthenticated('registration', 'secret', function(error, user) { + should.not.exist(error); + should.exist(user); + done(); + }); + }); + }); + + }); + + describe('* Deregistration', function() { + it('should fail to delete user account without security token', function(done) { + request(globalServer) + .delete('/api/users') + .expect(401, done); + }); + + it('should fail to delete user account with fake security token', function(done) { + request(globalServer) + .delete('/api/users') + .set('Authorization', 'JWT fake_token') + .expect(401, done); + }); + + it('should delete user with accounts and entries', function(done) { + var eventEmitter= EventEmitter.eventEmitter, + spy_accounts = sinon.spy(), + spy_entries = sinon.spy(); + + eventEmitter.on(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, spy_accounts); + eventEmitter.on(EventEmitter.events.ENTRIES_DELETE_BY_ACCOUNT_EVT, spy_entries) + + request(globalServer) + .delete('/api/users') + .set('Authorization', 'JWT ' + token) + .expect(204) + .end(function(error, result) { + User.findOne({username: 'test'}, function(error, user) { + should.not.exist(error); + should.not.exist(user); + + sinon.assert.calledWith(spy_accounts, user_id); + spy_entries.called.should.equal.true; + spy_entries.args[0][0].id.should.be.equal(account_id); + done(); + }); + }); + }); + + it('should delete user without account', function(done) { + var eventEmitter= EventEmitter.eventEmitter, + spy_accounts = sinon.spy(), + spy_entries = sinon.spy(); + + eventEmitter.on(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, spy_accounts); + eventEmitter.on(EventEmitter.events.ENTRIES_DELETE_BY_ACCOUNT_EVT, spy_entries) + + request(globalServer) + .delete('/api/users') + .set('Authorization', 'JWT ' + hacker_token) + .expect(204) + .end(function(error, result) { + User.findOne({username: 'hacker'}, function(error, user) { + should.not.exist(error); + should.not.exist(user); + + spy_accounts.called.should.equal.true; + spy_entries.called.should.equal.false; + + done(); + }); + }); + }); + }); +}); \ No newline at end of file