diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index d35b80d279..f71db2aada 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -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; diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index d6b02ed4e8..d6d26c6af5 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -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'] } }, diff --git a/src/RestWrite.js b/src/RestWrite.js index 82e136784b..497ebeb8b3 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -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');