Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions common/models/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
"permission": "ALLOW",
"property": "updateAttributes"
},
{
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "replaceById"
},
{
"principalType": "ROLE",
"principalId": "$everyone",
Expand Down
74 changes: 63 additions & 11 deletions lib/persisted-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ module.exports = function(registry) {
* @param {Object} model Updated model instance.
*/

PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) {
PersistedModel.upsert = PersistedModel.updateOrCreate = PersistedModel.patchOrCreate =
function upsert(data, callback) {
throwNotAttached(this.modelName, 'upsert');
};

Expand Down Expand Up @@ -494,7 +495,8 @@ module.exports = function(registry) {
* @param {Object} instance Updated instance.
*/

PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) {
PersistedModel.prototype.updateAttributes = PersistedModel.prototype.patchAttributes =
function updateAttributes(data, cb) {
throwNotAttached(this.modelName, 'updateAttributes');
};

Expand Down Expand Up @@ -600,6 +602,9 @@ module.exports = function(registry) {
var typeName = PersistedModel.modelName;
var options = PersistedModel.settings;

// This is just for LB 3.x
options.replaceOnPUT = options.replaceOnPUT !== false;

function setRemoting(scope, name, options) {
var fn = scope[name];
fn._delegate = true;
Expand All @@ -616,15 +621,35 @@ module.exports = function(registry) {
http: { verb: 'post', path: '/' },
});

setRemoting(PersistedModel, 'upsert', {
aliases: ['updateOrCreate'],
description: 'Update an existing model instance or insert a new one into the data source.',
var upsertOptions = {
aliases: ['upsert', 'updateOrCreate'],
description: 'Patch an existing model instance or insert a new one into the data source.',
accessType: 'WRITE',
accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description:
'Model instance data' },
returns: { arg: 'data', type: typeName, root: true },
http: { verb: 'put', path: '/' },
});
http: [{ verb: 'patch', path: '/' }],
};

if (!options.replaceOnPUT) {
upsertOptions.http.push({ verb: 'put', path: '/' });
}
setRemoting(PersistedModel, 'patchOrCreate', upsertOptions);

var replaceOrCreateOptions = {
description: 'Replace an existing model instance or insert a new one into the data source.',
accessType: 'WRITE',
accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description:
'Model instance data' },
returns: { arg: 'data', type: typeName, root: true },
http: [{ verb: 'post', path: '/replaceOrCreate' }],
};

if (options.replaceOnPUT) {
replaceOrCreateOptions.http.push({ verb: 'put', path: '/' });
}

setRemoting(PersistedModel, 'replaceOrCreate', replaceOrCreateOptions);

setRemoting(PersistedModel, 'exists', {
description: 'Check whether a model instance exists in the data source.',
Expand Down Expand Up @@ -671,6 +696,26 @@ module.exports = function(registry) {
rest: { after: convertNullToNotFoundError },
});

var replaceByIdOptions = {
description: 'Replace attributes for a model instance and persist it into the data source.',
accessType: 'WRITE',
accepts: [
{ arg: 'id', type: 'any', description: 'Model id', required: true,
http: { source: 'path' }},
{ arg: 'data', type: 'object', http: { source: 'body' }, description:
'Model instance data' },
],
returns: { arg: 'data', type: typeName, root: true },
http: [{ verb: 'post', path: '/:id/replace' }],
};

if (options.replaceOnPUT) {
replaceByIdOptions.http.push({ verb: 'put', path: '/:id' });
}

setRemoting(PersistedModel, 'replaceById', replaceByIdOptions);


setRemoting(PersistedModel, 'find', {
description: 'Find all instances of the model matched by filter from the data source.',
accessType: 'READ',
Expand Down Expand Up @@ -741,13 +786,20 @@ module.exports = function(registry) {
http: { verb: 'get', path: '/count' },
});

setRemoting(PersistedModel.prototype, 'updateAttributes', {
description: 'Update attributes for a model instance and persist it into the data source.',
var updateAttributesOptions = {
aliases: ['updateAttributes'],
description: 'Patch attributes for a model instance and persist it into the data source.',
accessType: 'WRITE',
accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description: 'An object of model property name/value pairs' },
returns: { arg: 'data', type: typeName, root: true },
http: { verb: 'put', path: '/' },
});
http: [{ verb: 'patch', path: '/' }],
};

setRemoting(PersistedModel.prototype, 'patchAttributes', updateAttributesOptions);

if (!options.replaceOnPUT) {
updateAttributesOptions.http.push({ verb: 'put', path: '/' });
}

if (options.trackChanges || options.enableRemoteReplication) {
setRemoting(PersistedModel, 'diff', {
Expand Down
125 changes: 110 additions & 15 deletions test/access-control.integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,15 @@ describe('access control - integration', function() {
assert.equal(user.password, undefined);
});
});

// user has replaceOnPUT = false; so then both PUT and PATCH should be allowed for update
lt.describe.whenCalledRemotely('PUT', '/api/users/:id', function() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test will have to be changed when back-ported to 2.x, because replaceById will be exposed at /api/users/:id/replace only. Could you please add a note so that we don't forget?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, only now I realised that you are explicitly setting replaceOnPUT=false for User model. In that case, I think we need to add a test case verifying that POST /api/users/:id/replace is allowed.

@Amir-61 Amir-61 Jun 7, 2016

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we decided not to include ACL test for replace for user model because it replaces email, password,...which breaks... instead we are testing it for account model...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough.

lt.it.shouldBeAllowed();
});

lt.describe.whenCalledRemotely('PATCH', '/api/users/:id', function() {
lt.it.shouldBeAllowed();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add test(s) for new replace* endpoints too, to verify that ACLs work correctly for them too.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you are testing new replace* endpoints below for replaceOnPUT:true. I think we should test the endpoints for replaceOnPUT:false too.

@Amir-61 Amir-61 May 25, 2016

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should test the endpoints for replaceOnPUT:false too.

I already have tests for replaceOnPUT:false Please see:
https://github.com/strongloop/loopback/pull/2316/files#diff-eeafd17907dda5058f6e881724ba8c6eR245

});

lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForUser);
Expand Down Expand Up @@ -163,7 +169,7 @@ describe('access control - integration', function() {
}
});

describe('/accounts', function() {
describe('/accounts with replaceOnPUT true', function() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifying existing test suite and adding more tests into it is not a good practice. It makes it more difficult to understand the scope of this change, why are we changing existing tests not related to replace* methods to run against a model with replaceOnPUT set to true?

A better approach is to create a new describe block where you will use the new accountWithReplaceOnPUTtrue and then add only tests that are related to replaceOnPUT into this new block.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I should have read the full patch before commenting. So you actually did what I proposed in /accounts with replaceOnPUT false. Can we please simplify that other describe block and keep there only tests relevant to replaceOnPut?

var count = 0;
before(function() {
var roleModel = loopback.getModelByType(loopback.Role);
Expand All @@ -177,56 +183,145 @@ describe('access control - integration', function() {
});
});

lt.beforeEach.givenModel('account');
lt.beforeEach.givenModel('accountWithReplaceOnPUTtrue');

lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts-replacing');
lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts-replacing');
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts-replacing');

lt.it.shouldBeDeniedWhenCalledAnonymously('GET', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', urlForAccount);

lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts');
lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts-replacing');
lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts-replacing');
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts-replacing');

lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST);

lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount);

lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount);

lt.describe.whenLoggedInAsUser(CURRENT_USER, function() {
var actId;
beforeEach(function(done) {
var self = this;

// Create an account under the given user
app.models.account.create({
app.models.accountWithReplaceOnPUTtrue.create({
userId: self.user.id,
balance: 100,
}, function(err, act) {
self.url = '/api/accounts/' + act.id;
actId = act.id;
self.url = '/api/accounts-replacing/' + actId;
done();
});
});

lt.describe.whenCalledRemotely('PATCH', '/api/accounts-replacing/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('PUT', '/api/accounts-replacing/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('GET', '/api/accounts-replacing/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('DELETE', '/api/accounts-replacing/:id', function() {
lt.it.shouldBeDenied();
});
describe('replace on POST verb', function() {
beforeEach(function(done) {
this.url = '/api/accounts-replacing/' + actId + '/replace';
done();
});
lt.describe.whenCalledRemotely('POST', '/api/accounts-replacing/:id/replace', function() {
lt.it.shouldBeAllowed();
});
});
});

lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount);

lt.describe.whenCalledRemotely('PUT', '/api/accounts/:id', function() {
function urlForAccount() {
return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id;
}
function urlForReplaceAccountPOST() {
return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id + '/replace';
}
});

describe('/accounts with replaceOnPUT false', function() {
lt.beforeEach.givenModel('accountWithReplaceOnPUTfalse');
lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST);

lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount);

lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount);

lt.describe.whenLoggedInAsUser(CURRENT_USER, function() {
var actId;
beforeEach(function(done) {
var self = this;
// Create an account under the given user
app.models.accountWithReplaceOnPUTfalse.create({
userId: self.user.id,
balance: 100,
}, function(err, act) {
actId = act.id;
self.url = '/api/accounts-updating/' + actId;
done();
});
});

lt.describe.whenCalledRemotely('PATCH', '/api/accounts-updating/:id', function() {
lt.it.shouldBeAllowed();
});

lt.describe.whenCalledRemotely('PUT', '/api/accounts-updating/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('GET', '/api/accounts/:id', function() {
lt.describe.whenCalledRemotely('GET', '/api/accounts-updating/:id', function() {
lt.it.shouldBeAllowed();
});
lt.describe.whenCalledRemotely('DELETE', '/api/accounts/:id', function() {
lt.describe.whenCalledRemotely('DELETE', '/api/accounts-updating/:id', function() {
lt.it.shouldBeDenied();
});

describe('replace on POST verb', function() {
beforeEach(function(done) {
this.url = '/api/accounts-updating/' + actId + '/replace';
done();
});
lt.describe.whenCalledRemotely('POST', '/api/accounts-updating/:id/replace', function() {
lt.it.shouldBeAllowed();
});
});
});

lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount);
lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForAccount);
lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount);

function urlForAccount() {
return '/api/accounts/' + this.account.id;
return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id;
}
function urlForReplaceAccountPOST() {
return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id + '/replace';
}
});
});
6 changes: 4 additions & 2 deletions test/fixtures/access-control/common/models/account.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "account",
"name": "accountWithReplaceOnPUTtrue",
"plural": "accounts-replacing",
"relations": {
"transactions": {
"model": "transaction",
Expand Down Expand Up @@ -38,5 +39,6 @@
"principalId": "$dummy"
}
],
"properties": {}
"properties": {},
"replaceOnPUT": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "accountWithReplaceOnPUTfalse",
"plural": "accounts-updating",
"relations": {
"transactions": {
"model": "transaction",
"type": "hasMany"
},
"user": {
"model": "user",
"type": "belongsTo",
"foreignKey": "userId"
}
},
"acls": [
{
"accessType": "*",
"permission": "DENY",
"principalType": "ROLE",
"principalId": "$everyone"
},
{
"accessType": "*",
"permission": "ALLOW",
"principalType": "ROLE",
"principalId": "$owner"
},
{
"permission": "DENY",
"principalType": "ROLE",
"principalId": "$owner",
"property": "deleteById"
},
{
"accessType": "*",
"permission": "DENY",
"property": "find",
"principalType": "ROLE",
"principalId": "$dummy"
}
],
"properties": {},
"replaceOnPUT": false
}
3 changes: 2 additions & 1 deletion test/fixtures/access-control/common/models/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
"principalType": "ROLE",
"principalId": "$everyone"
}
]
],
"replaceOnPUT": false
}
Loading