From 7c386e6c2c233dff93dcda5dd587cccb8ab2ac23 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 4 May 2026 13:35:29 -0400 Subject: [PATCH] fix: _Installation update fails when clearing deviceToken handleInstallation runs before Parse __op operators are processed, so when a client cleared deviceToken via { __op: 'Delete' } or null, the operator object was being pushed into the Mongo $or lookup query and rejected by transformQueryKeyValue with "You cannot use [object Object] as a query parameter" (Parse error 107). Detect the clearing intent and route the identification/lookup paths through deviceTokenForLookup (undefined when clearing) while leaving this.data.deviceToken untouched so the field is still cleared on write. Adds regression specs covering both clearing shapes and the case where deviceToken is cleared alongside another field update. --- spec/ParseInstallation.spec.js | 104 +++++++++++++++++++++++++++++++++ src/RestWrite.js | 35 +++++++---- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 261733c3af..cbf8e31f10 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -544,6 +544,110 @@ describe('Installations', () => { }); }); + it('clears deviceToken via Delete op without crashing the lookup', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + }; + 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 }, + { deviceToken: { __op: 'Delete' } } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toBeUndefined(); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('clears deviceToken via null without crashing the lookup', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + }; + 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 }, + { deviceToken: null } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken == null).toBeTrue(); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('clears deviceToken alongside another field update', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + appVersion: '1', + }; + 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 }, + { deviceToken: { __op: 'Delete' }, deviceType: 'ios', appVersion: '2' } + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toBeUndefined(); + expect(results[0].appVersion).toEqual('2'); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(err => { + jfail(err); + 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 6d3c0d35a9..f1a0b73cb7 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1295,9 +1295,19 @@ RestWrite.prototype.handleInstallation = function () { return; } + // A client clearing the deviceToken sends either null or { __op: 'Delete' }. + // Treat that as "no deviceToken to identify/match by" so we do not feed the + // operator object into the lookup query (which would fail Mongo transform). + const clearingDeviceToken = + this.data.deviceToken === null || + (typeof this.data.deviceToken === 'object' && + this.data.deviceToken !== null && + this.data.deviceToken.__op === 'Delete'); + let deviceTokenForLookup = clearingDeviceToken ? undefined : this.data.deviceToken; + if ( !this.query && - !this.data.deviceToken && + !deviceTokenForLookup && !this.data.installationId && !this.auth.installationId ) { @@ -1309,8 +1319,9 @@ RestWrite.prototype.handleInstallation = function () { // If the device token is 64 characters long, we assume it is for iOS // and lowercase it. - if (this.data.deviceToken && this.data.deviceToken.length == 64) { - this.data.deviceToken = this.data.deviceToken.toLowerCase(); + if (deviceTokenForLookup && deviceTokenForLookup.length == 64) { + this.data.deviceToken = deviceTokenForLookup.toLowerCase(); + deviceTokenForLookup = this.data.deviceToken; } // We lowercase the installationId if present @@ -1330,7 +1341,7 @@ RestWrite.prototype.handleInstallation = function () { } // Updating _Installation but not updating anything critical - if (this.query && !this.data.deviceToken && !installationId && !this.data.deviceType) { + if (this.query && !deviceTokenForLookup && !installationId && !this.data.deviceType) { return; } @@ -1353,8 +1364,8 @@ RestWrite.prototype.handleInstallation = function () { installationId: installationId, }); } - if (this.data.deviceToken) { - orQueries.push({ deviceToken: this.data.deviceToken }); + if (deviceTokenForLookup) { + orQueries.push({ deviceToken: deviceTokenForLookup }); } if (orQueries.length == 0) { @@ -1379,7 +1390,7 @@ RestWrite.prototype.handleInstallation = function () { if (result.installationId == installationId) { installationIdMatch = result; } - if (result.deviceToken == this.data.deviceToken) { + if (deviceTokenForLookup && result.deviceToken == deviceTokenForLookup) { deviceTokenMatches.push(result); } }); @@ -1397,9 +1408,9 @@ RestWrite.prototype.handleInstallation = function () { throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } if ( - this.data.deviceToken && + deviceTokenForLookup && objectIdMatch.deviceToken && - this.data.deviceToken !== objectIdMatch.deviceToken && + deviceTokenForLookup !== objectIdMatch.deviceToken && !this.data.installationId && !objectIdMatch.installationId ) { @@ -1451,7 +1462,7 @@ RestWrite.prototype.handleInstallation = function () { // deviceToken, and return nil to signal that a new object should be // created. const delQuery = { - deviceToken: this.data.deviceToken, + deviceToken: deviceTokenForLookup, installationId: { $ne: installationId, }, @@ -1486,12 +1497,12 @@ RestWrite.prototype.handleInstallation = function () { validSchemaController: this.validSchemaController, }); } else { - if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { + if (deviceTokenForLookup && idMatch.deviceToken != deviceTokenForLookup) { // We're setting the device token on an existing installation, so // we should try cleaning out old installations that match this // device token. const delQuery = { - deviceToken: this.data.deviceToken, + deviceToken: deviceTokenForLookup, }; // We have a unique install Id, use that to preserve // the interesting installation