fix: Stale data read in validation query on Parse.Object update causes inconsistency between validation read and subsequent update write operation (#9859)

This commit is contained in:
mavriel@gmail.com
2025-10-25 03:58:44 +09:00
committed by GitHub
parent 8006a9e2c1
commit f49efaf5bb
2 changed files with 71 additions and 1 deletions

View File

@@ -615,6 +615,76 @@ describe('DatabaseController', function () {
expect(result2.length).toEqual(1);
});
});
describe('update with validateOnly', () => {
const mockStorageAdapter = {
findOneAndUpdate: () => Promise.resolve({}),
find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]),
watch: () => Promise.resolve(),
getAllClasses: () =>
Promise.resolve([
{
className: 'TestObject',
fields: { testField: 'String' },
indexes: {},
classLevelPermissions: { protectedFields: {} },
},
]),
};
it('should use primary readPreference when validateOnly is true', async () => {
const databaseController = new DatabaseController(mockStorageAdapter, {});
const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
try {
// Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger)
await databaseController.update(
'TestObject',
{ objectId: 'test123' },
{ testField: 'newValue' },
{},
true, // skipSanitization: true (matches RestWrite behavior)
true // validateOnly: true
);
} catch (error) {
// validateOnly may throw, but we're checking the find call options
}
// Verify that find was called with primary readPreference
expect(findSpy).toHaveBeenCalled();
const findCall = findSpy.calls.mostRecent();
expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter
// Verify that findOneAndUpdate was NOT called (only validation, no actual update)
expect(findOneAndUpdateSpy).not.toHaveBeenCalled();
});
it('should not use primary readPreference when validateOnly is false', async () => {
const databaseController = new DatabaseController(mockStorageAdapter, {});
const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough();
const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough();
try {
// Call update with validateOnly: false
await databaseController.update(
'TestObject',
{ objectId: 'test123' },
{ testField: 'newValue' },
{},
false, // skipSanitization
false // validateOnly
);
} catch (error) {
// May throw for other reasons, but we're checking the call pattern
}
// When validateOnly is false, find should not be called for validation
// Instead, findOneAndUpdate should be called
expect(findSpy).not.toHaveBeenCalled();
expect(findOneAndUpdateSpy).toHaveBeenCalled();
});
});
});
function buildCLP(pointerNames) {

View File

@@ -593,7 +593,7 @@ class DatabaseController {
convertUsernameToLowercase(update, className, this.options);
transformAuthData(className, update, schema);
if (validateOnly) {
return this.adapter.find(className, schema, query, {}).then(result => {
return this.adapter.find(className, schema, query, { readPreference: 'primary' }).then(result => {
if (!result || !result.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.');
}