diff --git a/lib/application.js b/lib/application.js index 62db78582..ebccabd58 100644 --- a/lib/application.js +++ b/lib/application.js @@ -19,6 +19,7 @@ var classify = require('underscore.string/classify'); var camelize = require('underscore.string/camelize'); var path = require('path'); var util = require('util'); +var Promise = require('bluebird'); /** * The `App` object represents a Loopback application. @@ -398,69 +399,201 @@ app.enableAuth = function(options) { } }; - this._verifyAuthModelRelations(); - - this.isAuthEnabled = true; + return this._verifyAuthModelRelations(app._warnOnBadAuthModelRelations) + .then(() => { + this.isAuthEnabled = true; + }); }; -app._verifyAuthModelRelations = function() { +app._verifyAuthModelRelations = function(warnFn) { + let self = this; + // Allow unit-tests (but also LoopBack users) to disable the warnings - if (this.get('_verifyAuthModelRelations') === false) return; + const warnOnBadSetup = this.get('_warnOnBadAuthModelsSetup') !== false; + // Prevent the app from being killed although the configuration is inconsistent + const abortOnBadUserSetup = this.get('_abortOnBadUserSetup') !== false; const AccessToken = this.registry.findModel('AccessToken'); const User = this.registry.findModel('User'); - this.models().forEach(Model => { - if (Model === AccessToken || Model.prototype instanceof AccessToken) { - scheduleVerification(Model, verifyAccessTokenRelations); - } + const models = this.models(); - if (Model === User || Model.prototype instanceof User) { - scheduleVerification(Model, verifyUserRelations); - } - }); + return verifyUserModelsSetup().then(verifyRelationsSetup); + + function verifyUserModelsSetup() { + let userModels = models.filter(model => { + return (model === User || model.prototype instanceof User) || + (model === AccessToken || model.prototype instanceof AccessToken); + }); + + // we use Promise.reflect() here to let all the tests go without throwing, + // this way we can get all the logs for all models we are checking + return Promise.map(userModels, + model => scheduleVerification(model, hasMultipleUserModelsConfig).reflect() + ) + .then(inspections => { + // proceed to next checkups if no error + let hasErrors = inspections.filter(inspection => inspection.isRejected()); + if (!hasErrors.length) { + return; + } + + // else log and eventually kill the app + warnFn('BAD_USER_MODELS_SETUP'); + + // detailed logs + inspections.forEach(inspection => { + console.log(inspection); + let modelSetup = (inspection.value() || inspection.reason()).modelSetup; + console.warn( + 'Model %j of type %j is set to use the %j user model config', + modelSetup.model, + modelSetup.instanceOf, + modelSetup.hasMultipleUserModelsConfig ? 'Multiple' : 'Single' + ); + }); + + delete this.hasMultipleUserModelsConfig; + + if (abortOnBadUserSetup) { + const msg = 'Application setup is inconsistent, please see the log ' + + 'for more information'; + const error = new Error(msg); + error.code = 'BAD_USER_MODELS_SETUP'; + throw error; + } + }); + } + + function verifyRelationsSetup() { + models.forEach(Model => { + if (Model === AccessToken || Model.prototype instanceof AccessToken) { + scheduleVerification(Model, verifyAccessTokenRelations); + } + + if (Model === User || Model.prototype instanceof User) { + scheduleVerification(Model, verifyUserRelations); + } + }); + } + // instant or scheduled Model verifications, as a promise function scheduleVerification(Model, verifyFn) { - if (Model.dataSource) { - verifyFn(Model); - } else { - Model.on('attached', () => verifyFn(Model)); + return new Promise((resolve, reject) => { + if (Model.dataSource) { + try { + resolve(verifyFn(Model)); + } catch (e) { + reject(e); + } + } else { + Model.on('attached', () => { + scheduleVerification(Model, verifyFn); + }); + } + }); + } + + function hasMultipleUserModelsConfig(model) { + let hasMultipleUserModelsConfig, isInstanceOfUser; + + // check is the model is set to use mutiple users config (with polymorphic relations) + if (model === User || model.prototype instanceof User) { + isInstanceOfUser = true; + const hasManyTokens = model.relations && model.relations.accessTokens; + // TODO: handle when hasManyTokens is undefined? + hasMultipleUserModelsConfig = !!(hasManyTokens.polymorphic); + } else if (model === AccessToken || model.prototype instanceof AccessToken) { + const belongsToUser = model.relations && model.relations.user; + // the test on belongsToUser is required as we allow AccessToken model not + // to define the relation with custom user model for backward compatibility + // see https://github.com/strongloop/loopback/pull/3227 + hasMultipleUserModelsConfig = !!(belongsToUser && belongsToUser.polymorphic); } + + let modelSetup = { + model: model.modelName, + instanceOf: isInstanceOfUser ? 'User' : 'AccessToken', + hasMultipleUserModelsConfig, + }; + + // check if model config is consistent with already parsed models' config + if (self.hasMultipleUserModelsConfig === undefined) { + self.hasMultipleUserModelsConfig = hasMultipleUserModelsConfig; + } else if (self.hasMultipleUserModelsConfig !== hasMultipleUserModelsConfig) { + let error = new Error('User models setup is inconsistent'); + error.modelSetup = modelSetup; + throw error; + } + + // if no error, return the modelSetup for further logging purposes + return {modelSetup}; } function verifyAccessTokenRelations(Model) { + if (!warnOnBadSetup) return; const belongsToUser = Model.relations && Model.relations.user; if (belongsToUser) return; + const modelFrom = Model.modelName; const relationsConfig = Model.settings.relations || {}; - const userName = (relationsConfig.user || {}).model; - if (userName) { - console.warn( - 'The model %j configures "belongsTo User-like models" relation ' + - 'with target model %j. However, the model %j is not attached to ' + - 'the application and therefore cannot be used by this relation. ' + - 'This typically happens when the application has a custom ' + - 'custom User subclass, but does not fix AccessToken relations ' + - 'to use this new model.\n' + - 'Learn more at http://ibm.biz/setup-loopback-auth', - Model.modelName, userName, userName); + const modelTo = (relationsConfig.user || {}).model; + if (modelTo) { + warnFn('CUSTOM_USER_MODEL_NOT_AVAILABLE', {modelFrom, modelTo}); return; } - console.warn( - 'The model %j does not have "belongsTo User-like model" relation ' + - 'configured.\n' + - 'Learn more at http://ibm.biz/setup-loopback-auth', - Model.modelName); + warnFn('MISSING_RELATION_TO_USER', {modelFrom}); } function verifyUserRelations(Model) { + if (!warnOnBadSetup) return; + const hasManyTokens = Model.relations && Model.relations.accessTokens; if (hasManyTokens) return; + const modelFrom = Model.modelName; const relationsConfig = Model.settings.relations || {}; - const accessTokenName = (relationsConfig.accessTokens || {}).model; - if (accessTokenName) { - console.warn( + const modelTo = (relationsConfig.accessTokens || {}).model; + if (modelTo) { + warnFn('CUSTOM_ACCESS_TOKEN_MODEL_NOT_AVAILABLE', {modelFrom, modelTo}); + return; + } + + warnFn('MISSING_RELATION_TO_ACCESS_TOKEN', {modelFrom}); + } +}; + +// function warnOnBadAuthModelRelations(code, args) { +app._warnOnBadAuthModelRelations = function(code, args) { + switch (code) { + case 'BAD_USER_MODELS_SETUP': + g.warn('Application setup is inconsistent: some models are set to use ' + + 'the Multiple user models configuration while some other models are set to ' + + 'use the Single user model configuration. The model config is listed below.'); + break; + + case 'CUSTOM_USER_MODEL_NOT_AVAILABLE': + g.warn( + 'The model %j configures "belongsTo User-like models" relation ' + + 'with target model %j. However, the model %j is not attached to ' + + 'the application and therefore cannot be used by this relation. ' + + 'This typically happens when the application has a custom ' + + 'custom User subclass, but does not fix AccessToken relations ' + + 'to use this new model.\n' + + 'Learn more at http://ibm.biz/setup-loopback-auth', + args.modelFrom, args.modelTo, args.modelTo); + break; + + case 'MISSING_RELATION_TO_USER': + g.warn( + 'The model %j does not have "belongsTo User-like model" relation ' + + 'configured.\n' + + 'Learn more at http://ibm.biz/setup-loopback-auth', + args.modelFrom); + break; + + case 'CUSTOM_ACCESS_TOKEN_MODEL_NOT_AVAILABLE': + g.warn( 'The model %j configures "hasMany AccessToken-like models" relation ' + 'with target model %j. However, the model %j is not attached to ' + 'the application and therefore cannot be used by this relation. ' + @@ -468,15 +601,15 @@ app._verifyAuthModelRelations = function() { 'AccessToken subclass, but does not fix User relations to use this ' + 'new model.\n' + 'Learn more at http://ibm.biz/setup-loopback-auth', - Model.modelName, accessTokenName, accessTokenName); - return; - } + args.modelFrom, args.modelTo, args.modelTo); + break; - console.warn( - 'The model %j does not have "hasMany AccessToken-like models" relation ' + - 'configured.\n' + - 'Learn more at http://ibm.biz/setup-loopback-auth', - Model.modelName); + case 'MISSING_RELATION_TO_ACCESS_TOKEN': + g.warn( + 'The model %j does not have "hasMany AccessToken-like models" relation ' + + 'configured.\n' + + 'Learn more at http://ibm.biz/setup-loopback-auth', + args.modelFrom); } }; diff --git a/test/app.test.js b/test/app.test.js index ab56eff3d..dabc0790e 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -18,6 +18,8 @@ var expect = require('./helpers/expect'); var it = require('./util/it'); var request = require('supertest'); +var Promise = require('bluebird'); + describe('app', function() { var app; beforeEach(function() { @@ -859,10 +861,11 @@ describe('app', function() { }); describe.onServer('enableAuth', function() { - it('should set app.isAuthEnabled to true', function() { + it('sets app.isAuthEnabled to true', function() { expect(app.isAuthEnabled).to.not.equal(true); - app.enableAuth(); - expect(app.isAuthEnabled).to.equal(true); + return app.enableAuth().then(() => { + expect(app.isAuthEnabled).to.equal(true); + }); }); it('auto-configures required models to provided dataSource', function() { @@ -871,14 +874,14 @@ describe('app', function() { require('../lib/builtin-models')(app.registry); var db = app.dataSource('db', {connector: 'memory'}); - app.enableAuth({dataSource: 'db'}); - - expect(Object.keys(app.models)).to.include.members(AUTH_MODELS); + return app.enableAuth({dataSource: 'db'}).then(() => { + expect(Object.keys(app.models)).to.include.members(AUTH_MODELS); - AUTH_MODELS.forEach(function(m) { - var Model = app.models[m]; - expect(Model.dataSource, m + '.dataSource').to.equal(db); - expect(Model.shared, m + '.shared').to.equal(m === 'User'); + AUTH_MODELS.forEach(function(m) { + var Model = app.models[m]; + expect(Model.dataSource, m + '.dataSource').to.equal(db); + expect(Model.shared, m + '.shared').to.equal(m === 'User'); + }); }); }); @@ -892,9 +895,71 @@ describe('app', function() { const AccessToken = app.registry.getModel('AccessToken'); AccessToken.settings.relations.user.model = 'Customer'; - app.enableAuth({dataSource: 'db'}); + return app.enableAuth({dataSource: 'db'}).then(() => { + expect(Object.keys(app.models)).to.not.include('User'); + }); + }); + + describe('auth models config health check', function() { + var app, warnings; + beforeEach(function() { + app = loopback({localRegistry: true, loadBuiltinModels: true}); + app.dataSource('db', {connector: 'memory'}); - expect(Object.keys(app.models)).to.not.include('User'); + warnings = []; + function warnFn(code, args) { + warnings.push(Object.assign(args || {}, {code})); + } + app._warnOnBadAuthModelRelations = warnFn; + }); + + it('sets app.hasMultipleUserModelsConfig to false if the app ' + + 'has a single User model correctly configured', function() { + return app.enableAuth({dataSource: 'db'}).then(() => { + expect(app.hasMultipleUserModelsConfig).to.be.false(); + }); + }); + + it('sets app.hasMultipleUserModelsConfig to true if the app ' + + 'has multiple User models correctly configured', function() { + var Merchant = createModel(app, 'Merchant', {base: 'User'}); + var Customer = createModel(app, 'Customer', {base: 'User'}); + var Token = createModel(app, 'Token', {base: 'AccessToken'}); + + // Update AccessToken and Users to bind them through polymorphic relations + Token.belongsTo('user', {idName: 'id', polymorphic: {idType: 'string', + foreignKey: 'userId', discriminator: 'principalType'}}); + Merchant.hasMany('accessTokens', {model: Token, polymorphic: {foreignKey: 'userId', + discriminator: 'principalType'}}); + Customer.hasMany('accessTokens', {model: Token, polymorphic: {foreignKey: 'userId', + discriminator: 'principalType'}}); + return app.enableAuth({dataSource: 'db'}).then(() => { + expect(app.hasMultipleUserModelsConfig).to.be.true(); + }); + }); + + it('warns if a custom user model is referenced in the ' + + 'AccessToken "user" relation, but is not available', function() { + // Set AccessToken's belongsTo relation "user" to use a custom user model + // This model is deliberately not available + const AccessToken = app.registry.getModel('AccessToken'); + AccessToken.settings.relations.user.model = 'Customer'; + + return app.enableAuth({dataSource: 'db'}).then(() => { + expect(warnings).to.eql([{ + code: 'CUSTOM_USER_MODEL_NOT_AVAILABLE', + modelFrom: 'AccessToken', + modelTo: 'Customer', + }]); + }); + }); + + // helpers + function createModel(app, name, options) { + var model = app.registry.createModel(Object.assign({name: name}, options)); + app.model(model, {dataSource: 'db'}); + return model; + } }); }); diff --git a/test/user.test.js b/test/user.test.js index 396bb89f0..877a14867 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -2442,7 +2442,7 @@ describe('User', function() { it('handles subclassed user with no accessToken relation', () => { // setup a new LoopBack app, we don't want to use shared models app = loopback({localRegistry: true, loadBuiltinModels: true}); - app.set('_verifyAuthModelRelations', false); + app.set('_warnOnBadAuthModelsSetup', false); app.set('remoting', {errorHandler: {debug: true, log: false}}); app.dataSource('db', {connector: 'memory'}); const User = app.registry.createModel({