Enable multiple user models

Allow LoopBack applications to configure multiple User models and share
the same AccessToken model.

To enable this feature:

1) In your custom AccessToken model:

 - add a new property "principalType" of type "string".
 - configure the relation "belongsTo user" as polymorphic,
   using "principalType" as the discriminator

2) In your User models:

 - Configure the "hasMany accessTokens" relation as polymorphic,
   using "principalType" as the discriminator

When creating custom Role and Principal instances, set your
User model's name as the value of "prinicipalType".
This commit is contained in:
Eric 2016-11-21 21:51:43 +01:00 committed by Miroslav Bajtoš
parent 798ebfba81
commit 9fe084fffd
9 changed files with 597 additions and 91 deletions

View File

@ -146,6 +146,16 @@ module.exports = function(AccessToken) {
var userRelation = AccessToken.relations.user; // may not be set up var userRelation = AccessToken.relations.user; // may not be set up
var User = userRelation && userRelation.modelTo; var User = userRelation && userRelation.modelTo;
// redefine user model if accessToken's principalType is available
if (this.principalType) {
User = AccessToken.registry.findModel(this.principalType);
if (!User) {
process.nextTick(function() {
return cb(null, false);
});
}
}
var now = Date.now(); var now = Date.now();
var created = this.created.getTime(); var created = this.created.getTime();
var elapsedSeconds = (now - created) / 1000; var elapsedSeconds = (now - created) / 1000;
@ -157,14 +167,18 @@ module.exports = function(AccessToken) {
elapsedSeconds < secondsToLive; elapsedSeconds < secondsToLive;
if (isValid) { if (isValid) {
process.nextTick(function() {
cb(null, isValid); cb(null, isValid);
});
} else { } else {
this.destroy(function(err) { this.destroy(function(err) {
cb(err, isValid); cb(err, isValid);
}); });
} }
} catch (e) { } catch (e) {
process.nextTick(function() {
cb(e); cb(e);
});
} }
}; };

View File

@ -395,6 +395,7 @@ module.exports = function(ACL) {
self.resolveRelatedModels(); self.resolveRelatedModels();
var roleModel = self.roleModel; var roleModel = self.roleModel;
context.registry = this.registry;
if (!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }
@ -480,6 +481,7 @@ module.exports = function(ACL) {
assert(token, 'Access token is required'); assert(token, 'Access token is required');
if (!callback) callback = utils.createPromiseCallback(); if (!callback) callback = utils.createPromiseCallback();
var context = new AccessContext({ var context = new AccessContext({
registry: this.registry,
accessToken: token, accessToken: token,
model: model, model: model,
property: method, property: method,
@ -514,6 +516,7 @@ module.exports = function(ACL) {
cb = cb || utils.createPromiseCallback(); cb = cb || utils.createPromiseCallback();
type = type || ACL.ROLE; type = type || ACL.ROLE;
this.resolveRelatedModels(); this.resolveRelatedModels();
switch (type) { switch (type) {
case ACL.ROLE: case ACL.ROLE:
this.roleModel.findOne({where: {or: [{name: id}, {id: id}]}}, cb); this.roleModel.findOne({where: {or: [{name: id}, {id: id}]}}, cb);
@ -527,12 +530,21 @@ module.exports = function(ACL) {
{where: {or: [{name: id}, {email: id}, {id: id}]}}, cb); {where: {or: [{name: id}, {email: id}, {id: id}]}}, cb);
break; break;
default: default:
// try resolving a user model that matches principalType
var userModel = this.registry.findModel(type);
if (userModel) {
userModel.findOne(
{where: {or: [{username: id}, {email: id}, {id: id}]}},
cb);
} else {
process.nextTick(function() { process.nextTick(function() {
var err = new Error(g.f('Invalid principal type: %s', type)); var err = new Error(g.f('Invalid principal type: %s', type));
err.statusCode = 400; err.statusCode = 400;
err.code = 'INVALID_PRINCIPAL_TYPE';
cb(err); cb(err);
}); });
} }
}
return cb.promise; return cb.promise;
}; };

View File

@ -63,9 +63,17 @@ module.exports = function(RoleMapping) {
RoleMapping.prototype.user = function(callback) { RoleMapping.prototype.user = function(callback) {
callback = callback || utils.createPromiseCallback(); callback = callback || utils.createPromiseCallback();
this.constructor.resolveRelatedModels(); this.constructor.resolveRelatedModels();
var userModel;
if (this.principalType === RoleMapping.USER) { if (this.principalType === RoleMapping.USER) {
var userModel = this.constructor.userModel; userModel = this.constructor.userModel;
userModel.findById(this.principalId, callback);
return callback.promise;
}
// try resolving a user model that matches principalType
userModel = this.constructor.registry.findModel(this.principalType);
if (userModel) {
userModel.findById(this.principalId, callback); userModel.findById(this.principalId, callback);
} else { } else {
process.nextTick(function() { process.nextTick(function() {

View File

@ -9,7 +9,7 @@
}, },
"principalType": { "principalType": {
"type": "string", "type": "string",
"description": "The principal type, such as user, application, or role" "description": "The principal type, such as USER, APPLICATION, ROLE, or user model name in case of multiple user models"
}, },
"principalId": { "principalId": {
"type": "string", "type": "string",

View File

@ -9,9 +9,9 @@ var debug = require('debug')('loopback:security:role');
var assert = require('assert'); var assert = require('assert');
var async = require('async'); var async = require('async');
var utils = require('../../lib/utils'); var utils = require('../../lib/utils');
var ctx = require('../../lib/access-context');
var AccessContext = require('../../lib/access-context').AccessContext; var AccessContext = ctx.AccessContext;
var Principal = ctx.Principal;
var RoleMapping = loopback.RoleMapping; var RoleMapping = loopback.RoleMapping;
assert(RoleMapping, 'RoleMapping model must be defined before Role model'); assert(RoleMapping, 'RoleMapping model must be defined before Role model');
@ -70,7 +70,8 @@ module.exports = function(Role) {
callback = utils.createPromiseCallback(); callback = utils.createPromiseCallback();
} }
} }
if (!query) query = {}; query = query || {};
query.where = query.where || {};
roleModel.resolveRelatedModels(); roleModel.resolveRelatedModels();
var relsToModels = { var relsToModels = {
@ -86,8 +87,29 @@ module.exports = function(Role) {
roles: ACL.ROLE, roles: ACL.ROLE,
}; };
var model = relsToModels[rel]; var principalModel = relsToModels[rel];
listByPrincipalType(this, model, relsToTypes[rel], query, callback); var principalType = relsToTypes[rel];
// redefine user model and user type if user principalType is custom (available and not "USER")
var isCustomUserPrincipalType = rel === 'users' &&
query.where.principalType &&
query.where.principalType !== RoleMapping.USER;
if (isCustomUserPrincipalType) {
var registry = this.constructor.registry;
principalModel = registry.findModel(query.where.principalType);
principalType = query.where.principalType;
}
// make sure we don't keep principalType in userModel query
delete query.where.principalType;
if (principalModel) {
listByPrincipalType(this, principalModel, principalType, query, callback);
} else {
process.nextTick(function() {
callback(null, []);
});
}
return callback.promise; return callback.promise;
}; };
}); });
@ -102,10 +124,11 @@ module.exports = function(Role) {
* @param {Function} [callback] callback function called with `(err, models)` arguments. * @param {Function} [callback] callback function called with `(err, models)` arguments.
*/ */
function listByPrincipalType(context, model, principalType, query, callback) { function listByPrincipalType(context, model, principalType, query, callback) {
if (callback === undefined) { if (callback === undefined && typeof query === 'function') {
callback = query; callback = query;
query = {}; query = {};
} }
query = query || {};
roleModel.roleMappingModel.find({ roleModel.roleMappingModel.find({
where: {roleId: context.id, principalType: principalType}, where: {roleId: context.id, principalType: principalType},
@ -303,6 +326,7 @@ module.exports = function(Role) {
* @promise * @promise
*/ */
Role.isInRole = function(role, context, callback) { Role.isInRole = function(role, context, callback) {
context.registry = this.registry;
if (!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }
@ -421,9 +445,9 @@ module.exports = function(Role) {
callback = utils.createPromiseCallback(); callback = utils.createPromiseCallback();
} }
} }
if (!options) options = {}; if (!options) options = {};
context.registry = this.registry;
if (!(context instanceof AccessContext)) { if (!(context instanceof AccessContext)) {
context = new AccessContext(context); context = new AccessContext(context);
} }

View File

@ -683,13 +683,18 @@ module.exports = function(User) {
return process.nextTick(cb); return process.nextTick(cb);
var AccessToken = accessTokenRelation.modelTo; var AccessToken = accessTokenRelation.modelTo;
var query = {userId: {inq: userIds}}; var query = {userId: {inq: userIds}};
var tokenPK = AccessToken.definition.idName() || 'id'; var tokenPK = AccessToken.definition.idName() || 'id';
if (options.accessToken && tokenPK in options.accessToken) { if (options.accessToken && tokenPK in options.accessToken) {
query[tokenPK] = {neq: options.accessToken[tokenPK]}; query[tokenPK] = {neq: options.accessToken[tokenPK]};
} }
// add principalType in AccessToken.query if using polymorphic relations
// between AccessToken and User
var relatedUser = AccessToken.relations.user;
var isRelationPolymorphic = relatedUser.polymorphic && !relatedUser.modelTo;
if (isRelationPolymorphic) {
query.principalType = this.modelName;
}
AccessToken.deleteAll(query, options, cb); AccessToken.deleteAll(query, options, cb);
}; };

View File

@ -32,9 +32,12 @@ function AccessContext(context) {
} }
context = context || {}; context = context || {};
assert(context.registry,
'Application registry is mandatory in AccessContext but missing in provided context');
this.registry = context.registry;
this.principals = context.principals || []; this.principals = context.principals || [];
var model = context.model; var model = context.model;
model = ('string' === typeof model) ? loopback.getModel(model) : model; model = ('string' === typeof model) ? this.registry.getModel(model) : model;
this.model = model; this.model = model;
this.modelName = model && model.modelName; this.modelName = model && model.modelName;
@ -62,6 +65,7 @@ function AccessContext(context) {
var principalType = context.principalType || Principal.USER; var principalType = context.principalType || Principal.USER;
var principalId = context.principalId || undefined; var principalId = context.principalId || undefined;
var principalName = context.principalName || undefined; var principalName = context.principalName || undefined;
if (principalId) { if (principalId) {
this.addPrincipal(principalType, principalId, principalName); this.addPrincipal(principalType, principalId, principalName);
} }
@ -124,11 +128,25 @@ AccessContext.prototype.addPrincipal = function(principalType, principalId, prin
* @returns {*} * @returns {*}
*/ */
AccessContext.prototype.getUserId = function() { AccessContext.prototype.getUserId = function() {
var BaseUser = this.registry.getModel('User');
for (var i = 0; i < this.principals.length; i++) { for (var i = 0; i < this.principals.length; i++) {
var p = this.principals[i]; var p = this.principals[i];
var isBuiltinPrincipal = p.type === Principal.APP ||
p.type === Principal.ROLE ||
p.type == Principal.SCOPE;
if (isBuiltinPrincipal) continue;
// the principalType must either be 'USER'
if (p.type === Principal.USER) { if (p.type === Principal.USER) {
return p.id; return p.id;
} }
// or permit to resolve a valid user model
var userModel = this.registry.findModel(p.type);
if (!userModel) continue;
if (userModel.prototype instanceof BaseUser) {
return p.id;
}
} }
return null; return null;
}; };
@ -189,8 +207,9 @@ AccessContext.prototype.debug = function() {
* This class represents the abstract notion of a principal, which can be used * This class represents the abstract notion of a principal, which can be used
* to represent any entity, such as an individual, a corporation, and a login id * to represent any entity, such as an individual, a corporation, and a login id
* @param {String} type The principal type * @param {String} type The principal type
* @param {*} id The princiapl id * @param {*} id The principal id
* @param {String} [name] The principal name * @param {String} [name] The principal name
* @param {String} modelName The principal model name
* @returns {Principal} * @returns {Principal}
* @class * @class
*/ */

View File

@ -0,0 +1,444 @@
// Copyright IBM Corp. 2013,2016. All Rights Reserved.
// Node module: loopback
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
'use strict';
var expect = require('./helpers/expect');
var request = require('supertest');
var loopback = require('../');
var ctx = require('../lib/access-context');
var AccessContext = ctx.AccessContext;
var Principal = ctx.Principal;
var Promise = require('bluebird');
describe('Multiple users with custom principalType', function() {
this.timeout(10000);
var commonCredentials = {email: 'foo@bar.com', password: 'bar'};
var app, OneUser, AnotherUser, AccessToken, Role,
userFromOneModel, userFromAnotherModel, userRole, userOneBaseContext;
beforeEach(function setupAppAndModels() {
// create a local app object that does not share state with other tests
app = loopback({localRegistry: true, loadBuiltinModels: true});
app.dataSource('db', {connector: 'memory'});
var userModelOptions = {
base: 'User',
// forceId is set to false for the purpose of updating the same affected user within the
// `Email Update` test cases.
forceId: false,
// Speed up the password hashing algorithm for tests
saltWorkFactor: 4,
};
// create and attach 2 User-based models
OneUser = createUserModel(app, 'OneUser', userModelOptions);
AnotherUser = createUserModel(app, 'AnotherUser', userModelOptions);
AccessToken = app.registry.getModel('AccessToken');
app.model(AccessToken, {dataSource: 'db'});
Role = app.registry.getModel('Role');
app.model(Role, {dataSource: 'db'});
// Update AccessToken and Users to bind them through polymorphic relations
AccessToken.belongsTo('user', {idName: 'id', polymorphic: {idType: 'string',
foreignKey: 'userId', discriminator: 'principalType'}});
OneUser.hasMany('accessTokens', {polymorphic: {foreignKey: 'userId',
discriminator: 'principalType'}});
AnotherUser.hasMany('accessTokens', {polymorphic: {foreignKey: 'userId',
discriminator: 'principalType'}});
app.enableAuth({dataSource: 'db'});
app.use(loopback.token({model: AccessToken}));
// create one user per user model to use them throughout the tests
return Promise.all([
OneUser.create(commonCredentials),
AnotherUser.create(commonCredentials),
Role.create({name: 'userRole'}),
])
.spread(function(u1, u2, r) {
userFromOneModel = u1;
userFromAnotherModel = u2;
userRole = r;
userOneBaseContext = {
principalType: OneUser.modelName,
principalId: userFromOneModel.id,
};
});
});
describe('User.login', function() {
it('works for one user model and valid credentials', function() {
return OneUser.login(commonCredentials)
.then(function(accessToken) {
assertGoodToken(accessToken, userFromOneModel);
});
});
it('works for a second user model and valid credentials', function() {
return AnotherUser.login(commonCredentials)
.then(function(accessToken) {
assertGoodToken(accessToken, userFromAnotherModel);
});
});
it('fails when credentials are not correct', function() {
return OneUser.login({email: 'foo@bar.com', password: 'invalid'})
.then(
function onSuccess() {
throw new Error('OneUser.login() should have failed');
},
function onError(err) {
expect(err).to.have.property('code', 'LOGIN_FAILED');
}
);
});
});
function assertGoodToken(accessToken, user) {
if (accessToken instanceof AccessToken) {
accessToken = accessToken.toJSON();
}
expect(accessToken.id, 'token id').to.have.lengthOf(64);
expect(accessToken).to.have.property('userId', user.id);
expect(accessToken).to.have.property('principalType', user.constructor.definition.name);
}
describe('User.logout', function() {
it('logs out a user from user model 1 without logging out user from model 2',
function() {
var tokenOfOneUser;
return Promise.all([
OneUser.login(commonCredentials),
AnotherUser.login(commonCredentials),
])
.spread(function(t1, t2) {
tokenOfOneUser = t1;
return OneUser.logout(tokenOfOneUser.id);
})
.then(function() {
return AccessToken.find({});
})
.then(function(allTokens) {
var data = allTokens.map(function(token) {
return {userId: token.userId, principalType: token.principalType};
});
expect(data).to.eql([
// no token for userFromAnotherModel
{userId: userFromAnotherModel.id, principalType: 'AnotherUser'},
]);
});
});
});
describe('Password Reset', function() {
describe('User.resetPassword(options)', function() {
var options = {
email: 'foo@bar.com',
redirect: 'http://foobar.com/reset-password',
};
it('creates a temp accessToken to allow a user to change password',
function() {
return Promise.all([
OneUser.resetPassword({email: options.email}),
waitForResetRequestAndVerify,
]);
});
function waitForResetRequestAndVerify() {
return waitForEvent(OneUser, 'resetPasswordRequest')
.then(function(info) {
assertGoodToken(info.accessToken, userFromOneModel);
return info.accessToken.user.getAsync();
})
.then(function(user) {
expect(user).to.have.property('id', userFromOneModel.id);
expect(user).to.have.property('email', userFromOneModel.email);
});
}
});
});
describe('AccessToken (session) invalidation when changing email', function() {
var anotherUserFromOneModel;
it('impact only the related user', function() {
return OneUser.create({email: 'original@example.com', password: 'bar'})
.then(function(u) {
anotherUserFromOneModel = u;
return Promise.all([
OneUser.login({email: 'original@example.com', password: 'bar'}),
OneUser.login(commonCredentials),
AnotherUser.login(commonCredentials),
]);
})
.then(function() {
return anotherUserFromOneModel.updateAttribute('email', 'updated@example.com');
})
.then(function() {
// we need to sort on principalType to ensure stability in results' order
return AccessToken.find({'order': 'principalType ASC'});
})
.then(function(allTokens) {
var data = allTokens.map(function(token) {
return {userId: token.userId, principalType: token.principalType};
});
expect(data).to.eql([
// no token for anotherUserFromOneModel
{userId: userFromAnotherModel.id, principalType: 'AnotherUser'},
{userId: userFromOneModel.id, principalType: 'OneUser'},
]);
});
});
});
describe('AccessContext', function() {
var ThirdUser, userFromThirdModel, accessContext;
beforeEach(function() {
accessContext = new AccessContext({registry: OneUser.registry});
});
describe('getUserId()', function() {
it('returns userId although principals contain non USER principals',
function() {
return Promise.try(function() {
addToAccessContext([
{type: Principal.ROLE},
{type: Principal.APP},
{type: Principal.SCOPE},
{type: OneUser.modelName, id: userFromOneModel.id},
]);
var userId = accessContext.getUserId();
expect(userId).to.equal(userFromOneModel.id);
});
});
it('returns userId although principals contain invalid principals',
function() {
return Promise.try(function() {
addToAccessContext([
{type: 'AccessToken'},
{type: 'invalidModelName'},
{type: OneUser.modelName, id: userFromOneModel.id},
]);
var userId = accessContext.getUserId();
expect(userId).to.equal(userFromOneModel.id);
});
});
it('supports any level of built-in User model inheritance',
function() {
ThirdUser = createUserModel(app, 'ThirdUser', {base: 'OneUser'});
return ThirdUser.create(commonCredentials)
.then(function(userFromThirdModel) {
accessContext.addPrincipal(ThirdUser.modelName, userFromThirdModel.id);
var userId = accessContext.getUserId();
expect(userId).to.equal(userFromThirdModel.id);
});
});
});
// helper
function addToAccessContext(list) {
list.forEach(function(principal) {
expect(principal).to.exist();
accessContext.addPrincipal(principal.type, principal.id);
});
}
});
describe('role model', function() {
this.timeout(10000);
var RoleMapping, ACL, user;
beforeEach(function() {
ACL = app.registry.getModel('ACL');
app.model(ACL, {dataSource: 'db'});
RoleMapping = app.registry.getModel('RoleMapping');
app.model(RoleMapping, {dataSource: 'db'});
});
describe('role.users()', function() {
it('returns users when using custom user principalType', function() {
return userRole.principals.create(
{principalType: OneUser.modelName, principalId: userFromOneModel.id})
.then(function() {
return userRole.users({where: {principalType: OneUser.modelName}});
})
.then(getIds)
.then(function(userIds) {
expect(userIds).to.eql([userFromOneModel.id]);
});
});
it('returns empty array when using invalid principalType', function() {
return userRole.principals.create(
{principalType: 'invalidModelName', principalId: userFromOneModel.id})
.then(function() {
return userRole.users({where: {principalType: 'invalidModelName'}});
})
.then(function(users) {
expect(users).to.be.empty();
});
});
});
describe('principal.user()', function() {
it('returns the correct user instance', function() {
return userRole.principals.create(
{principalType: OneUser.modelName, principalId: userFromOneModel.id})
.then(function(principal) {
return principal.user();
})
.then(function(user) {
expect(user).to.have.property('id', userFromOneModel.id);
});
});
it('returns null when created with invalid principalType', function() {
return userRole.principals.create(
{principalType: 'invalidModelName', principalId: userFromOneModel.id})
.then(function(principal) {
return principal.user();
})
.then(function(user) {
expect(user).to.not.exist();
});
});
});
describe('isInRole() & getRole()', function() {
beforeEach(function() {
return userRole.principals.create({principalType: OneUser.modelName,
principalId: userFromOneModel.id});
});
it('supports isInRole()', function() {
return Role.isInRole('userRole', userOneBaseContext)
.then(function(isInRole) {
expect(isInRole).to.be.true();
});
});
it('supports getRoles()', function() {
return Role.getRoles(
userOneBaseContext)
.then(function(roles) {
expect(roles).to.eql([
Role.AUTHENTICATED,
Role.EVERYONE,
userRole.id,
]);
});
});
});
describe('built-in role resolver', function() {
it('supports AUTHENTICATED', function() {
return Role.isInRole(Role.AUTHENTICATED, userOneBaseContext)
.then(function(isInRole) {
expect(isInRole).to.be.true();
});
});
it('supports UNAUTHENTICATED', function() {
return Role.isInRole(Role.UNAUTHENTICATED, userOneBaseContext)
.then(function(isInRole) {
expect(isInRole).to.be.false();
});
});
it('supports OWNER', function() {
var Album = app.registry.createModel('Album', {
name: String,
userId: Number,
}, {
relations: {
user: {
type: 'belongsTo',
model: 'OneUser',
foreignKey: 'userId',
},
},
});
app.model(Album, {dataSource: 'db'});
return Album.create({name: 'album', userId: userFromOneModel.id})
.then(function(album) {
return Role.isInRole(
Role.OWNER,
{
principalType: OneUser.modelName,
principalId: userFromOneModel.id,
model: Album,
id: album.id,
});
})
.then(function(isInRole) {
expect(isInRole).to.be.true();
});
});
});
describe('isMappedToRole()', function() {
beforeEach(function() {
return userRole.principals.create(userOneBaseContext);
});
it('resolves user by id using custom user principalType', function() {
return ACL.resolvePrincipal(OneUser.modelName, userFromOneModel.id)
.then(function(principal) {
expect(principal.id).to.eql(userFromOneModel.id);
});
});
it('throws error with code \'INVALID_PRINCIPAL_TYPE\' when principalType is incorrect',
function() {
return ACL.resolvePrincipal('incorrectPrincipalType', userFromOneModel.id)
.then(
function onSuccess() {
throw new Error('ACL.resolvePrincipal() should have failed');
},
function onError(err) {
expect(err).to.have.property('statusCode', 400);
expect(err).to.have.property('code', 'INVALID_PRINCIPAL_TYPE');
}
);
});
it('reports isMappedToRole by user.username using custom user principalType',
function() {
return ACL.isMappedToRole(OneUser.modelName, userFromOneModel.username, 'userRole')
.then(function(isMappedToRole) {
expect(isMappedToRole).to.be.true();
});
});
});
});
// helpers
function createUserModel(app, name, options) {
var model = app.registry.createModel(Object.assign({name: name}, options));
app.model(model, {dataSource: 'db'});
model.setMaxListeners(0); // allow many User.afterRemote's to be called
return model;
}
function waitForEvent(emitter, name) {
return new Promise(function(resolve, reject) {
emitter.once(name, resolve);
});
};
function getIds(array) {
return array.map(function(it) { return it.id; });
};
});

View File

@ -28,11 +28,12 @@ describe('User', function() {
var validMixedCaseEmailCredentials = {email: 'Foo@bar.com', password: 'bar'}; var validMixedCaseEmailCredentials = {email: 'Foo@bar.com', password: 'bar'};
var invalidCredentials = {email: 'foo1@bar.com', password: 'invalid'}; var invalidCredentials = {email: 'foo1@bar.com', password: 'invalid'};
var incompleteCredentials = {password: 'bar1'}; var incompleteCredentials = {password: 'bar1'};
var validCredentialsUser, validCredentialsEmailVerifiedUser;
// Create a local app variable to prevent clashes with the global // Create a local app variable to prevent clashes with the global
// variable shared by all tests. While this should not be necessary if // variable shared by all tests. While this should not be necessary if
// the tests were written correctly, it turns out that's not the case :( // the tests were written correctly, it turns out that's not the case :(
var app; var app = null;
beforeEach(function setupAppAndModels(done) { beforeEach(function setupAppAndModels(done) {
// override the global app object provided by test/support.js // override the global app object provided by test/support.js
@ -89,8 +90,12 @@ describe('User', function() {
User.create(validCredentials, function(err, user) { User.create(validCredentials, function(err, user) {
if (err) return done(err); if (err) return done(err);
validCredentialsUser = user;
User.create(validCredentialsEmailVerified, done); User.create(validCredentialsEmailVerified, function(err, user) {
if (err) return done(err);
validCredentialsEmailVerifiedUser = user;
done();
});
}); });
}); });
@ -279,14 +284,15 @@ describe('User', function() {
it('invalidates the user\'s accessToken when the user is deleted all', function(done) { it('invalidates the user\'s accessToken when the user is deleted all', function(done) {
var userIds = []; var userIds = [];
var accessTokenId; var users;
async.series([ async.series([
function(next) { function(next) {
User.create([ User.create([
{name: 'myname', email: 'b@c.com', password: 'bar'}, {name: 'myname', email: 'b@c.com', password: 'bar'},
{name: 'myname', email: 'd@c.com', password: 'bar'}, {name: 'myname', email: 'd@c.com', password: 'bar'},
], function(err, users) { ], function(err, createdUsers) {
userIds = users.map(function(u) { users = createdUsers;
userIds = createdUsers.map(function(u) {
return u.pk; return u.pk;
}); });
next(err); next(err);
@ -294,17 +300,15 @@ describe('User', function() {
}, },
function(next) { function(next) {
User.login({email: 'b@c.com', password: 'bar'}, function(err, accessToken) { User.login({email: 'b@c.com', password: 'bar'}, function(err, accessToken) {
accessTokenId = accessToken.userId;
if (err) return next(err); if (err) return next(err);
assert(accessTokenId); assertGoodToken(accessToken, users[0]);
next(); next();
}); });
}, },
function(next) { function(next) {
User.login({email: 'd@c.com', password: 'bar'}, function(err, accessToken) { User.login({email: 'd@c.com', password: 'bar'}, function(err, accessToken) {
accessTokenId = accessToken.userId;
if (err) return next(err); if (err) return next(err);
assert(accessTokenId); assertGoodToken(accessToken, users[1]);
next(); next();
}); });
}, },
@ -424,12 +428,11 @@ describe('User', function() {
}); });
it('allows login with password exactly 72 characters long', function(done) { it('allows login with password exactly 72 characters long', function(done) {
User.create({email: 'b@c.com', password: pass72Char}, function(err) { User.create({email: 'b@c.com', password: pass72Char}, function(err, user) {
if (err) return done(err); if (err) return done(err);
User.login({email: 'b@c.com', password: pass72Char}, function(err, accessToken) { User.login({email: 'b@c.com', password: pass72Char}, function(err, accessToken) {
if (err) return done(err); if (err) return done(err);
assertGoodToken(accessToken); assertGoodToken(accessToken, user);
assert(accessToken.id);
done(); done();
}); });
}); });
@ -502,9 +505,7 @@ describe('User', function() {
describe('User.login', function() { describe('User.login', function() {
it('Login a user by providing credentials', function(done) { it('Login a user by providing credentials', function(done) {
User.login(validCredentials, function(err, accessToken) { User.login(validCredentials, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.id.length, 64);
done(); done();
}); });
@ -513,9 +514,7 @@ describe('User', function() {
it('Login a user by providing email credentials (email case-sensitivity off)', function(done) { it('Login a user by providing email credentials (email case-sensitivity off)', function(done) {
User.settings.caseSensitiveEmail = false; User.settings.caseSensitiveEmail = false;
User.login(validMixedCaseEmailCredentials, function(err, accessToken) { User.login(validMixedCaseEmailCredentials, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.id.length, 64);
done(); done();
}); });
@ -531,10 +530,8 @@ describe('User', function() {
it('Login a user by providing credentials with TTL', function(done) { it('Login a user by providing credentials with TTL', function(done) {
User.login(validCredentialsWithTTL, function(err, accessToken) { User.login(validCredentialsWithTTL, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, validCredentialsWithTTL.ttl); assert.equal(accessToken.ttl, validCredentialsWithTTL.ttl);
assert.equal(accessToken.id.length, 64);
done(); done();
}); });
@ -547,10 +544,8 @@ describe('User', function() {
User.findById(accessToken.userId, function(err, user) { User.findById(accessToken.userId, function(err, user) {
user.createAccessToken(120, function(err, accessToken) { user.createAccessToken(120, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 120); assert.equal(accessToken.ttl, 120);
assert.equal(accessToken.id.length, 64);
done(); done();
}); });
@ -566,10 +561,8 @@ describe('User', function() {
User.findById(accessToken.userId, function(err, user) { User.findById(accessToken.userId, function(err, user) {
user.createAccessToken(120) user.createAccessToken(120)
.then(function(accessToken) { .then(function(accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 120); assert.equal(accessToken.ttl, 120);
assert.equal(accessToken.id.length, 64);
done(); done();
}) })
@ -588,17 +581,13 @@ describe('User', function() {
this.accessTokens.create({ttl: ttl / 2}, cb); this.accessTokens.create({ttl: ttl / 2}, cb);
}; };
User.login(validCredentialsWithTTL, function(err, accessToken) { User.login(validCredentialsWithTTL, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 1800); assert.equal(accessToken.ttl, 1800);
assert.equal(accessToken.id.length, 64);
User.findById(accessToken.userId, function(err, user) { User.findById(accessToken.userId, function(err, user) {
user.createAccessToken(120, function(err, accessToken) { user.createAccessToken(120, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 60); assert.equal(accessToken.ttl, 60);
assert.equal(accessToken.id.length, 64);
// Restore create access token // Restore create access token
User.prototype.createAccessToken = createToken; User.prototype.createAccessToken = createToken;
@ -617,18 +606,14 @@ describe('User', function() {
this.accessTokens.create({ttl: ttl / 2, scopes: options.scope}, cb); this.accessTokens.create({ttl: ttl / 2, scopes: options.scope}, cb);
}; };
User.login(validCredentialsWithTTLAndScope, function(err, accessToken) { User.login(validCredentialsWithTTLAndScope, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 1800); assert.equal(accessToken.ttl, 1800);
assert.equal(accessToken.id.length, 64);
assert.equal(accessToken.scopes, 'all'); assert.equal(accessToken.scopes, 'all');
User.findById(accessToken.userId, function(err, user) { User.findById(accessToken.userId, function(err, user) {
user.createAccessToken(120, {scope: 'default'}, function(err, accessToken) { user.createAccessToken(120, {scope: 'default'}, function(err, accessToken) {
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.ttl, 60); assert.equal(accessToken.ttl, 60);
assert.equal(accessToken.id.length, 64);
assert.equal(accessToken.scopes, 'default'); assert.equal(accessToken.scopes, 'default');
// Restore create access token // Restore create access token
User.prototype.createAccessToken = createToken; User.prototype.createAccessToken = createToken;
@ -652,13 +637,13 @@ describe('User', function() {
it('Login should only allow correct credentials - promise variant', function(done) { it('Login should only allow correct credentials - promise variant', function(done) {
User.login(invalidCredentials) User.login(invalidCredentials)
.then(function(accessToken) { .then(function(accessToken) {
assert(!accessToken); expect(accessToken, 'accessToken').to.not.exist();
done(); done();
}) })
.catch(function(err) { .catch(function(err) {
assert(err); expect(err, 'err').to.exist();
assert.equal(err.code, 'LOGIN_FAILED'); expect(err).to.have.property('code', 'LOGIN_FAILED');
done(); done();
}); });
@ -666,8 +651,8 @@ describe('User', function() {
it('Login a user providing incomplete credentials', function(done) { it('Login a user providing incomplete credentials', function(done) {
User.login(incompleteCredentials, function(err, accessToken) { User.login(incompleteCredentials, function(err, accessToken) {
assert(err); expect(err, 'err').to.exist();
assert.equal(err.code, 'USERNAME_EMAIL_REQUIRED'); expect(err).to.have.property('code', 'USERNAME_EMAIL_REQUIRED');
done(); done();
}); });
@ -676,13 +661,13 @@ describe('User', function() {
it('Login a user providing incomplete credentials - promise variant', function(done) { it('Login a user providing incomplete credentials - promise variant', function(done) {
User.login(incompleteCredentials) User.login(incompleteCredentials)
.then(function(accessToken) { .then(function(accessToken) {
assert(!accessToken); expect(accessToken, 'accessToken').to.not.exist();
done(); done();
}) })
.catch(function(err) { .catch(function(err) {
assert(err); expect(err, 'err').to.exist();
assert.equal(err.code, 'USERNAME_EMAIL_REQUIRED'); expect(err).to.have.property('code', 'USERNAME_EMAIL_REQUIRED');
done(); done();
}); });
@ -699,9 +684,7 @@ describe('User', function() {
var accessToken = res.body; var accessToken = res.body;
assert(accessToken.userId); assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.id);
assert.equal(accessToken.id.length, 64);
assert(accessToken.user === undefined); assert(accessToken.user === undefined);
done(); done();
@ -811,8 +794,11 @@ describe('User', function() {
}); });
}); });
function assertGoodToken(accessToken) { function assertGoodToken(accessToken, user) {
assert(accessToken.userId); if (accessToken instanceof AccessToken) {
accessToken = accessToken.toJSON();
}
expect(accessToken).to.have.property('userId', user.pk);
assert(accessToken.id); assert(accessToken.id);
assert.equal(accessToken.id.length, 64); assert.equal(accessToken.id.length, 64);
} }
@ -880,7 +866,7 @@ describe('User', function() {
it('Login a user by with email verification', function(done) { it('Login a user by with email verification', function(done) {
User.login(validCredentialsEmailVerified, function(err, accessToken) { User.login(validCredentialsEmailVerified, function(err, accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, validCredentialsEmailVerifiedUser);
done(); done();
}); });
@ -889,7 +875,7 @@ describe('User', function() {
it('Login a user by with email verification - promise variant', function(done) { it('Login a user by with email verification - promise variant', function(done) {
User.login(validCredentialsEmailVerified) User.login(validCredentialsEmailVerified)
.then(function(accessToken) { .then(function(accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, validCredentialsEmailVerifiedUser);
done(); done();
}) })
@ -909,7 +895,7 @@ describe('User', function() {
var accessToken = res.body; var accessToken = res.body;
assertGoodToken(accessToken); assertGoodToken(accessToken, validCredentialsEmailVerifiedUser);
assert(accessToken.user === undefined); assert(accessToken.user === undefined);
done(); done();
@ -1079,8 +1065,7 @@ describe('User', function() {
it('logs in a user by with realm', function(done) { it('logs in a user by with realm', function(done) {
User.login(credentialWithRealm, function(err, accessToken) { User.login(credentialWithRealm, function(err, accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, user1);
assert.equal(accessToken.userId, user1.pk);
done(); done();
}); });
@ -1088,8 +1073,7 @@ describe('User', function() {
it('logs in a user by with realm in username', function(done) { it('logs in a user by with realm in username', function(done) {
User.login(credentialRealmInUsername, function(err, accessToken) { User.login(credentialRealmInUsername, function(err, accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, user1);
assert.equal(accessToken.userId, user1.pk);
done(); done();
}); });
@ -1097,8 +1081,7 @@ describe('User', function() {
it('logs in a user by with realm in email', function(done) { it('logs in a user by with realm in email', function(done) {
User.login(credentialRealmInEmail, function(err, accessToken) { User.login(credentialRealmInEmail, function(err, accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, user1);
assert.equal(accessToken.userId, user1.pk);
done(); done();
}); });
@ -1115,8 +1098,7 @@ describe('User', function() {
it('logs in a user by with realm', function(done) { it('logs in a user by with realm', function(done) {
User.login(credentialWithRealm, function(err, accessToken) { User.login(credentialWithRealm, function(err, accessToken) {
assertGoodToken(accessToken); assertGoodToken(accessToken, user1);
assert.equal(accessToken.userId, user1.pk);
done(); done();
}); });
@ -1139,7 +1121,7 @@ describe('User', function() {
login(logout); login(logout);
function login(fn) { function login(fn) {
User.login({email: 'foo@bar.com', password: 'bar'}, fn); User.login(validCredentials, fn);
} }
function logout(err, accessToken) { function logout(err, accessToken) {
@ -1152,7 +1134,7 @@ describe('User', function() {
login(logout); login(logout);
function login(fn) { function login(fn) {
User.login({email: 'foo@bar.com', password: 'bar'}, fn); User.login(validCredentials, fn);
} }
function logout(err, accessToken) { function logout(err, accessToken) {
@ -1171,14 +1153,12 @@ describe('User', function() {
.post('/test-users/login') .post('/test-users/login')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.send({email: 'foo@bar.com', password: 'bar'}) .send(validCredentials)
.end(function(err, res) { .end(function(err, res) {
if (err) return done(err); if (err) return done(err);
var accessToken = res.body; var accessToken = res.body;
assertGoodToken(accessToken, validCredentialsUser);
assert(accessToken.userId);
assert(accessToken.id);
fn(null, accessToken.id); fn(null, accessToken.id);
}); });