Feature: First version of the REST API done

This commit is contained in:
2015-08-14 08:04:31 +00:00
commit e6fc0be0ed
28 changed files with 3170 additions and 0 deletions

3
.bowerrc Normal file
View File

@@ -0,0 +1,3 @@
{
"directory": "public/libs"
}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
logs/*
data/*
mongod*
public/libs/*

1
README.md Normal file
View File

@@ -0,0 +1 @@
Work in progress

3
app.js Normal file
View File

@@ -0,0 +1,3 @@
var server = require('./server.js');
server.listen();

227
app/controllers/accounts.js Normal file
View File

@@ -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);
});
});
}
}

79
app/controllers/users.js Normal file
View File

@@ -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();
});
}
}

35
app/events/listeners.js Normal file
View File

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

18
app/helpers/handler.js Normal file
View File

@@ -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();
}
}
}

25
app/models/account.js Normal file
View File

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

1514
app/models/categories.js Normal file

File diff suppressed because it is too large Load Diff

20
app/models/entry.js Normal file
View File

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

87
app/models/user.js Normal file
View File

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

16
app/routes.js Normal file
View File

@@ -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');
});
};

22
app/routes/accounts.js Normal file
View File

@@ -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);
};

13
app/routes/users.js Normal file
View File

@@ -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);
};

40
app/security/passport.js Normal file
View File

@@ -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})
}

11
bower.json Normal file
View File

@@ -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"
}
}

11
config/db.js Normal file
View File

@@ -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'
},
}

7
config/security.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
jwt : {
secretOrKey : 's3cr3t',
issuer : undefined, // accounts.examplesoft.com
audience : undefined // yoursite.net
}
}

17
config/server.js Normal file
View File

@@ -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}
},
}

30
package.json Normal file
View File

@@ -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"
}
}

39
public/views/index.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<base href="/" />
<title>MEAN Demo Single Page Application</title>
<link rel="stylesheet" href="libs/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/style.css" />
<script src="libs/angular/angular.min.js" ></script>
<script src="libs/angular-route/angular-route.min.js" ></script>
<script src="js/controllers/MainCtrl.js"></script>
<script src="js/controllers/NerdCtrl.js"></script>
<script src="js/services/NerdService.js"></script>
<script src="js/appRoutes.js"></script>
<script src="js/app.js"></script>
</head>
<body ng-app="sampleApp" ng-controller="NerdController">
<div class="container">
<nav class="navbar navbar-inverse">
<div class="navbar-header">
<a class="navbar-brand" href="/">MEAN-Demo</a>
</div>
<ul class="nav navbar-nav">
<li><a href="/nerds">Nerds</a></li>
</ul>
</nav>
<div ng-view></div>
</div>
</body>
</html>

0
public/views/user.html Normal file
View File

64
server.js Normal file
View File

@@ -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);
};

543
test/accounts.js Normal file
View File

@@ -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);
});
});
});
});
});

119
test/db.js Normal file
View File

@@ -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);
}
}

23
test/server.js Normal file
View File

@@ -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);
})
})

198
test/users.js Normal file
View File

@@ -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();
});
});
});
});
});