Skip to content
Closed
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
170 changes: 170 additions & 0 deletions spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,176 @@ describe('Cloud Code', () => {
expect(obj.get('foo')).toBe('bar');
});

it('before save can revert a dot notation operation without skipping afterSave', async () => {
const obj = await new Parse.Object('BeforeSaveRevert').save({
field: { sub: 'original', other: 1 },
count: 0,
});
Parse.Cloud.beforeSave('BeforeSaveRevert', ({ object }) => {
object.revert('field.sub');
});
const afterSave = jasmine.createSpy('afterSave');
Parse.Cloud.afterSave('BeforeSaveRevert', afterSave);

const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/BeforeSaveRevert/${obj.id}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
'field.sub': { __op: 'Delete' },
'field.other': { __op: 'Increment', amount: 1 },
count: { __op: 'Increment', amount: 1 },
},
}).catch(error => error);

expect(response.status).toBe(200);
expect(response.data['field.sub']).toBe('original');
expect(afterSave).toHaveBeenCalledTimes(1);
if (afterSave.calls.count()) {
const { object, original } = afterSave.calls.first().args[0];
expect(object.get('field')).toEqual({ sub: 'original', other: 2 });
expect(object.get('count')).toBe(1);
expect(original.get('field')).toEqual({ sub: 'original', other: 1 });
}
await obj.fetch();
expect(obj.get('field')).toEqual({ sub: 'original', other: 2 });
expect(obj.get('count')).toBe(1);
});

describe('before save reverts dot notation operations', () => {
const cases = [
{ name: 'Delete on a string', value: 'original', op: { __op: 'Delete' } },
{ name: 'Delete on zero', value: 0, op: { __op: 'Delete' } },
{ name: 'Delete on false', value: false, op: { __op: 'Delete' } },
{ name: 'Delete on null', value: null, op: { __op: 'Delete' } },
{ name: 'Delete on an object', value: { nested: 'original' }, op: { __op: 'Delete' } },
{ name: 'Increment', value: 5, op: { __op: 'Increment', amount: 1 } },
{ name: 'Add', value: ['original'], op: { __op: 'Add', objects: ['new'] } },
{ name: 'AddUnique', value: ['original'], op: { __op: 'AddUnique', objects: ['new'] } },
{ name: 'Remove', value: ['original'], op: { __op: 'Remove', objects: ['original'] } },
{ name: 'Increment on a missing field', op: { __op: 'Increment', amount: 1 } },
];

for (const { name, value, op } of cases) {
it(`restores the SDK and afterSave object for ${name}`, async () => {
const field = { other: 'unchanged' };
if (value !== undefined) {
field.sub = value;
}
const obj = await new Parse.Object('BeforeSaveRevert').save({ field });
Parse.Cloud.beforeSave('BeforeSaveRevert', ({ object }) => {
object.revert('field.sub');
});
const afterSave = jasmine.createSpy('afterSave');
Parse.Cloud.afterSave('BeforeSaveRevert', afterSave);

obj.set('field.sub', op);
await obj.save();

expect(obj.get('field')).toEqual(field);
expect(afterSave).toHaveBeenCalledTimes(1);
expect(afterSave.calls.first().args[0].object.get('field')).toEqual(field);
await obj.fetch();
expect(obj.get('field')).toEqual(field);
});
}

it('restores a deeply nested field without an afterSave trigger', async () => {
const field = { nested: { sub: 5, other: 'unchanged' } };
const obj = await new Parse.Object('BeforeSaveRevert').save({ field });
Parse.Cloud.beforeSave('BeforeSaveRevert', ({ object }) => {
object.revert('field.nested.sub');
});

obj.increment('field.nested.sub');
await obj.save();

expect(obj.get('field')).toEqual(field);
await obj.fetch();
expect(obj.get('field')).toEqual(field);
});

it('reverts all operations on a new object', async () => {
Parse.Cloud.beforeSave('BeforeSaveRevert', ({ object }) => {
object.revert();
});
const afterSave = jasmine.createSpy('afterSave');
Parse.Cloud.afterSave('BeforeSaveRevert', afterSave);
const obj = new Parse.Object('BeforeSaveRevert');
obj.increment('field.sub');
obj.increment('count');
await obj.save();

expect(obj.get('field')?.sub).toBeUndefined();
expect(obj.get('count')).toBeUndefined();
expect(afterSave).toHaveBeenCalledTimes(1);
expect(afterSave.calls.first().args[0].object.get('field')?.sub).toBeUndefined();
await obj.fetch();
expect(obj.get('field')?.sub).toBeUndefined();
expect(obj.get('count')).toBeUndefined();
});

it('reverts a dot notation operation on an installation pointer', async () => {
const pointer = await new Parse.Object('Target').save();
const obj = await new Parse.Object('_Installation').save({
deviceType: 'ios',
deviceToken: '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306',
field: pointer,
});
Parse.Cloud.beforeSave('_Installation', ({ object }) => {
object.revert('field.sub');
});
const afterSave = jasmine.createSpy('afterSave');
Parse.Cloud.afterSave('_Installation', afterSave);

const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/_Installation/${obj.id}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: { 'field.sub': { __op: 'Delete' } },
});

expect(response.status).toBe(200);
expect(response.data['field.sub']).toEqual({ __op: 'Delete' });
expect(afterSave).toHaveBeenCalledTimes(1);
expect(afterSave.calls.first().args[0].object.get('field').id).toBe(pointer.id);
await obj.fetch({ useMasterKey: true });
expect(obj.get('field').id).toBe(pointer.id);
});

it('delivers a LiveQuery update without an afterSave trigger', async () => {
const obj = await new TestObject().save({ field: { sub: 'original' }, count: 0 });
Parse.Cloud.beforeSave('TestObject', ({ object }) => {
object.revert('field.sub');
});
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
try {
const subscription = await new Parse.Query('TestObject')
.equalTo('objectId', obj.id)
.subscribe();
const update = new Promise(resolve => subscription.on('update', resolve));
obj.unset('field.sub');
obj.increment('count');
await obj.save();

const updatedObject = await update;
expect(updatedObject.get('field')).toEqual({ sub: 'original' });
expect(updatedObject.get('count')).toBe(1);
expect(obj.get('field')).toEqual({ sub: 'original' });
} finally {
await client.close();
}
});
});

it('create role with name and ACL and a beforeSave', async () => {
Parse.Cloud.beforeSave(Parse.Role, ({ object }) => {
return object;
Expand Down
58 changes: 58 additions & 0 deletions spec/ProtectedFields.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2394,6 +2394,64 @@ describe('ProtectedFields', function () {
});

describe('protectedFieldsSaveResponseExempt', function () {
for (const auth of ['anonymous', 'session', 'master', 'maintenance']) {
it(`filters reverted dot notation fields for ${auth} auth`, async () => {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: true,
protectedFieldsSaveResponseExempt: false,
});
const obj = await new Parse.Object('MyClass').save(
{
secretField: { sub: 'hidden' },
secretFieldPublic: { sub: 'visible' },
},
{ useMasterKey: true }
);
Parse.Cloud.beforeSave('MyClass', ({ object }) => {
object.revert('secretField.sub', 'secretFieldPublic.sub');
});
const afterSave = jasmine.createSpy('afterSave');
Parse.Cloud.afterSave('MyClass', afterSave);
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
};
if (auth === 'session') {
const user = await Parse.User.signUp('testuser', 'password');
headers['X-Parse-Session-Token'] = user.getSessionToken();
} else if (auth === 'master') {
headers['X-Parse-Master-Key'] = 'test';
} else if (auth === 'maintenance') {
headers['X-Parse-Maintenance-Key'] = 'testing';
}

const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/MyClass/${obj.id}`,
headers,
body: JSON.stringify({
'secretField.sub': { __op: 'Delete' },
'secretFieldPublic.sub': { __op: 'Delete' },
}),
});

expect(response.status).toBe(200);
expect(response.data['secretFieldPublic.sub']).toBe('visible');
if (auth === 'master' || auth === 'maintenance') {
expect(response.data['secretField.sub']).toBe('hidden');
} else {
expect(response.data.secretField).toBeUndefined();
expect(response.data['secretField.sub']).toBeUndefined();
}
expect(afterSave).toHaveBeenCalledTimes(1);
expect(afterSave.calls.first().args[0].object.get('secretField')).toEqual({ sub: 'hidden' });
await obj.fetch({ useMasterKey: true });
expect(obj.get('secretField')).toEqual({ sub: 'hidden' });
});
}

it('should strip protected fields from update response when protectedFieldsSaveResponseExempt is false', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
Expand Down
13 changes: 10 additions & 3 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -2117,20 +2117,27 @@ RestWrite.prototype.filterProtectedFieldsInResponse = async function () {
if (!protectedFields) {
return;
}
for (const field of protectedFields) {
delete this.response.response[field];
for (const key of Object.keys(this.response.response)) {
if (protectedFields.some(field => key === field || key.startsWith(`${field}.`))) {
delete this.response.response[key];
}
}
};

RestWrite.prototype._updateResponseWithData = function (response, data) {
const stateController = Parse.CoreManager.getObjectStateController();
const [pending] = stateController.getPendingOps(this.pendingOps.identifier);
let revertedData;
for (const key in this.pendingOps.operations) {
if (!pending[key]) {
data[key] = this.originalData ? this.originalData[key] : { __op: 'Delete' };
// Reverted fields only correct the response; keep them out of the write data
// used to build the afterSave and LiveQuery objects.
revertedData ||= { ...data };
revertedData[key] = _.get(this.originalData, key.split('.'), { __op: 'Delete' });
this.storage.fieldsChangedByTrigger.push(key);
}
}
data = revertedData || data;
const skipKeys = [...(requiredColumns.read[this.className] || [])];
if (!this.query) {
skipKeys.push('objectId', 'createdAt');
Expand Down
Loading