Skip to content

Commit 0b47407

Browse files
authored
feat: Allow returning objects in Parse.Cloud.beforeFind without invoking database query (#9770)
1 parent 0b606ae commit 0b47407

File tree

4 files changed

+524
-41
lines changed

4 files changed

+524
-41
lines changed

spec/CloudCode.spec.js

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,346 @@ describe('Cloud Code', () => {
202202
}
203203
});
204204

205+
describe('beforeFind without DB operations', () => {
206+
let findSpy;
207+
208+
beforeEach(() => {
209+
const config = Config.get('test');
210+
const databaseAdapter = config.database.adapter;
211+
findSpy = spyOn(databaseAdapter, 'find').and.callThrough();
212+
});
213+
214+
it('beforeFind can return object without DB operation', async () => {
215+
Parse.Cloud.beforeFind('TestObject', () => {
216+
return new Parse.Object('TestObject', { foo: 'bar' });
217+
});
218+
Parse.Cloud.afterFind('TestObject', req => {
219+
expect(req.objects).toBeDefined();
220+
expect(req.objects[0].get('foo')).toBe('bar');
221+
});
222+
223+
const newObj = await new Parse.Query('TestObject').first();
224+
expect(newObj.className).toBe('TestObject');
225+
expect(newObj.toJSON()).toEqual({ foo: 'bar' });
226+
expect(findSpy).not.toHaveBeenCalled();
227+
await newObj.save();
228+
});
229+
230+
it('beforeFind can return array of objects without DB operation', async () => {
231+
Parse.Cloud.beforeFind('TestObject', () => {
232+
return [new Parse.Object('TestObject', { foo: 'bar' })];
233+
});
234+
Parse.Cloud.afterFind('TestObject', req => {
235+
expect(req.objects).toBeDefined();
236+
expect(req.objects[0].get('foo')).toBe('bar');
237+
});
238+
239+
const newObj = await new Parse.Query('TestObject').first();
240+
expect(newObj.className).toBe('TestObject');
241+
expect(newObj.toJSON()).toEqual({ foo: 'bar' });
242+
expect(findSpy).not.toHaveBeenCalled();
243+
await newObj.save();
244+
});
245+
246+
it('beforeFind can return object for get query without DB operation', async () => {
247+
Parse.Cloud.beforeFind('TestObject', () => {
248+
return [new Parse.Object('TestObject', { foo: 'bar' })];
249+
});
250+
Parse.Cloud.afterFind('TestObject', req => {
251+
expect(req.objects).toBeDefined();
252+
expect(req.objects[0].get('foo')).toBe('bar');
253+
});
254+
255+
const testObj = new Parse.Object('TestObject');
256+
await testObj.save();
257+
findSpy.calls.reset();
258+
259+
const newObj = await new Parse.Query('TestObject').get(testObj.id);
260+
expect(newObj.className).toBe('TestObject');
261+
expect(newObj.toJSON()).toEqual({ foo: 'bar' });
262+
expect(findSpy).not.toHaveBeenCalled();
263+
await newObj.save();
264+
});
265+
266+
it('beforeFind can return empty array without DB operation', async () => {
267+
Parse.Cloud.beforeFind('TestObject', () => {
268+
return [];
269+
});
270+
Parse.Cloud.afterFind('TestObject', req => {
271+
expect(req.objects.length).toBe(0);
272+
});
273+
274+
const obj = new Parse.Object('TestObject');
275+
await obj.save();
276+
findSpy.calls.reset();
277+
278+
const newObj = await new Parse.Query('TestObject').first();
279+
expect(newObj).toBeUndefined();
280+
expect(findSpy).not.toHaveBeenCalled();
281+
});
282+
});
283+
284+
describe('beforeFind security with returned objects', () => {
285+
let userA;
286+
let userB;
287+
let secret;
288+
289+
beforeEach(async () => {
290+
userA = new Parse.User();
291+
userA.setUsername('userA_' + Date.now());
292+
userA.setPassword('passA');
293+
await userA.signUp();
294+
295+
userB = new Parse.User();
296+
userB.setUsername('userB_' + Date.now());
297+
userB.setPassword('passB');
298+
await userB.signUp();
299+
300+
// Create an object readable only by userB
301+
const acl = new Parse.ACL();
302+
acl.setPublicReadAccess(false);
303+
acl.setPublicWriteAccess(false);
304+
acl.setReadAccess(userB.id, true);
305+
acl.setWriteAccess(userB.id, true);
306+
307+
secret = new Parse.Object('SecretDoc');
308+
secret.set('title', 'top');
309+
secret.set('content', 'classified');
310+
secret.setACL(acl);
311+
await secret.save(null, { sessionToken: userB.getSessionToken() });
312+
313+
Parse.Cloud.beforeFind('SecretDoc', () => {
314+
return [secret];
315+
});
316+
});
317+
318+
it('should not expose objects not readable by current user', async () => {
319+
const q = new Parse.Query('SecretDoc');
320+
const results = await q.find({ sessionToken: userA.getSessionToken() });
321+
expect(results.length).toBe(0);
322+
});
323+
324+
it('should allow authorized user to see their objects', async () => {
325+
const q = new Parse.Query('SecretDoc');
326+
const results = await q.find({ sessionToken: userB.getSessionToken() });
327+
expect(results.length).toBe(1);
328+
expect(results[0].id).toBe(secret.id);
329+
expect(results[0].get('title')).toBe('top');
330+
expect(results[0].get('content')).toBe('classified');
331+
});
332+
333+
it('should return OBJECT_NOT_FOUND on get() for unauthorized user', async () => {
334+
const q = new Parse.Query('SecretDoc');
335+
await expectAsync(
336+
q.get(secret.id, { sessionToken: userA.getSessionToken() })
337+
).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND }));
338+
});
339+
340+
it('should allow master key to bypass ACL filtering when returning objects', async () => {
341+
const q = new Parse.Query('SecretDoc');
342+
const results = await q.find({ useMasterKey: true });
343+
expect(results.length).toBe(1);
344+
expect(results[0].id).toBe(secret.id);
345+
});
346+
347+
it('should apply protectedFields masking after re-filtering', async () => {
348+
// Configure protectedFields for SecretMask: mask `secretField` for everyone
349+
const protectedFields = { SecretMask: { '*': ['secretField'] } };
350+
await reconfigureServer({ protectedFields });
351+
352+
const user = new Parse.User();
353+
user.setUsername('pfUser');
354+
user.setPassword('pfPass');
355+
await user.signUp();
356+
357+
// Object is publicly readable but has a protected field
358+
const doc = new Parse.Object('SecretMask');
359+
doc.set('name', 'visible');
360+
doc.set('secretField', 'hiddenValue');
361+
await doc.save(null, { useMasterKey: true });
362+
363+
Parse.Cloud.beforeFind('SecretMask', () => {
364+
return [doc];
365+
});
366+
367+
// Query as normal user; after re-filtering, secretField should be removed
368+
const res = await new Parse.Query('SecretMask').first({ sessionToken: user.getSessionToken() });
369+
expect(res).toBeDefined();
370+
expect(res.get('name')).toBe('visible');
371+
expect(res.get('secretField')).toBeUndefined();
372+
const json = res.toJSON();
373+
expect(Object.prototype.hasOwnProperty.call(json, 'secretField')).toBeFalse();
374+
});
375+
});
376+
const { maybeRunAfterFindTrigger } = require('../lib/triggers');
377+
378+
describe('maybeRunAfterFindTrigger - direct function tests', () => {
379+
const testConfig = {
380+
applicationId: 'test',
381+
logLevels: { triggerBeforeSuccess: 'info', triggerAfter: 'info' },
382+
};
383+
384+
it('should convert Parse.Object instances to JSON when no trigger defined', async () => {
385+
const className = 'TestParseObjectDirect_' + Date.now();
386+
387+
const parseObj1 = new Parse.Object(className);
388+
parseObj1.set('name', 'test1');
389+
parseObj1.id = 'obj1';
390+
391+
const parseObj2 = new Parse.Object(className);
392+
parseObj2.set('name', 'test2');
393+
parseObj2.id = 'obj2';
394+
395+
const result = await maybeRunAfterFindTrigger(
396+
'afterFind',
397+
null,
398+
className,
399+
[parseObj1, parseObj2],
400+
testConfig,
401+
null,
402+
{}
403+
);
404+
405+
expect(result).toBeDefined();
406+
expect(Array.isArray(result)).toBe(true);
407+
expect(result.length).toBe(2);
408+
expect(result[0].name).toBe('test1');
409+
expect(result[1].name).toBe('test2');
410+
});
411+
412+
it('should handle null/undefined objectsInput when no trigger', async () => {
413+
const className = 'TestNullDirect_' + Date.now();
414+
415+
const resultNull = await maybeRunAfterFindTrigger(
416+
'afterFind',
417+
null,
418+
className,
419+
null,
420+
testConfig,
421+
null,
422+
{}
423+
);
424+
expect(resultNull).toEqual([]);
425+
426+
const resultUndefined = await maybeRunAfterFindTrigger(
427+
'afterFind',
428+
null,
429+
className,
430+
undefined,
431+
testConfig,
432+
null,
433+
{}
434+
);
435+
expect(resultUndefined).toEqual([]);
436+
437+
const resultEmpty = await maybeRunAfterFindTrigger(
438+
'afterFind',
439+
null,
440+
className,
441+
[],
442+
testConfig,
443+
null,
444+
{}
445+
);
446+
expect(resultEmpty).toEqual([]);
447+
});
448+
449+
it('should handle plain object query with where clause', async () => {
450+
const className = 'TestQueryWhereDirect_' + Date.now();
451+
let receivedQuery = null;
452+
453+
Parse.Cloud.afterFind(className, req => {
454+
receivedQuery = req.query;
455+
return req.objects;
456+
});
457+
458+
const mockObject = { id: 'test123', className: className, name: 'test' };
459+
460+
const result = await maybeRunAfterFindTrigger(
461+
'afterFind',
462+
null,
463+
className,
464+
[mockObject],
465+
testConfig,
466+
{ where: { name: 'test' }, limit: 10 },
467+
{}
468+
);
469+
470+
expect(receivedQuery).toBeInstanceOf(Parse.Query);
471+
expect(result).toBeDefined();
472+
});
473+
474+
it('should handle plain object query without where clause', async () => {
475+
const className = 'TestQueryNoWhereDirect_' + Date.now();
476+
let receivedQuery = null;
477+
478+
Parse.Cloud.afterFind(className, req => {
479+
receivedQuery = req.query;
480+
return req.objects;
481+
});
482+
483+
const mockObject = { id: 'test456', className: className, name: 'test' };
484+
const pq = new Parse.Query(className).withJSON({ limit: 5, skip: 1 });
485+
486+
const result = await maybeRunAfterFindTrigger(
487+
'afterFind',
488+
null,
489+
className,
490+
[mockObject],
491+
testConfig,
492+
pq,
493+
{}
494+
);
495+
496+
expect(receivedQuery).toBeInstanceOf(Parse.Query);
497+
const qJSON = receivedQuery.toJSON();
498+
expect(qJSON.limit).toBe(5);
499+
expect(qJSON.skip).toBe(1);
500+
expect(qJSON.where).toEqual({});
501+
expect(result).toBeDefined();
502+
});
503+
504+
it('should create default query for invalid query parameter', async () => {
505+
const className = 'TestInvalidQueryDirect_' + Date.now();
506+
let receivedQuery = null;
507+
508+
Parse.Cloud.afterFind(className, req => {
509+
receivedQuery = req.query;
510+
return req.objects;
511+
});
512+
513+
const mockObject = { id: 'test789', className: className, name: 'test' };
514+
515+
await maybeRunAfterFindTrigger(
516+
'afterFind',
517+
null,
518+
className,
519+
[mockObject],
520+
testConfig,
521+
'invalid_query_string',
522+
{}
523+
);
524+
525+
expect(receivedQuery).toBeInstanceOf(Parse.Query);
526+
expect(receivedQuery.className).toBe(className);
527+
528+
receivedQuery = null;
529+
530+
await maybeRunAfterFindTrigger(
531+
'afterFind',
532+
null,
533+
className,
534+
[mockObject],
535+
testConfig,
536+
null,
537+
{}
538+
);
539+
540+
expect(receivedQuery).toBeInstanceOf(Parse.Query);
541+
expect(receivedQuery.className).toBe(className);
542+
});
543+
});
544+
205545
it('beforeSave rejection with custom error code', function (done) {
206546
Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function () {
207547
throw new Parse.Error(999, 'Nope');

0 commit comments

Comments
 (0)