From 619e7cb1e549a4df5f33991be1254d2b640952c5 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:57:46 -0400 Subject: [PATCH 1/2] fix: deleting a role does not invalidate the cached role closures --- spec/ParseRole.spec.js | 28 ++++++++++++++++++++++++++++ src/rest.js | 12 ++++++++++++ 2 files changed, 40 insertions(+) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 95e6189a6a..6d178d76c5 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -675,4 +675,32 @@ describe('Parse Role testing', () => { const fetchedRole = await query.get(savedRole.id, { useMasterKey: true }); expect(fetchedRole.get('name')).toBe('ModifiedName'); }); + + it('clears the role cache when a role is deleted', async () => { + const cacheController = Parse.Server.cacheController; + const role = new Parse.Role('Doomed', new Parse.ACL()); + await role.save(null, { useMasterKey: true }); + + // Saving the role already clears the cache, so seed the entry afterwards. + await cacheController.role.put('someUser', ['role:Doomed']); + expect(await cacheController.role.get('someUser')).toEqual(['role:Doomed']); + + await role.destroy({ useMasterKey: true }); + // The clear is issued without being awaited, matching RestWrite. + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(await cacheController.role.get('someUser')).toEqual(null); + }); + + it('leaves the role cache alone when a non-role object is deleted', async () => { + const cacheController = Parse.Server.cacheController; + const object = new Parse.Object('TestObject'); + await object.save(null, { useMasterKey: true }); + + await cacheController.role.put('someUser', ['role:Admin']); + await object.destroy({ useMasterKey: true }); + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(await cacheController.role.get('someUser')).toEqual(['role:Admin']); + }); }); diff --git a/src/rest.js b/src/rest.js index 7a78f2f8b5..60c3f08ba6 100644 --- a/src/rest.js +++ b/src/rest.js @@ -241,6 +241,18 @@ function del(config, auth, className, objectId, context) { ); }) .then(() => { + // A deleted role is revoked from everyone who held it, so the cached role + // closures have to be dropped the same way they are on a role write (see + // RestWrite#runDatabaseOperation). The cached value is a flattened + // transitive closure, so deleting a parent role also affects the members + // of its children, and the whole role cache is cleared rather than one + // user's entry. + if (className === '_Role') { + config.cacheController.role.clear(); + if (config.liveQueryController) { + config.liveQueryController.clearCachedRoles(auth.user); + } + } // Notify LiveQuery server if possible const perms = schemaController.getClassLevelPermissions(className); config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); From 89b1b6a4ed4f3141a137bf28d7d7e283d286e63a Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:27:16 -0400 Subject: [PATCH 2/2] Stabilize role cache deletion tests Refactors ParseRole cache invalidation specs to use the app config controllers instead of global server state, and adds explicit spies for both role cache and LiveQuery role cache clearing. The role-deletion test now waits on the actual clear promise rather than a fixed timeout, making it deterministic, while the non-role deletion test verifies no cache clear methods are called. --- spec/ParseRole.spec.js | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 6d178d76c5..3dc8d134b3 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -677,30 +677,42 @@ describe('Parse Role testing', () => { }); it('clears the role cache when a role is deleted', async () => { - const cacheController = Parse.Server.cacheController; + const config = Config.get(Parse.applicationId); const role = new Parse.Role('Doomed', new Parse.ACL()); await role.save(null, { useMasterKey: true }); // Saving the role already clears the cache, so seed the entry afterwards. - await cacheController.role.put('someUser', ['role:Doomed']); - expect(await cacheController.role.get('someUser')).toEqual(['role:Doomed']); + await config.cacheController.role.put('someUser', ['role:Doomed']); + expect(await config.cacheController.role.get('someUser')).toEqual(['role:Doomed']); + + const clearSpy = spyOn(config.cacheController.role, 'clear').and.callThrough(); + const liveQuerySpy = spyOn(config.liveQueryController, 'clearCachedRoles').and.callThrough(); await role.destroy({ useMasterKey: true }); - // The clear is issued without being awaited, matching RestWrite. - await new Promise(resolve => setTimeout(resolve, 200)); - expect(await cacheController.role.get('someUser')).toEqual(null); + expect(clearSpy).toHaveBeenCalledTimes(1); + expect(liveQuerySpy).toHaveBeenCalledTimes(1); + // The clear is issued without being awaited, matching RestWrite, so wait on + // the promise the call returned rather than on a fixed delay. + await clearSpy.calls.mostRecent().returnValue; + + expect(await config.cacheController.role.get('someUser')).toEqual(null); }); it('leaves the role cache alone when a non-role object is deleted', async () => { - const cacheController = Parse.Server.cacheController; + const config = Config.get(Parse.applicationId); const object = new Parse.Object('TestObject'); await object.save(null, { useMasterKey: true }); - await cacheController.role.put('someUser', ['role:Admin']); + await config.cacheController.role.put('someUser', ['role:Admin']); + + const clearSpy = spyOn(config.cacheController.role, 'clear').and.callThrough(); + const liveQuerySpy = spyOn(config.liveQueryController, 'clearCachedRoles').and.callThrough(); + await object.destroy({ useMasterKey: true }); - await new Promise(resolve => setTimeout(resolve, 200)); - expect(await cacheController.role.get('someUser')).toEqual(['role:Admin']); + expect(clearSpy).not.toHaveBeenCalled(); + expect(liveQuerySpy).not.toHaveBeenCalled(); + expect(await config.cacheController.role.get('someUser')).toEqual(['role:Admin']); }); });