From 699bc7aa974c4a46cd8b6d2da71d5088ad532fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 7 Apr 2015 13:43:26 +0200 Subject: [PATCH 1/2] test: remove global autoAttach --- test/model.test.js | 2 ++ test/rest.middleware.test.js | 1 + test/support.js | 3 --- test/user.test.js | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/model.test.js b/test/model.test.js index 29e0fef8..7b037adb 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -501,9 +501,11 @@ describe.onServer('Remote Methods', function() { describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; + var originalValue = Model._ACL(); var acl = ACL.extend('acl'); Model._ACL(null); // Reset the ACL class for the base model var model = Model._ACL(); + Model._ACL(originalValue); // Reset the value back assert.equal(model, acl); }); }); diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index e608c5b2..8168a491 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -3,6 +3,7 @@ describe('loopback.rest', function() { beforeEach(function() { var ds = app.dataSource('db', { connector: loopback.Memory }); MyModel = ds.createModel('MyModel', {name: String}); + loopback.autoAttach(); }); it('works out-of-the-box', function(done) { diff --git a/test/support.js b/test/support.js index 1ef0eefc..0f925a38 100644 --- a/test/support.js +++ b/test/support.js @@ -30,9 +30,6 @@ beforeEach(function() { {type: 'STUB'} ] }); - - // auto attach data sources to models - loopback.autoAttach(); }); assertValidDataSource = function(dataSource) { diff --git a/test/user.test.js b/test/user.test.js index 56aae9e9..682b2f46 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -23,6 +23,7 @@ describe('User', function() { // Update the AccessToken relation to use the subclass of User AccessToken.belongsTo(User); + User.hasMany(AccessToken); // allow many User.afterRemote's to be called User.setMaxListeners(0); @@ -1071,6 +1072,7 @@ describe('User', function() { assert.equal(info.accessToken.ttl / 60, 15); assert(calledBack); info.accessToken.user(function(err, user) { + if (err) return done(err); assert.equal(user.email, email); done(); }); From 9c5fe088e377d10b8af89cff453f04f361cb4f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 3 Apr 2015 16:41:32 +0200 Subject: [PATCH 2/2] AccessControl for change replication 1) Add integration tests running change replication over REST to verify that access control at model level is correctly enforced. 2) Implement a new access type "REPLICATE" that allows principals to create new checkpoints, even though they don't have full WRITE access to the model. Together with the "READ" permission, these two types allow principals to replicate (pull) changes from the server. Note that anybody having "WRITE" access type is automatically granted "REPLICATE" type too. 3) Add a new model option "enableRemoteReplication" that exposes replication methods via strong remoting, but does not configure change rectification. This option should be used the clients when setting up Remote models attached to the server via the remoting connector. --- common/models/acl.js | 40 ++- lib/access-context.js | 1 + lib/model.js | 3 +- lib/persisted-model.js | 35 ++- test/replication.rest.test.js | 484 ++++++++++++++++++++++++++++++++++ test/replication.test.js | 1 + test/util/model-tests.js | 4 + 7 files changed, 549 insertions(+), 19 deletions(-) create mode 100644 test/replication.rest.test.js diff --git a/common/models/acl.js b/common/models/acl.js index 81070972..0bc48c7d 100644 --- a/common/models/acl.js +++ b/common/models/acl.js @@ -88,6 +88,7 @@ module.exports = function(ACL) { ACL.DENY = AccessContext.DENY; // Deny ACL.READ = AccessContext.READ; // Read operation + ACL.REPLICATE = AccessContext.REPLICATE; // Replicate (pull) changes ACL.WRITE = AccessContext.WRITE; // Write operation ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation @@ -109,21 +110,31 @@ module.exports = function(ACL) { for (var i = 0; i < props.length; i++) { // Shift the score by 4 for each of the properties as the weight score = score * 4; - var val1 = rule[props[i]] || ACL.ALL; - var val2 = req[props[i]] || ACL.ALL; - var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1; + var ruleValue = rule[props[i]] || ACL.ALL; + var requestedValue = req[props[i]] || ACL.ALL; + var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(ruleValue) !== -1; - // accessType: EXECUTE should match READ or WRITE - var isMatchingAccessType = props[i] === 'accessType' && - val1 === ACL.EXECUTE; + var isMatchingAccessType = ruleValue === requestedValue; + if (props[i] === 'accessType' && !isMatchingAccessType) { + switch (ruleValue) { + case ACL.EXECUTE: + // EXECUTE should match READ, REPLICATE and WRITE + isMatchingAccessType = true; + break; + case ACL.WRITE: + // WRITE should match REPLICATE too + isMatchingAccessType = requestedValue === ACL.REPLICATE; + break; + } + } - if (val1 === val2 || isMatchingMethodName || isMatchingAccessType) { + if (isMatchingMethodName || isMatchingAccessType) { // Exact match score += 3; - } else if (val1 === ACL.ALL) { + } else if (ruleValue === ACL.ALL) { // Wildcard match score += 2; - } else if (val2 === ACL.ALL) { + } else if (requestedValue === ACL.ALL) { score += 1; } else { // Doesn't match at all @@ -370,7 +381,8 @@ module.exports = function(ACL) { * @property {String|Model} model The model name or model class. * @property {*} id The model instance ID. * @property {String} property The property/method/relation name. - * @property {String} accessType The access type: READE, WRITE, or EXECUTE. + * @property {String} accessType The access type: + * READ, REPLICATE, WRITE, or EXECUTE. * @param {Function} callback Callback function */ @@ -388,7 +400,12 @@ module.exports = function(ACL) { var methodNames = context.methodNames; var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])}; - var accessTypeQuery = (accessType === ACL.ALL) ? undefined : {inq: [accessType, ACL.ALL]}; + + var accessTypeQuery = (accessType === ACL.ALL) ? + undefined : + (accessType === ACL.REPLICATE) ? + {inq: [ACL.REPLICATE, ACL.WRITE, ACL.ALL]} : + {inq: [accessType, ACL.ALL]}; var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames); @@ -438,6 +455,7 @@ module.exports = function(ACL) { if (callback) callback(err, null); return; } + var resolved = self.resolvePermission(effectiveACLs, req); if (resolved && resolved.permission === ACL.DEFAULT) { resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW; diff --git a/lib/access-context.js b/lib/access-context.js index b838bcc1..75ec5016 100644 --- a/lib/access-context.js +++ b/lib/access-context.js @@ -76,6 +76,7 @@ AccessContext.ALL = '*'; // Define constants for access types AccessContext.READ = 'READ'; // Read operation +AccessContext.REPLICATE = 'REPLICATE'; // Replicate (pull) changes AccessContext.WRITE = 'WRITE'; // Write operation AccessContext.EXECUTE = 'EXECUTE'; // Execute operation diff --git a/lib/model.js b/lib/model.js index c9e1da25..5a06b3f4 100644 --- a/lib/model.js +++ b/lib/model.js @@ -333,10 +333,11 @@ module.exports = function(registry) { // Check the explicit setting of accessType if (method.accessType) { assert(method.accessType === ACL.READ || + method.accessType === ACL.REPLICATE || method.accessType === ACL.WRITE || method.accessType === ACL.EXECUTE, 'invalid accessType ' + method.accessType + - '. It must be "READ", "WRITE", or "EXECUTE"'); + '. It must be "READ", "REPLICATE", "WRITE", or "EXECUTE"'); return method.accessType; } diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 07847787..53f8cc62 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -45,6 +45,8 @@ module.exports = function(registry) { PersistedModel.once('dataSourceAttached', function() { PersistedModel.enableChangeTracking(); }); + } else if (this.settings.enableRemoteReplication) { + PersistedModel._defineChangeModel(); } PersistedModel.setupRemoting(); @@ -643,7 +645,7 @@ module.exports = function(registry) { http: {verb: 'put', path: '/'} }); - if (options.trackChanges) { + if (options.trackChanges || options.enableRemoteReplication) { setRemoting(PersistedModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accessType: 'READ', @@ -670,7 +672,11 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'checkpoint', { description: 'Create a checkpoint.', - accessType: 'WRITE', + // The replication algorithm needs to create a source checkpoint, + // even though it is otherwise not making any source changes. + // We need to allow this method for users that don't have full + // WRITE permissions. + accessType: 'REPLICATE', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); @@ -684,7 +690,10 @@ module.exports = function(registry) { setRemoting(PersistedModel, 'createUpdates', { description: 'Create an update list from a delta list', - accessType: 'WRITE', + // This operation is read-only, it does not change any local data. + // It is called by the replication algorithm to compile a list + // of changes to apply on the target. + accessType: 'READ', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} @@ -696,7 +705,11 @@ module.exports = function(registry) { accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); + } + if (options.trackChanges) { + // Deprecated (legacy) exports kept for backwards compatibility + // TODO(bajtos) Hide these two exports in LoopBack 3.0 setRemoting(PersistedModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', accessType: 'WRITE', @@ -1280,7 +1293,7 @@ module.exports = function(registry) { var changeModel = this.Change; var isSetup = changeModel && changeModel.dataSource; - assert(isSetup, 'Cannot get a setup Change model'); + assert(isSetup, 'Cannot get a setup Change model for ' + this.modelName); return changeModel; }; @@ -1327,9 +1340,6 @@ module.exports = function(registry) { 'which requries a string id with GUID/UUID default value.'); } - Change.attachTo(this.dataSource); - Change.getCheckpointModel().attachTo(this.dataSource); - Model.observe('after save', rectifyOnSave); Model.observe('after delete', rectifyOnDelete); @@ -1411,7 +1421,18 @@ module.exports = function(registry) { } ); + if (this.dataSource) { + attachRelatedModels(this); + } else { + this.once('dataSourceAttached', attachRelatedModels); + } + return this.Change; + + function attachRelatedModels(self) { + self.Change.attachTo(self.dataSource); + self.Change.getCheckpointModel().attachTo(self.dataSource); + } }; PersistedModel.rectifyAllChanges = function(callback) { diff --git a/test/replication.rest.test.js b/test/replication.rest.test.js new file mode 100644 index 00000000..a1ae6765 --- /dev/null +++ b/test/replication.rest.test.js @@ -0,0 +1,484 @@ +var async = require('async'); +var debug = require('debug')('test'); +var extend = require('util')._extend; +var loopback = require('../'); +var expect = require('chai').expect; +var supertest = require('supertest'); + +describe('Replication over REST', function() { + var ALICE = { id: 'a', username: 'alice', email: 'a@t.io', password: 'p' }; + var PETER = { id: 'p', username: 'peter', email: 'p@t.io', password: 'p' }; + var EMERY = { id: 'e', username: 'emery', email: 'e@t.io', password: 'p' }; + + var serverApp, serverUrl, ServerUser, ServerCar, serverCars; + var aliceId, peterId, aliceToken, peterToken, emeryToken, request; + var clientApp, LocalUser, LocalCar, RemoteUser, RemoteCar, clientCars; + + before(setupServer); + before(setupClient); + beforeEach(seedServerData); + beforeEach(seedClientData); + + describe('the replication scenario scaffolded for the tests', function() { + describe('Car model', function() { + it('rejects anonymous READ', function(done) { + listCars().expect(401, done); + }); + + it('rejects anonymous WRITE', function(done) { + createCar().expect(401, done); + }); + + it('allows EMERY to READ', function(done) { + listCars() + .set('Authorization', emeryToken) + .expect(200, done); + }); + + it('denies EMERY to WRITE', function(done) { + createCar() + .set('Authorization', emeryToken) + .expect(401, done); + }); + + it('allows ALICE to READ', function(done) { + listCars() + .set('Authorization', aliceToken) + .expect(200, done); + }); + + it('denies ALICE to WRITE', function(done) { + createCar() + .set('Authorization', aliceToken) + .expect(401, done); + }); + + it('allows PETER to READ', function(done) { + listCars() + .set('Authorization', peterToken) + .expect(200, done); + }); + + it('allows PETER to WRITE', function(done) { + createCar() + .set('Authorization', peterToken) + .expect(200, done); + }); + + function listCars() { + return request.get('/Cars'); + } + + function createCar() { + return request.post('/Cars').send({ model: 'a-model' }); + } + }); + }); + + describe('sync with model-level permissions', function() { + describe('as anonymous user', function() { + it('rejects pull from server', function(done) { + RemoteCar.replicate(LocalCar, expectHttpError(401, done)); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with READ-only permissions', function() { + beforeEach(function() { + setAccessToken(emeryToken); + }); + + it('rejects pull from server', function(done) { + RemoteCar.replicate(LocalCar, expectHttpError(401, done)); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with REPLICATE-only permissions', function() { + beforeEach(function() { + setAccessToken(aliceToken); + }); + + it('allows pull from server', function(done) { + RemoteCar.replicate(LocalCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + LocalCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(serverCars); + done(); + }); + }); + }); + + it('rejects push to the server', function(done) { + LocalCar.replicate(RemoteCar, expectHttpError(401, done)); + }); + }); + + describe('as user with READ and WRITE permissions', function() { + beforeEach(function() { + setAccessToken(peterToken); + }); + + it('allows pull from server', function(done) { + RemoteCar.replicate(LocalCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + LocalCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(serverCars); + done(); + }); + }); + }); + + it('allows push to the server', function(done) { + LocalCar.replicate(RemoteCar, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + + ServerCar.find(function(err, list) { + if (err) return done(err); + expect(list.map(carToString)).to.include.members(clientCars); + done(); + }); + }); + }); + }); + + // TODO conflict resolution + // TODO verify permissions of individual methods + }); + + describe.skip('sync with instance-level permissions', function() { + it('pulls only authorized records', function(done) { + setAccessToken(aliceToken); + RemoteUser.replicate(LocalUser, function(err, conflicts, cps) { + if (err) return done(err); + if (conflicts.length) return done(conflictError(conflicts)); + LocalUser.find(function(err, users) { + var userNames = users.map(function(u) { return u.username; }); + expect(userNames).to.eql([ALICE.username]); + done(); + }); + }); + }); + + it('allows push of authorized records', function(done) { + async.series([ + setupModifiedLocalCopyOfAlice, + + function replicateAsCurrentUser(next) { + setAccessToken(aliceToken); + LocalUser.replicate(RemoteUser, function(err, conflicts) { + if (err) return next(err); + if (conflicts.length) return next(conflictError(conflicts)); + next(); + }); + }, + + function verify(next) { + RemoteUser.findById(aliceId, function(err, found) { + if (err) return next(err); + expect(found.toObject()) + .to.have.property('fullname', 'Alice Smith'); + next(); + }); + } + ], done); + }); + + it('rejects push of unauthorized records', function(done) { + async.series([ + setupModifiedLocalCopyOfAlice, + + function replicateAsDifferentUser(next) { + setAccessToken(peterToken); + LocalUser.replicate(RemoteUser, function(err, conflicts) { + if (!err) + return next(new Error('Replicate should have failed.')); + expect(err).to.have.property('statusCode', 401); // or 403? + next(); + }); + }, + + function verify(next) { + ServerUser.findById(aliceId, function(err, found) { + if (err) return next(err); + expect(found.toObject()) + .to.not.have.property('fullname'); + next(); + }); + } + ], done); + }); + + // TODO verify conflict resolution + + function setupModifiedLocalCopyOfAlice(done) { + // Replicate directly, bypassing REST+AUTH layers + replicateServerToLocal(function(err) { + if (err) return done(err); + + LocalUser.updateAll( + { id: aliceId }, + { fullname: 'Alice Smith' }, + done); + }); + } + }); + + var USER_PROPS = { + id: { type: 'string', id: true } + }; + + var USER_OPTS = { + base: 'User', + plural: 'Users', // use the same REST path in all models + trackChanges: true, + strict: true, + persistUndefinedAsNull: true + }; + + var CAR_PROPS = { + id: { type: 'string', id: true, defaultFn: 'guid' }, + model: { type: 'string', required: true }, + maker: { type: 'string' } + }; + + var CAR_OPTS = { + base: 'PersistedModel', + plural: 'Cars', // use the same REST path in all models + trackChanges: true, + strict: true, + persistUndefinedAsNull: true, + acls: [ + // disable anonymous access + { + principalType: 'ROLE', + principalId: '$everyone', + permission: 'DENY' + }, + // allow all authenticated users to read data + { + principalType: 'ROLE', + principalId: '$authenticated', + permission: 'ALLOW', + accessType: 'READ' + }, + // allow Alice to pull changes + { + principalType: 'USER', + principalId: ALICE.id, + permission: 'ALLOW', + accessType: 'REPLICATE' + }, + // allow Peter to write data + { + principalType: 'USER', + principalId: PETER.id, + permission: 'ALLOW', + accessType: 'WRITE' + } + ] + }; + + function setupServer(done) { + serverApp = loopback(); + serverApp.enableAuth(); + + serverApp.dataSource('db', { connector: 'memory' }); + + // Setup a custom access-token model that is not shared + // with the client app + var ServerToken = loopback.createModel('ServerToken', {}, { + base: 'AccessToken', + relations: { + user: { + type: 'belongsTo', + model: 'ServerUser', + foreignKey: 'userId' + } + } + }); + serverApp.model(ServerToken, { dataSource: 'db', public: false }); + serverApp.model(loopback.ACL, { dataSource: 'db', public: false }); + serverApp.model(loopback.Role, { dataSource: 'db', public: false }); + serverApp.model(loopback.RoleMapping, { dataSource: 'db', public: false }); + + ServerUser = loopback.createModel('ServerUser', USER_PROPS, USER_OPTS); + serverApp.model(ServerUser, { + dataSource: 'db', + public: true, + relations: { accessTokens: { model: 'ServerToken' } } + }); + + ServerCar = loopback.createModel('ServerCar', CAR_PROPS, CAR_OPTS); + serverApp.model(ServerCar, { dataSource: 'db', public: true }); + + serverApp.use(function(req, res, next) { + debug(req.method + ' ' + req.path); + next(); + }); + serverApp.use(loopback.token({ model: ServerToken })); + serverApp.use(loopback.rest()); + + serverApp.set('legacyExplorer', false); + serverApp.set('port', 0); + serverApp.set('host', '127.0.0.1'); + serverApp.listen(function() { + serverUrl = serverApp.get('url').replace(/\/+$/, ''); + request = supertest(serverUrl); + done(); + }); + } + + function setupClient() { + clientApp = loopback(); + clientApp.dataSource('db', { connector: 'memory' }); + clientApp.dataSource('remote', { + connector: 'remote', + url: serverUrl + }); + + // NOTE(bajtos) At the moment, all models share the same Checkpoint + // model. This causes the in-process replication to work differently + // than client-server replication. + // As a workaround, we manually setup unique Checkpoint for ClientModel. + var ClientCheckpoint = loopback.Checkpoint.extend('ClientCheckpoint'); + ClientCheckpoint.attachTo(clientApp.dataSources.db); + + LocalUser = loopback.createModel('LocalUser', USER_PROPS, USER_OPTS); + if (LocalUser.Change) LocalUser.Change.Checkpoint = ClientCheckpoint; + clientApp.model(LocalUser, { dataSource: 'db' }); + + LocalCar = loopback.createModel('LocalCar', CAR_PROPS, CAR_OPTS); + LocalCar.Change.Checkpoint = ClientCheckpoint; + clientApp.model(LocalCar, { dataSource: 'db' }); + + var remoteOpts = createRemoteModelOpts(USER_OPTS); + RemoteUser = loopback.createModel('RemoteUser', USER_PROPS, remoteOpts); + clientApp.model(RemoteUser, { dataSource: 'remote' }); + + remoteOpts = createRemoteModelOpts(CAR_OPTS); + RemoteCar = loopback.createModel('RemoteCar', CAR_PROPS, remoteOpts); + clientApp.model(RemoteCar, { dataSource: 'remote' }); + } + + function createRemoteModelOpts(modelOpts) { + return extend(modelOpts, { + // Disable change tracking, server will call rectify/rectifyAll + // after each change, because it's tracking the changes too. + trackChanges: false, + // Enable remote replication in order to get remoting API metadata + // used by the remoting connector + enableRemoteReplication: true + }); + } + + function seedServerData(done) { + async.series([ + function(next) { + serverApp.dataSources.db.automigrate(next); + }, + function(next) { + ServerUser.deleteAll(next); + }, + function(next) { + ServerUser.create([ALICE, PETER, EMERY], function(err, created) { + if (err) return next(err); + aliceId = created[0].id; + peterId = created[1].id; + next(); + }); + }, + function(next) { + ServerUser.login(ALICE, function(err, token) { + if (err) return next(err); + aliceToken = token.id; + + ServerUser.login(PETER, function(err, token) { + if (err) return next(err); + peterToken = token.id; + + ServerUser.login(EMERY, function(err, token) { + emeryToken = token.id; + + next(); + }); + }); + }); + }, + function(next) { + ServerCar.create( + [ + { maker: 'Ford', model: 'Mustang' }, + { maker: 'Audi', model: 'R8' } + ], + function(err, cars) { + if (err) return next(err); + serverCars = cars.map(carToString); + next(); + }); + } + ], done); + } + + function seedClientData(done) { + LocalUser.deleteAll(function(err) { + if (err) return done(err); + LocalCar.deleteAll(function(err) { + if (err) return done(err); + LocalCar.create( + [{ maker: 'Local', model: 'Custom' }], + function(err, cars) { + if (err) return done(err); + clientCars = cars.map(carToString); + done(); + }); + }); + }); + } + + function setAccessToken(token) { + clientApp.dataSources.remote.connector.remotes.auth = { + bearer: new Buffer(token).toString('base64'), + sendImmediately: true + }; + } + + function expectHttpError(code, done) { + return function(err) { + if (!err) return done(new Error('The method should have failed.')); + expect(err).to.have.property('statusCode', code); + done(); + }; + } + + function replicateServerToLocal(next) { + ServerUser.replicate(LocalUser, function(err, conflicts) { + if (err) return next(err); + if (conflicts.length) return next(conflictError(conflicts)); + next(); + }); + } + + function conflictError(conflicts) { + var err = new Error('Unexpected conflicts\n' + + conflicts.map(JSON.stringify).join('\n')); + err.name = 'ConflictError'; + } + + function carToString(c) { + return c.maker ? c.maker + ' ' + c.model : c.model; + } +}); diff --git a/test/replication.test.js b/test/replication.test.js index cc091d03..2d0eb21c 100644 --- a/test/replication.test.js +++ b/test/replication.test.js @@ -1,3 +1,4 @@ +var assert = require('assert'); var async = require('async'); var loopback = require('../'); var Change = loopback.Change; diff --git a/test/util/model-tests.js b/test/util/model-tests.js index 487e9820..cd89f307 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -50,6 +50,10 @@ module.exports = function defineModelTestsWithDataSource(options) { }); User.attachTo(dataSource); + User.handleChangeError = function(err) { + console.warn('WARNING: unhandled change-tracking error'); + console.warn(err); + }; }); describe('Model.validatesPresenceOf(properties...)', function() {