Merge pull request #1294 from strongloop/feature/conflict-resolution-access-control

Conflict resolution and Access control
This commit is contained in:
Miroslav Bajtoš 2015-04-14 09:36:40 +02:00
commit bd973def63
3 changed files with 255 additions and 30 deletions

View File

@ -617,9 +617,8 @@ module.exports = function(Change) {
], done);
function getSourceChange(cb) {
conflict.SourceChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
var SourceModel = conflict.SourceModel;
SourceModel.findLastChange(conflict.modelId, function(err, change) {
if (err) return cb(err);
sourceChange = change;
cb();
@ -627,9 +626,8 @@ module.exports = function(Change) {
}
function getTargetChange(cb) {
conflict.TargetChange.findOne({where: {
modelId: conflict.modelId
}}, function(err, change) {
var TargetModel = conflict.TargetModel;
TargetModel.findLastChange(conflict.modelId, function(err, change) {
if (err) return cb(err);
targetChange = change;
cb();
@ -658,10 +656,14 @@ module.exports = function(Change) {
Conflict.prototype.resolve = function(cb) {
var conflict = this;
conflict.changes(function(err, sourceChange, targetChange) {
conflict.TargetModel.findLastChange(
this.modelId,
function(err, targetChange) {
if (err) return cb(err);
sourceChange.prev = targetChange.rev;
sourceChange.save(cb);
conflict.SourceModel.updateLastChange(
conflict.modelId,
{ prev: targetChange.rev },
cb);
});
};
@ -692,7 +694,9 @@ module.exports = function(Change) {
if (target === null) {
return conflict.SourceModel.deleteById(conflict.modelId, done);
}
var inst = new conflict.SourceModel(target);
var inst = new conflict.SourceModel(
target.toObject(),
{ persisted: true });
inst.save(done);
});
@ -702,6 +706,23 @@ module.exports = function(Change) {
}
};
/**
* Return a new Conflict instance with swapped Source and Target models.
*
* This is useful when resolving a conflict in one-way
* replication, where the source data must not be changed:
*
* ```js
* conflict.swapParties().resolveUsingTarget(cb);
* ```
*
* @returns {Conflict} A new Conflict instance.
*/
Conflict.prototype.swapParties = function() {
var Ctor = this.constructor;
return new Ctor(this.modelId, this.TargetModel, this.SourceModel);
};
/**
* Resolve the conflict using the supplied instance data.
*

View File

@ -705,6 +705,37 @@ module.exports = function(registry) {
accepts: {arg: 'updates', type: 'array'},
http: {verb: 'post', path: '/bulk-update'}
});
setRemoting(PersistedModel, 'findLastChange', {
description: 'Get the most recent change record for this instance.',
accessType: 'READ',
accepts: {
arg: 'id', type: 'any', required: true, http: { source: 'path' },
description: 'Model id'
},
returns: { arg: 'result', type: this.Change.modelName, root: true },
http: { verb: 'get', path: '/:id/changes/last' }
});
setRemoting(PersistedModel, 'updateLastChange', {
description: [
'Update the properties of the most recent change record',
'kept for this instance.'
],
accessType: 'WRITE',
accepts: [
{
arg: 'id', type: 'any', required: true, http: { source: 'path' },
description: 'Model id'
},
{
arg: 'data', type: 'object', http: {source: 'body'},
description: 'An object of Change property name/value pairs'
},
],
returns: { arg: 'result', type: this.Change.modelName, root: true },
http: { verb: 'put', path: '/:id/changes/last' }
});
}
if (options.trackChanges) {
@ -1423,10 +1454,15 @@ module.exports = function(registry) {
if (this.dataSource) {
attachRelatedModels(this);
} else {
this.once('dataSourceAttached', attachRelatedModels);
}
// We have to attach related model whenever the datasource changes,
// this is a workaround for autoAttach called by loopback.createModel
var self = this;
this.on('dataSourceAttached', function() {
attachRelatedModels(self);
});
return this.Change;
function attachRelatedModels(self) {
@ -1464,6 +1500,26 @@ module.exports = function(registry) {
Change.rectifyModelChanges(this.modelName, [id], callback);
};
PersistedModel.findLastChange = function(id, cb) {
var Change = this.getChangeModel();
Change.findOne({ where: { modelId: id } }, cb);
};
PersistedModel.updateLastChange = function(id, data, cb) {
var self = this;
this.findLastChange(id, function(err, inst) {
if (err) return cb(err);
if (!inst) {
err = new Error('No change record found for ' +
self.modelName + ' with id ' + id);
err.statusCode = 404;
return cb(err);
}
inst.updateAttributes(data, cb);
});
};
PersistedModel.setup();
return PersistedModel;

View File

@ -13,6 +13,7 @@ describe('Replication over REST', function() {
var serverApp, serverUrl, ServerUser, ServerCar, serverCars;
var aliceId, peterId, aliceToken, peterToken, emeryToken, request;
var clientApp, LocalUser, LocalCar, RemoteUser, RemoteCar, clientCars;
var conflictedCarId;
before(setupServer);
before(setupClient);
@ -154,9 +155,137 @@ describe('Replication over REST', function() {
});
});
});
});
// TODO conflict resolution
// TODO verify permissions of individual methods
describe('conflict resolution with model-level permissions', function() {
var LocalConflict, RemoteConflict;
before(function setupConflictModels() {
LocalConflict = LocalCar.getChangeModel().Conflict;
RemoteConflict = RemoteCar.getChangeModel().Conflict;
});
beforeEach(seedConflict);
describe('as anonymous user', function() {
it('rejects resolve() on the client', function(done) {
// simulate replication Client->Server
var conflict = new LocalConflict(
conflictedCarId,
LocalCar,
RemoteCar);
conflict.resolveUsingSource(expectHttpError(401, done));
});
it('rejects resolve() on the server', function(done) {
// simulate replication Server->Client
var conflict = new RemoteConflict(
conflictedCarId,
RemoteCar,
LocalCar);
conflict.resolveUsingSource(expectHttpError(401, done));
});
});
describe('as user with READ-only permissions', function() {
beforeEach(function() {
setAccessToken(emeryToken);
});
it('allows resolve() on the client', function(done) {
// simulate replication Client->Server
var conflict = new LocalConflict(
conflictedCarId,
LocalCar,
RemoteCar);
conflict.resolveUsingSource(done);
});
it('rejects resolve() on the server', function(done) {
// simulate replication Server->Client
var conflict = new RemoteConflict(
conflictedCarId,
RemoteCar,
LocalCar);
conflict.resolveUsingSource(expectHttpError(401, done));
});
});
describe('as user with REPLICATE-only permissions', function() {
beforeEach(function() {
setAccessToken(aliceToken);
});
it('allows reverse resolve() on the client', function(done) {
RemoteCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
expect(conflicts, 'conflicts').to.have.length(1);
// By default, conflicts are always resolved by modifying
// the source datasource, so that the next replication run
// replicates the resolved data.
// However, when replicating changes from a datasource that
// users are not authorized to change, the users have to resolve
// conflicts at the target, discarding any changes made in
// the target datasource only.
conflicts[0].swapParties().resolveUsingTarget(function(err) {
if (err) return done(err);
RemoteCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
if (conflicts.length) return done(conflictError(conflicts));
done();
});
});
});
});
it('rejects resolve() on the server', function(done) {
RemoteCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
expect(conflicts, 'conflicts').to.have.length(1);
conflicts[0].resolveUsingSource(expectHttpError(401, done));
});
});
});
describe('as user with READ and WRITE permissions', function() {
beforeEach(function() {
setAccessToken(peterToken);
});
it('allows resolve() on the client', function(done) {
LocalCar.replicate(RemoteCar, function(err, conflicts) {
if (err) return done(err);
expect(conflicts).to.have.length(1);
conflicts[0].resolveUsingSource(function(err) {
if (err) return done(err);
LocalCar.replicate(RemoteCar, function(err, conflicts) {
if (err) return done(err);
if (conflicts.length) return done(conflictError(conflicts));
done();
});
});
});
});
it('allows resolve() on the server', function(done) {
RemoteCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
expect(conflicts).to.have.length(1);
conflicts[0].resolveUsingSource(function(err) {
if (err) return done(err);
RemoteCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
if (conflicts.length) return done(conflictError(conflicts));
done();
});
});
});
});
});
});
describe.skip('sync with instance-level permissions', function() {
@ -222,7 +351,7 @@ describe('Replication over REST', function() {
], done);
});
// TODO verify conflict resolution
// TODO(bajtos) verify conflict resolution
function setupModifiedLocalCopyOfAlice(done) {
// Replicate directly, bypassing REST+AUTH layers
@ -245,7 +374,7 @@ describe('Replication over REST', function() {
base: 'User',
plural: 'Users', // use the same REST path in all models
trackChanges: true,
strict: true,
strict: 'throw',
persistUndefinedAsNull: true
};
@ -259,7 +388,7 @@ describe('Replication over REST', function() {
base: 'PersistedModel',
plural: 'Cars', // use the same REST path in all models
trackChanges: true,
strict: true,
strict: 'throw',
persistUndefinedAsNull: true,
acls: [
// disable anonymous access
@ -390,9 +519,6 @@ describe('Replication over REST', function() {
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);
@ -421,8 +547,8 @@ describe('Replication over REST', function() {
function(next) {
ServerCar.create(
[
{ maker: 'Ford', model: 'Mustang' },
{ maker: 'Audi', model: 'R8' }
{ id: 'Ford-Mustang', maker: 'Ford', model: 'Mustang' },
{ id: 'Audi-R8', maker: 'Audi', model: 'R8' }
],
function(err, cars) {
if (err) return next(err);
@ -434,16 +560,38 @@ describe('Replication over REST', function() {
}
function seedClientData(done) {
LocalUser.deleteAll(function(err) {
if (err) return done(err);
LocalCar.deleteAll(function(err) {
if (err) return done(err);
async.series([
function(next) {
clientApp.dataSources.db.automigrate(next);
},
function(next) {
LocalCar.create(
[{ maker: 'Local', model: 'Custom' }],
function(err, cars) {
if (err) return done(err);
if (err) return next(err);
clientCars = cars.map(carToString);
done();
next();
});
},
], done);
}
function seedConflict(done) {
LocalCar.replicate(ServerCar, function(err, conflicts) {
if (err) return done(err);
if (conflicts.length) return done(conflictError(conflicts));
ServerCar.replicate(LocalCar, function(err, conflicts) {
if (err) return done(err);
if (conflicts.length) return done(conflictError(conflicts));
// Hard-coded, see the seed data above
conflictedCarId = 'Ford-Mustang';
new LocalCar({ id: conflictedCarId })
.updateAttributes({ model: 'Client' }, function(err, c) {
if (err) return done(err);
new ServerCar({ id: conflictedCarId })
.updateAttributes({ model: 'Server' }, done);
});
});
});