Merge branch 'release/2.11.0' into production

This commit is contained in:
Miroslav Bajtoš 2015-01-27 09:47:43 +01:00
commit 4d54ad2d6c
15 changed files with 286 additions and 46 deletions

View File

@ -1,3 +1,27 @@
2015-01-27, Version 2.11.0
==========================
* Document options for persistedmodel.save() (Rand McKinney)
* Add test case to demonstrate url-encoded http path (Pradnya Baviskar)
* Fix JSdocs per #888 (crandmck)
* Add test case for loopback issue #698 (Pradnya Baviskar)
* Remove usages of deprecated `req.param()` (Miroslav Bajtoš)
* Add error code property to known error responses. (Ron Edgecomb)
* test: use 127.0.0.1 instead of localhost (Ryan Graham)
* Extend AccessToken to parse Basic auth headers (Ryan Graham)
* tests: fix Bearer token test (Ryan Graham)
* don't send queries to the DB when no changes are detected (bitmage)
2015-01-16, Version 2.10.2
==========================
@ -755,6 +779,10 @@
* Enhance the error message (Raymond Feng)
2014-07-16, Version 2.0.0-beta7
===============================
* Bump version (Raymond Feng)
* 2.0.0-beta6 (Miroslav Bajtoš)
@ -895,13 +923,6 @@
2014-07-16, Version 1.10.0
==========================
2014-07-16, Version 2.0.0-beta7
===============================
* Bump version (Raymond Feng)
* Remove unused dep (Raymond Feng)
* Bump version and update deps (Raymond Feng)
@ -1890,18 +1911,19 @@
* Add Model.requireToken, default swagger to false (Ritchie Martori)
* Add password reset (Ritchie Martori)
2013-12-06, Version 1.3.3
=========================
* Bump version (Raymond Feng)
* Add password reset (Ritchie Martori)
2013-12-06, Version show
========================
2013-12-06, Version 1.3.3
=========================
* Bump version (Raymond Feng)
* Make loopback-datasource-juggler a peer dep (Raymond Feng)
@ -2189,12 +2211,12 @@
* Update docs for api->project rename. (Michael Schoonmaker)
2013-09-12, Version strongloopsuite-1.0.0-5
2013-09-12, Version strongloopsuite-1.0.0-4
===========================================
2013-09-12, Version strongloopsuite-1.0.0-4
2013-09-12, Version strongloopsuite-1.0.0-5
===========================================
* Update docs for api->project rename. (Michael Schoonmaker)

View File

@ -108,6 +108,7 @@ module.exports = function(AccessToken) {
} else {
var e = new Error('Invalid Access Token');
e.status = e.statusCode = 401;
e.code = 'INVALID_TOKEN';
cb(e);
}
});
@ -171,7 +172,12 @@ module.exports = function(AccessToken) {
cookies = cookies.concat(['access_token', 'authorization']);
for (length = params.length; i < length; i++) {
id = req.param(params[i]);
var param = params[i];
// replacement for deprecated req.param()
id = req.params && req.params[param] !== undefined ? req.params[param] :
req.body && req.body[param] !== undefined ? req.body[param] :
req.query && req.query[param] !== undefined ? req.query[param] :
undefined;
if (typeof id === 'string') {
return id;
@ -189,6 +195,20 @@ module.exports = function(AccessToken) {
// Decode from base64
var buf = new Buffer(id, 'base64');
id = buf.toString('utf8');
} else if (/^Basic /i.test(id)) {
id = id.substring(6);
id = (new Buffer(id, 'base64')).toString('utf8');
// The spec says the string is user:pass, so if we see both parts
// we will assume the longer of the two is the token, so we will
// extract "a2b2c3" from:
// "a2b2c3"
// "a2b2c3:" (curl http://a2b2c3@localhost:3000/)
// "token:a2b2c3" (curl http://token:a2b2c3@localhost:3000/)
// ":a2b2c3"
var parts = /^([^:]*):(.*)$/.exec(id);
if (parts) {
id = parts[2].length > parts[1].length ? parts[2] : parts[1];
}
}
return id;
}

View File

@ -344,6 +344,9 @@ module.exports = function(Change) {
*/
Change.diff = function(modelName, since, remoteChanges, callback) {
if (!Array.isArray(remoteChanges) || remoteChanges.length === 0) {
return callback(null, {deltas: [], conflicts: []});
}
var remoteChangeIndex = {};
var modelIds = [];
remoteChanges.forEach(function(ch) {

View File

@ -167,17 +167,20 @@ module.exports = function(User) {
if (realmRequired && !query.realm) {
var err1 = new Error('realm is required');
err1.statusCode = 400;
err1.code = 'REALM_REQUIRED';
return fn(err1);
}
if (!query.email && !query.username) {
var err2 = new Error('username or email is required');
err2.statusCode = 400;
err2.code = 'USERNAME_EMAIL_REQUIRED';
return fn(err2);
}
self.findOne({where: query}, function(err, user) {
var defaultError = new Error('login failed');
defaultError.statusCode = 401;
defaultError.code = 'LOGIN_FAILED';
if (err) {
debug('An error is reported from User.findOne: %j', err);
@ -193,6 +196,7 @@ module.exports = function(User) {
debug('User email has not been verified');
err = new Error('login failed as the email has not been verified');
err.statusCode = 401;
err.code = 'LOGIN_FAILED_EMAIL_NOT_VERIFIED';
return fn(err);
} else {
user.createAccessToken(credentials.ttl, function(err, token) {
@ -396,9 +400,11 @@ module.exports = function(User) {
if (user) {
err = new Error('Invalid token: ' + token);
err.statusCode = 400;
err.code = 'INVALID_TOKEN';
} else {
err = new Error('User not found: ' + uid);
err.statusCode = 404;
err.code = 'USER_NOT_FOUND';
}
fn(err);
}
@ -447,7 +453,7 @@ module.exports = function(User) {
} else {
var err = new Error('email is required');
err.statusCode = 400;
err.code = 'EMAIL_REQUIRED';
cb(err);
}
};
@ -564,7 +570,17 @@ module.exports = function(User) {
UserModel.on('attached', function() {
UserModel.afterRemote('confirm', function(ctx, inst, next) {
if (ctx.req) {
ctx.res.redirect(ctx.req.param('redirect'));
// replacement for deprecated req.param()
var params = ctx.req.params;
var body = ctx.req.body;
var query = ctx.req.query;
var redirectUrl =
params && params.redirect !== undefined ? params.redirect :
body && body.redirect !== undefined ? body.redirect :
query && query.redirect !== undefined ? query.redirect :
undefined;
ctx.res.redirect(redirectUrl);
} else {
next(new Error('transport unsupported'));
}

View File

@ -296,7 +296,15 @@ app.enableAuth = function() {
var req = ctx.req;
var Model = method.ctor;
var modelInstance = ctx.instance;
var modelId = modelInstance && modelInstance.id || req.param('id');
var modelId = modelInstance && modelInstance.id ||
// replacement for deprecated req.param()
(req.params && req.params.id !== undefined ? req.params.id :
req.body && req.body.id !== undefined ? req.body.id :
req.query && req.query.id !== undefined ? req.query.id :
undefined);
var modelName = Model.modelName;
var modelSettings = Model.settings || {};
var errStatusCode = modelSettings.aclErrorStatus || app.get('aclErrorStatus') || 401;
@ -319,13 +327,23 @@ app.enableAuth = function() {
} else {
var messages = {
403:'Access Denied',
404: ('could not find a model with id ' + modelId),
401:'Authorization Required'
403: {
message: 'Access Denied',
code: 'ACCESS_DENIED'
},
404: {
message: ('could not find ' + modelName + ' with id ' + modelId),
code: 'MODEL_NOT_FOUND'
},
401: {
message: 'Authorization Required',
code: 'AUTHORIZATION_REQUIRED'
}
};
var e = new Error(messages[errStatusCode] || messages[403]);
var e = new Error(messages[errStatusCode].message || messages[403].message);
e.statusCode = errStatusCode;
e.code = messages[errStatusCode].code || messages[403].code;
next(e);
}
}

View File

@ -152,7 +152,7 @@ Model.setup = function() {
} else {
err = new Error('could not find a model with id ' + id);
err.statusCode = 404;
err.code = 'MODEL_NOT_FOUND';
fn(err);
}
});
@ -455,6 +455,7 @@ Model.hasManyRemoting = function(relationName, relation, define) {
var msg = 'Unknown "' + toModelName + '" id "' + fk + '".';
var error = new Error(msg);
error.statusCode = error.status = 404;
error.code = 'MODEL_NOT_FOUND';
cb(error);
}
@ -552,6 +553,7 @@ Model.hasManyRemoting = function(relationName, relation, define) {
var msg = 'Unknown "' + modelName + '" id "' + id + '".';
var error = new Error(msg);
error.statusCode = error.status = 404;
error.code = 'MODEL_NOT_FOUND';
cb(error);
} else {
cb();

View File

@ -73,6 +73,7 @@ function convertNullToNotFoundError(ctx, cb) {
var msg = 'Unknown "' + modelName + '" id "' + id + '".';
var error = new Error(msg);
error.statusCode = error.status = 404;
error.code = 'MODEL_NOT_FOUND';
cb(error);
}
@ -102,7 +103,8 @@ PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, ca
* Find one record, same as `find`, but limited to one object. Returns an object, not collection.
* If not found, create the object using data provided as second argument.
*
* @param {Object} query Search conditions: {where: {test: 'me'}}.
* @param {Object} where Where clause, such as `{where: {test: 'me'}}`
* <br/>see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter).
* @param {Object} data Object to create.
* @param {Function} cb Callback called with `cb(err, instance)` signature.
*/
@ -117,7 +119,7 @@ PersistedModel.findOrCreate._delegate = true;
* Check whether a model instance exists in database.
*
* @param {id} id Identifier of object (primary key value)
* @param {Function} cb Callback function called with (err, exists: Bool)
* @param {Function} cb Callback function called with `(err, exists: Bool)`
*/
PersistedModel.exists = function exists(id, cb) {
@ -174,8 +176,6 @@ PersistedModel.find = function find(params, cb) {
* <br/>See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter).
* @property {String|Object|Array} include See PersistedModel.include documentation.
* <br/>See [Include filter](http://docs.strongloop.com/display/LB/Include+filter).
* @property {Number} limit Maximum number of instances to return.
* <br/>See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter).
* @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending.
* <br/>See [Order filter](http://docs.strongloop.com/display/LB/Order+filter).
* @property {Number} skip Number of results to skip.
@ -195,13 +195,14 @@ PersistedModel.findOne = function findOne(params, cb) {
};
/**
* Destroy all model instances that match the optional `filter` specification.
* Destroy all model instances that match the optional `where` specification.
*
* @options {Object} [where] Optional where filter JSON object; see below.
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* <br/>See [Where filter](http://docs.strongloop.com/display/LB/Where+filter).
*
* @param {Function} [cb] - callback called with `(err)`.
*/
@ -236,6 +237,7 @@ PersistedModel.deleteAll = PersistedModel.destroyAll;
* ```
* { key: val, key2: {gt: 'val2'}, ...}
* ```
* <br/>see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter).
* @param {Object} data Changes to be made
* @param {Function} cb Callback function called with (err, count).
*/
@ -268,8 +270,8 @@ PersistedModel.removeById = PersistedModel.destroyById;
PersistedModel.deleteById = PersistedModel.destroyById;
/**
* Return the number of records that match the optional filter.
* @options {Object} [filter] Optional where filter JSON object; see below.
* Return the number of records that match the optional "where" filter.
* @options {Object} [where] Optional where filter JSON object; See [Where filter](http://docs.strongloop.com/display/LB/Where+filter).
* @property {Object} where Where clause, like
* ```
* { key: val, key2: {gt: 'val2'}, ...}
@ -285,8 +287,9 @@ PersistedModel.count = function(where, cb) {
* Save model instance. If the instance doesn't have an ID, then the [create](#persistedmodelcreatedata-cb) method is called instead.
* Triggers: validate, save, update, or create.
* @options {Object} [options] See below.
* @property {Boolean} validate
* @property {Boolean} throws
* @property {Boolean} validate Perform validation before saving. Default is true.
* @property {Boolean} throws Controls If true, throw a validation error; WARNING: This can crash Node.
* If false, report the error via callback. Default is false.
* @param {Function} [callback] Callback function called with (err, obj).
*/
@ -522,6 +525,7 @@ PersistedModel.setupRemoting = function() {
var msg = 'Unknown "' + modelName + '" id "' + id + '".';
var error = new Error(msg);
error.statusCode = error.status = 404;
error.code = 'MODEL_NOT_FOUND';
cb(error);
} else {
cb();
@ -728,6 +732,7 @@ PersistedModel.changes = function(since, filter, callback) {
modelName: this.modelName
}, function(err, changes) {
if (err) return callback(err);
if (!Array.isArray(changes) || changes.length === 0) return callback(null, []);
var ids = changes.map(function(change) {
return change.getModelId();
});

View File

@ -1,6 +1,6 @@
{
"name": "loopback",
"version": "2.10.2",
"version": "2.11.0",
"description": "LoopBack: Open Source Framework for Node.js",
"homepage": "http://loopback.io",
"keywords": [
@ -102,6 +102,6 @@
"url": "https://github.com/strongloop/loopback/blob/master/LICENSE"
},
"optionalDependencies": {
"sl-blip": "http://blip.strongloop.com/loopback@2.10.2"
"sl-blip": "http://blip.strongloop.com/loopback@2.11.0"
}
}

View File

@ -34,11 +34,53 @@ describe('loopback.token(options)', function() {
token = 'Bearer ' + new Buffer(token).toString('base64');
createTestAppAndRequest(this.token, done)
.get('/')
.set('authorization', this.token.id)
.set('authorization', token)
.expect(200)
.end(done);
});
describe('populating req.toen from HTTP Basic Auth formatted authorization header', function() {
it('parses "standalone-token"', function(done) {
var token = this.token.id;
token = 'Basic ' + new Buffer(token).toString('base64');
createTestAppAndRequest(this.token, done)
.get('/')
.set('authorization', this.token.id)
.expect(200)
.end(done);
});
it('parses "token-and-empty-password:"', function(done) {
var token = this.token.id + ':';
token = 'Basic ' + new Buffer(token).toString('base64');
createTestAppAndRequest(this.token, done)
.get('/')
.set('authorization', this.token.id)
.expect(200)
.end(done);
});
it('parses "ignored-user:token-is-password"', function(done) {
var token = 'username:' + this.token.id;
token = 'Basic ' + new Buffer(token).toString('base64');
createTestAppAndRequest(this.token, done)
.get('/')
.set('authorization', this.token.id)
.expect(200)
.end(done);
});
it('parses "token-is-username:ignored-password"', function(done) {
var token = this.token.id + ':password';
token = 'Basic ' + new Buffer(token).toString('base64');
createTestAppAndRequest(this.token, done)
.get('/')
.set('authorization', this.token.id)
.expect(200)
.end(done);
});
});
it('should populate req.token from a secure cookie', function(done) {
var app = createTestApp(this.token, done);
@ -149,7 +191,15 @@ describe('app.enableAuth()', function() {
.del('/tests/123')
.expect(401)
.set('authorization', this.token.id)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'AUTHORIZATION_REQUIRED');
done();
});
});
it('prevent remote call with app setting status on denied ACL', function(done) {
@ -157,7 +207,15 @@ describe('app.enableAuth()', function() {
.del('/tests/123')
.expect(403)
.set('authorization', this.token.id)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'ACCESS_DENIED');
done();
});
});
it('prevent remote call with app setting status on denied ACL', function(done) {
@ -165,7 +223,15 @@ describe('app.enableAuth()', function() {
.del('/tests/123')
.expect(404)
.set('authorization', this.token.id)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'MODEL_NOT_FOUND');
done();
});
});
it('prevent remote call if the accessToken is missing and required', function(done) {
@ -173,7 +239,15 @@ describe('app.enableAuth()', function() {
.del('/tests/123')
.expect(401)
.set('authorization', null)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'AUTHORIZATION_REQUIRED');
done();
});
});
it('stores token in the context', function(done) {

View File

@ -8,7 +8,7 @@ describe('RemoteConnector', function() {
before(function() {
// setup the remote connector
var ds = loopback.createDataSource({
url: 'http://localhost:3000/api',
url: 'http://127.0.0.1:3000/api',
connector: loopback.Remote
});
TestModel.attachTo(ds);

View File

@ -11,7 +11,7 @@ describe('Replication', function() {
before(function() {
// setup the remote connector
var ds = loopback.createDataSource({
url: 'http://localhost:3000/api',
url: 'http://127.0.0.1:3000/api',
connector: loopback.Remote
});
TestModel.attachTo(ds);

View File

@ -152,7 +152,15 @@ describe.onServer('Remote Methods', function() {
request(app)
.get('/users/not-found')
.expect(404)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'MODEL_NOT_FOUND');
done();
});
});
});

View File

@ -812,6 +812,7 @@ describe('relations - integration', function() {
this.get(url).expect(404, function(err, res) {
expect(res.body.error.status).to.be.equal(404);
expect(res.body.error.message).to.be.equal('Unknown "todoItem" id "2".');
expect(res.body.error.code).to.be.equal('MODEL_NOT_FOUND');
done();
});
});
@ -1273,6 +1274,7 @@ describe('relations - integration', function() {
expect(res.body.error).to.be.an.object;
var expected = 'could not find a model with id unknown';
expect(res.body.error.message).to.equal(expected);
expect(res.body.error.code).to.be.equal('MODEL_NOT_FOUND');
done();
});
});

View File

@ -18,7 +18,15 @@ describe('loopback.rest', function() {
app.use(loopback.rest());
request(app).get('/mymodels/1')
.expect(404)
.end(done);
.end(function(err, res) {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'MODEL_NOT_FOUND');
done();
});
});
it('should report 404 for HEAD /:id not found', function(done) {
@ -91,6 +99,32 @@ describe('loopback.rest', function() {
.expect(200, done);
});
it('allows models to provide a custom HTTP path', function(done) {
var ds = app.dataSource('db', { connector: loopback.Memory });
var CustomModel = ds.createModel('CustomModel',
{ name: String },
{ http: { 'path': 'domain1/CustomModelPath' }
});
app.model(CustomModel);
app.use(loopback.rest());
request(app).get('/domain1/CustomModelPath').expect(200).end(done);
});
it('should report 200 for url-encoded HTTP path', function(done) {
var ds = app.dataSource('db', { connector: loopback.Memory });
var CustomModel = ds.createModel('CustomModel',
{ name: String },
{ http: { path: 'domain%20one/CustomModelPath' }
});
app.model(CustomModel);
app.use(loopback.rest());
request(app).get('/domain%20one/CustomModelPath').expect(200).end(done);
});
it('includes loopback.token when necessary', function(done) {
givenUserModelWithAuth();
app.enableAuth();

View File

@ -126,6 +126,7 @@ describe('User', function() {
User.login({email: 'b@c.com'}, function(err, accessToken) {
assert(!accessToken, 'should not create a accessToken without a valid password');
assert(err, 'should not login without a password');
assert.equal(err.code, 'LOGIN_FAILED');
done();
});
});
@ -243,11 +244,20 @@ describe('User', function() {
it('Login should only allow correct credentials', function(done) {
User.login(invalidCredentials, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'LOGIN_FAILED');
assert(!accessToken);
done();
});
});
it('Login a user providing incomplete credentials', function(done) {
User.login(incompleteCredentials, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'USERNAME_EMAIL_REQUIRED');
done();
});
});
it('Login a user over REST by providing credentials', function(done) {
request(app)
.post('/users/login')
@ -279,6 +289,8 @@ describe('User', function() {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert.equal(errorResponse.code, 'LOGIN_FAILED');
done();
});
});
@ -293,6 +305,8 @@ describe('User', function() {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert.equal(errorResponse.code, 'USERNAME_EMAIL_REQUIRED');
done();
});
});
@ -308,6 +322,8 @@ describe('User', function() {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert.equal(errorResponse.code, 'USERNAME_EMAIL_REQUIRED');
done();
});
});
@ -370,6 +386,7 @@ describe('User', function() {
// strongloop/loopback#931
// error message should be "login failed" and not "login failed as the email has not been verified"
assert(err && !/verified/.test(err.message), ('expecting "login failed" error message, received: "' + err.message + '"'));
assert.equal(err.code, 'LOGIN_FAILED');
done();
});
});
@ -377,6 +394,7 @@ describe('User', function() {
it('Login a user by without email verification', function(done) {
User.login(validCredentials, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'LOGIN_FAILED_EMAIL_NOT_VERIFIED');
done();
});
});
@ -414,10 +432,14 @@ describe('User', function() {
.expect(401)
.send({ email: validCredentialsEmail })
.end(function(err, res) {
if (err) {
return done(err);
}
// strongloop/loopback#931
// error message should be "login failed" and not "login failed as the email has not been verified"
var errorResponse = res.body.error;
assert(errorResponse && !/verified/.test(errorResponse.message), ('expecting "login failed" error message, received: "' + errorResponse.message + '"'));
assert.equal(errorResponse.code, 'LOGIN_FAILED');
done();
});
});
@ -432,6 +454,8 @@ describe('User', function() {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert.equal(errorResponse.code, 'LOGIN_FAILED_EMAIL_NOT_VERIFIED');
done();
});
});
@ -530,6 +554,7 @@ describe('User', function() {
it('rejects a user by without realm', function(done) {
User.login(credentialWithoutRealm, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'REALM_REQUIRED');
done();
});
});
@ -537,6 +562,7 @@ describe('User', function() {
it('rejects a user by with bad realm', function(done) {
User.login(credentialWithBadRealm, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'LOGIN_FAILED');
done();
});
});
@ -544,6 +570,7 @@ describe('User', function() {
it('rejects a user by with bad pass', function(done) {
User.login(credentialWithBadPass, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'LOGIN_FAILED');
done();
});
});
@ -593,6 +620,7 @@ describe('User', function() {
function(done) {
User.login(credentialRealmInEmail, function(err, accessToken) {
assert(err);
assert.equal(err.code, 'REALM_REQUIRED');
done();
});
});
@ -841,7 +869,9 @@ describe('User', function() {
if (err) {
return done(err);
}
assert(res.body.error);
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'USER_NOT_FOUND');
done();
});
}, done);
@ -858,7 +888,9 @@ describe('User', function() {
if (err) {
return done(err);
}
assert(res.body.error);
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'INVALID_TOKEN');
done();
});
}, done);
@ -873,6 +905,7 @@ describe('User', function() {
it('Requires email address to reset password', function(done) {
User.resetPassword({ }, function(err) {
assert(err);
assert.equal(err.code, 'EMAIL_REQUIRED');
done();
});
});
@ -909,6 +942,9 @@ describe('User', function() {
if (err) {
return done(err);
}
var errorResponse = res.body.error;
assert(errorResponse);
assert.equal(errorResponse.code, 'EMAIL_REQUIRED');
done();
});
});