diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 261733c3af..b2fa338bfb 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -544,6 +544,236 @@ describe('Installations', () => { }); }); + it('update fails to clear installationId via Delete op', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { __op: 'Delete' } } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + + it('update fails to clear installationId via null', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: null } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + + it('create fails when installationId is the Delete op (no real ID provided)', done => { + const input = { + installationId: { __op: 'Delete' }, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('create fails when installationId is null (no real ID provided)', done => { + const input = { + installationId: null, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('create fails when installationId is a non-string object', done => { + const input = { + installationId: { foo: 'bar' }, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('create fails when installationId is a number', done => { + const input = { + installationId: 12345, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('create fails when installationId is an array', done => { + const input = { + installationId: ['abc'], + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Creating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('update fails when installationId is a non-string object', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { foo: 'bar' } } + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('master key cannot clear installationId via Delete op', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.master(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.master(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: { __op: 'Delete' } } + ); + }) + .then(() => { + fail('Master key clearing of installationId should have been rejected.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + + it('master key cannot clear installationId via null', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.master(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + return rest.update( + config, + auth.master(config), + '_Installation', + { objectId: results[0].objectId }, + { installationId: null } + ); + }) + .then(() => { + fail('Master key clearing of installationId via null should have been rejected.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); + }); + it('update fails to change deviceType', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; let input = { diff --git a/src/RestWrite.js b/src/RestWrite.js index 98c9fd4656..6e2d261479 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1293,6 +1293,42 @@ RestWrite.prototype.handleInstallation = function () { return; } + // installationId is the row's primary identity (used by the SDK auth + // header to bind a client request to its row). Reject any attempt to + // clear it via null or { __op: 'Delete' } before the lookup logic + // below runs — { __op: 'Delete' } would otherwise crash on + // `.toLowerCase()` (TypeError → 500) and null would silently orphan + // the row. Mirrors the existing 136 guard against changing + // installationId from one value to another. + const clearingInstallationId = + this.data.installationId === null || + (typeof this.data.installationId === 'object' && + this.data.installationId !== null && + this.data.installationId.__op === 'Delete'); + if (clearingInstallationId) { + if (this.query) { + throw new Parse.Error( + 136, + 'installationId may not be changed or cleared in this operation' + ); + } + // Create path: drop the invalid value so the existing "must specify + // ID" guard below can run. If no alternative ID (deviceToken, + // auth.installationId) is supplied the create is rejected with + // error 135; otherwise the create proceeds with the remaining ID. + delete this.data.installationId; + } + + // Any remaining non-string installationId (object, array, number, etc.) + // would crash on `.toLowerCase()` below; reject it as a Parse error + // rather than letting it surface as a 500. + if ( + this.data.installationId !== undefined && + typeof this.data.installationId !== 'string' + ) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'installationId must be a string'); + } + if ( !this.query && !this.data.deviceToken &&