Merge pull request #1270 from strongloop/feature/replication-access-control
Basic access control for change replication
This commit is contained in:
commit
b18d2516a4
|
@ -88,6 +88,7 @@ module.exports = function(ACL) {
|
||||||
ACL.DENY = AccessContext.DENY; // Deny
|
ACL.DENY = AccessContext.DENY; // Deny
|
||||||
|
|
||||||
ACL.READ = AccessContext.READ; // Read operation
|
ACL.READ = AccessContext.READ; // Read operation
|
||||||
|
ACL.REPLICATE = AccessContext.REPLICATE; // Replicate (pull) changes
|
||||||
ACL.WRITE = AccessContext.WRITE; // Write operation
|
ACL.WRITE = AccessContext.WRITE; // Write operation
|
||||||
ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation
|
ACL.EXECUTE = AccessContext.EXECUTE; // Execute operation
|
||||||
|
|
||||||
|
@ -109,21 +110,31 @@ module.exports = function(ACL) {
|
||||||
for (var i = 0; i < props.length; i++) {
|
for (var i = 0; i < props.length; i++) {
|
||||||
// Shift the score by 4 for each of the properties as the weight
|
// Shift the score by 4 for each of the properties as the weight
|
||||||
score = score * 4;
|
score = score * 4;
|
||||||
var val1 = rule[props[i]] || ACL.ALL;
|
var ruleValue = rule[props[i]] || ACL.ALL;
|
||||||
var val2 = req[props[i]] || ACL.ALL;
|
var requestedValue = req[props[i]] || ACL.ALL;
|
||||||
var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(val1) !== -1;
|
var isMatchingMethodName = props[i] === 'property' && req.methodNames.indexOf(ruleValue) !== -1;
|
||||||
|
|
||||||
// accessType: EXECUTE should match READ or WRITE
|
var isMatchingAccessType = ruleValue === requestedValue;
|
||||||
var isMatchingAccessType = props[i] === 'accessType' &&
|
if (props[i] === 'accessType' && !isMatchingAccessType) {
|
||||||
val1 === ACL.EXECUTE;
|
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
|
// Exact match
|
||||||
score += 3;
|
score += 3;
|
||||||
} else if (val1 === ACL.ALL) {
|
} else if (ruleValue === ACL.ALL) {
|
||||||
// Wildcard match
|
// Wildcard match
|
||||||
score += 2;
|
score += 2;
|
||||||
} else if (val2 === ACL.ALL) {
|
} else if (requestedValue === ACL.ALL) {
|
||||||
score += 1;
|
score += 1;
|
||||||
} else {
|
} else {
|
||||||
// Doesn't match at all
|
// Doesn't match at all
|
||||||
|
@ -370,7 +381,8 @@ module.exports = function(ACL) {
|
||||||
* @property {String|Model} model The model name or model class.
|
* @property {String|Model} model The model name or model class.
|
||||||
* @property {*} id The model instance ID.
|
* @property {*} id The model instance ID.
|
||||||
* @property {String} property The property/method/relation name.
|
* @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
|
* @param {Function} callback Callback function
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -388,7 +400,12 @@ module.exports = function(ACL) {
|
||||||
|
|
||||||
var methodNames = context.methodNames;
|
var methodNames = context.methodNames;
|
||||||
var propertyQuery = (property === ACL.ALL) ? undefined : {inq: methodNames.concat([ACL.ALL])};
|
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);
|
var req = new AccessRequest(modelName, property, accessType, ACL.DEFAULT, methodNames);
|
||||||
|
|
||||||
|
@ -438,6 +455,7 @@ module.exports = function(ACL) {
|
||||||
if (callback) callback(err, null);
|
if (callback) callback(err, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolved = self.resolvePermission(effectiveACLs, req);
|
var resolved = self.resolvePermission(effectiveACLs, req);
|
||||||
if (resolved && resolved.permission === ACL.DEFAULT) {
|
if (resolved && resolved.permission === ACL.DEFAULT) {
|
||||||
resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW;
|
resolved.permission = (model && model.settings.defaultPermission) || ACL.ALLOW;
|
||||||
|
|
|
@ -76,6 +76,7 @@ AccessContext.ALL = '*';
|
||||||
|
|
||||||
// Define constants for access types
|
// Define constants for access types
|
||||||
AccessContext.READ = 'READ'; // Read operation
|
AccessContext.READ = 'READ'; // Read operation
|
||||||
|
AccessContext.REPLICATE = 'REPLICATE'; // Replicate (pull) changes
|
||||||
AccessContext.WRITE = 'WRITE'; // Write operation
|
AccessContext.WRITE = 'WRITE'; // Write operation
|
||||||
AccessContext.EXECUTE = 'EXECUTE'; // Execute operation
|
AccessContext.EXECUTE = 'EXECUTE'; // Execute operation
|
||||||
|
|
||||||
|
|
|
@ -333,10 +333,11 @@ module.exports = function(registry) {
|
||||||
// Check the explicit setting of accessType
|
// Check the explicit setting of accessType
|
||||||
if (method.accessType) {
|
if (method.accessType) {
|
||||||
assert(method.accessType === ACL.READ ||
|
assert(method.accessType === ACL.READ ||
|
||||||
|
method.accessType === ACL.REPLICATE ||
|
||||||
method.accessType === ACL.WRITE ||
|
method.accessType === ACL.WRITE ||
|
||||||
method.accessType === ACL.EXECUTE, 'invalid accessType ' +
|
method.accessType === ACL.EXECUTE, 'invalid accessType ' +
|
||||||
method.accessType +
|
method.accessType +
|
||||||
'. It must be "READ", "WRITE", or "EXECUTE"');
|
'. It must be "READ", "REPLICATE", "WRITE", or "EXECUTE"');
|
||||||
return method.accessType;
|
return method.accessType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,8 @@ module.exports = function(registry) {
|
||||||
PersistedModel.once('dataSourceAttached', function() {
|
PersistedModel.once('dataSourceAttached', function() {
|
||||||
PersistedModel.enableChangeTracking();
|
PersistedModel.enableChangeTracking();
|
||||||
});
|
});
|
||||||
|
} else if (this.settings.enableRemoteReplication) {
|
||||||
|
PersistedModel._defineChangeModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistedModel.setupRemoting();
|
PersistedModel.setupRemoting();
|
||||||
|
@ -643,7 +645,7 @@ module.exports = function(registry) {
|
||||||
http: {verb: 'put', path: '/'}
|
http: {verb: 'put', path: '/'}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.trackChanges) {
|
if (options.trackChanges || options.enableRemoteReplication) {
|
||||||
setRemoting(PersistedModel, 'diff', {
|
setRemoting(PersistedModel, 'diff', {
|
||||||
description: 'Get a set of deltas and conflicts since the given checkpoint',
|
description: 'Get a set of deltas and conflicts since the given checkpoint',
|
||||||
accessType: 'READ',
|
accessType: 'READ',
|
||||||
|
@ -670,7 +672,11 @@ module.exports = function(registry) {
|
||||||
|
|
||||||
setRemoting(PersistedModel, 'checkpoint', {
|
setRemoting(PersistedModel, 'checkpoint', {
|
||||||
description: 'Create a 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},
|
returns: {arg: 'checkpoint', type: 'object', root: true},
|
||||||
http: {verb: 'post', path: '/checkpoint'}
|
http: {verb: 'post', path: '/checkpoint'}
|
||||||
});
|
});
|
||||||
|
@ -684,7 +690,10 @@ module.exports = function(registry) {
|
||||||
|
|
||||||
setRemoting(PersistedModel, 'createUpdates', {
|
setRemoting(PersistedModel, 'createUpdates', {
|
||||||
description: 'Create an update list from a delta list',
|
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'}},
|
accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}},
|
||||||
returns: {arg: 'updates', type: 'array', root: true},
|
returns: {arg: 'updates', type: 'array', root: true},
|
||||||
http: {verb: 'post', path: '/create-updates'}
|
http: {verb: 'post', path: '/create-updates'}
|
||||||
|
@ -696,7 +705,11 @@ module.exports = function(registry) {
|
||||||
accepts: {arg: 'updates', type: 'array'},
|
accepts: {arg: 'updates', type: 'array'},
|
||||||
http: {verb: 'post', path: '/bulk-update'}
|
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', {
|
setRemoting(PersistedModel, 'rectifyAllChanges', {
|
||||||
description: 'Rectify all Model changes.',
|
description: 'Rectify all Model changes.',
|
||||||
accessType: 'WRITE',
|
accessType: 'WRITE',
|
||||||
|
@ -1280,7 +1293,7 @@ module.exports = function(registry) {
|
||||||
var changeModel = this.Change;
|
var changeModel = this.Change;
|
||||||
var isSetup = changeModel && changeModel.dataSource;
|
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;
|
return changeModel;
|
||||||
};
|
};
|
||||||
|
@ -1327,9 +1340,6 @@ module.exports = function(registry) {
|
||||||
'which requries a string id with GUID/UUID default value.');
|
'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 save', rectifyOnSave);
|
||||||
|
|
||||||
Model.observe('after delete', rectifyOnDelete);
|
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;
|
return this.Change;
|
||||||
|
|
||||||
|
function attachRelatedModels(self) {
|
||||||
|
self.Change.attachTo(self.dataSource);
|
||||||
|
self.Change.getCheckpointModel().attachTo(self.dataSource);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
PersistedModel.rectifyAllChanges = function(callback) {
|
PersistedModel.rectifyAllChanges = function(callback) {
|
||||||
|
|
|
@ -501,9 +501,11 @@ describe.onServer('Remote Methods', function() {
|
||||||
describe('Model._getACLModel()', function() {
|
describe('Model._getACLModel()', function() {
|
||||||
it('should return the subclass of ACL', function() {
|
it('should return the subclass of ACL', function() {
|
||||||
var Model = require('../').Model;
|
var Model = require('../').Model;
|
||||||
|
var originalValue = Model._ACL();
|
||||||
var acl = ACL.extend('acl');
|
var acl = ACL.extend('acl');
|
||||||
Model._ACL(null); // Reset the ACL class for the base model
|
Model._ACL(null); // Reset the ACL class for the base model
|
||||||
var model = Model._ACL();
|
var model = Model._ACL();
|
||||||
|
Model._ACL(originalValue); // Reset the value back
|
||||||
assert.equal(model, acl);
|
assert.equal(model, acl);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,3 +1,4 @@
|
||||||
|
var assert = require('assert');
|
||||||
var async = require('async');
|
var async = require('async');
|
||||||
var loopback = require('../');
|
var loopback = require('../');
|
||||||
var Change = loopback.Change;
|
var Change = loopback.Change;
|
||||||
|
|
|
@ -3,6 +3,7 @@ describe('loopback.rest', function() {
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
var ds = app.dataSource('db', { connector: loopback.Memory });
|
var ds = app.dataSource('db', { connector: loopback.Memory });
|
||||||
MyModel = ds.createModel('MyModel', {name: String});
|
MyModel = ds.createModel('MyModel', {name: String});
|
||||||
|
loopback.autoAttach();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works out-of-the-box', function(done) {
|
it('works out-of-the-box', function(done) {
|
||||||
|
|
|
@ -30,9 +30,6 @@ beforeEach(function() {
|
||||||
{type: 'STUB'}
|
{type: 'STUB'}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// auto attach data sources to models
|
|
||||||
loopback.autoAttach();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assertValidDataSource = function(dataSource) {
|
assertValidDataSource = function(dataSource) {
|
||||||
|
|
|
@ -23,6 +23,7 @@ describe('User', function() {
|
||||||
|
|
||||||
// Update the AccessToken relation to use the subclass of User
|
// Update the AccessToken relation to use the subclass of User
|
||||||
AccessToken.belongsTo(User);
|
AccessToken.belongsTo(User);
|
||||||
|
User.hasMany(AccessToken);
|
||||||
|
|
||||||
// allow many User.afterRemote's to be called
|
// allow many User.afterRemote's to be called
|
||||||
User.setMaxListeners(0);
|
User.setMaxListeners(0);
|
||||||
|
@ -1071,6 +1072,7 @@ describe('User', function() {
|
||||||
assert.equal(info.accessToken.ttl / 60, 15);
|
assert.equal(info.accessToken.ttl / 60, 15);
|
||||||
assert(calledBack);
|
assert(calledBack);
|
||||||
info.accessToken.user(function(err, user) {
|
info.accessToken.user(function(err, user) {
|
||||||
|
if (err) return done(err);
|
||||||
assert.equal(user.email, email);
|
assert.equal(user.email, email);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,6 +50,10 @@ module.exports = function defineModelTestsWithDataSource(options) {
|
||||||
});
|
});
|
||||||
|
|
||||||
User.attachTo(dataSource);
|
User.attachTo(dataSource);
|
||||||
|
User.handleChangeError = function(err) {
|
||||||
|
console.warn('WARNING: unhandled change-tracking error');
|
||||||
|
console.warn(err);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Model.validatesPresenceOf(properties...)', function() {
|
describe('Model.validatesPresenceOf(properties...)', function() {
|
||||||
|
|
Loading…
Reference in New Issue