From 35e0c00725211af65f875f10a12fc2beb93e1fa8 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 24 Jul 2023 01:31:17 +1000 Subject: [PATCH 1/7] feat: add file get triggers --- spec/CloudCode.spec.js | 449 ++++++++++++++----------------------- src/Routers/FilesRouter.js | 83 +++++-- 2 files changed, 228 insertions(+), 304 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 99ec4910d1..116a2e528f 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -49,7 +49,6 @@ describe('Cloud Code', () => { }); it('cloud code must be valid type', async () => { - spyOn(console, 'error').and.callFake(() => {}); await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( "argument 'cloud' must either be a string or a function" ); @@ -1363,7 +1362,6 @@ describe('Cloud Code', () => { }); it('should not encode Parse Objects', async () => { - await reconfigureServer({ encodeParseObjectInCloudFunction: false }); const user = new Parse.User(); user.setUsername('username'); user.setPassword('password'); @@ -2317,7 +2315,7 @@ describe('beforeFind hooks', () => { ); }); - it_id('6ef0d226-af30-4dfd-8306-972a1b4becd3')(it)('should handle empty where', done => { + it('should handle empty where', done => { Parse.Cloud.beforeFind('MyObject', req => { const otherQuery = new Parse.Query('MyObject'); otherQuery.equalTo('some', true); @@ -2400,56 +2398,6 @@ describe('beforeFind hooks', () => { }); }); - it('sets correct beforeFind trigger isGet parameter for Parse.Object.fetch request', async () => { - const hook = { - method: req => { - expect(req.isGet).toEqual(true); - return Promise.resolve(); - }, - }; - spyOn(hook, 'method').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', hook.method); - const obj = new Parse.Object('MyObject'); - await obj.save(); - const getObj = await obj.fetch(); - expect(getObj).toBeInstanceOf(Parse.Object); - expect(hook.method).toHaveBeenCalledTimes(1); - }); - - it('sets correct beforeFind trigger isGet parameter for Parse.Query.get request', async () => { - const hook = { - method: req => { - expect(req.isGet).toEqual(false); - return Promise.resolve(); - }, - }; - spyOn(hook, 'method').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', hook.method); - const obj = new Parse.Object('MyObject'); - await obj.save(); - const query = new Parse.Query('MyObject'); - const getObj = await query.get(obj.id); - expect(getObj).toBeInstanceOf(Parse.Object); - expect(hook.method).toHaveBeenCalledTimes(1); - }); - - it('sets correct beforeFind trigger isGet parameter for Parse.Query.find request', async () => { - const hook = { - method: req => { - expect(req.isGet).toEqual(false); - return Promise.resolve(); - }, - }; - spyOn(hook, 'method').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', hook.method); - const obj = new Parse.Object('MyObject'); - await obj.save(); - const query = new Parse.Query('MyObject'); - const findObjs = await query.find(); - expect(findObjs?.[0]).toBeInstanceOf(Parse.Object); - expect(hook.method).toHaveBeenCalledTimes(1); - }); - it('should have request headers', done => { Parse.Cloud.beforeFind('MyObject', req => { expect(req.headers).toBeDefined(); @@ -2483,60 +2431,6 @@ describe('beforeFind hooks', () => { }) .then(() => done()); }); - - it('should run beforeFind on pointers and array of pointers from an object', async () => { - const obj1 = new Parse.Object('TestObject'); - const obj2 = new Parse.Object('TestObject2'); - const obj3 = new Parse.Object('TestObject'); - obj2.set('aField', 'aFieldValue'); - await obj2.save(); - obj1.set('pointerField', obj2); - obj3.set('pointerFieldArray', [obj2]); - await obj1.save(); - await obj3.save(); - const spy = jasmine.createSpy('beforeFindSpy'); - Parse.Cloud.beforeFind('TestObject2', spy); - const query = new Parse.Query('TestObject'); - await query.get(obj1.id); - // Pointer not included in query so we don't expect beforeFind to be called - expect(spy).not.toHaveBeenCalled(); - const query2 = new Parse.Query('TestObject'); - query2.include('pointerField'); - const res = await query2.get(obj1.id); - expect(res.get('pointerField').get('aField')).toBe('aFieldValue'); - // Pointer included in query so we expect beforeFind to be called - expect(spy).toHaveBeenCalledTimes(1); - const query3 = new Parse.Query('TestObject'); - query3.include('pointerFieldArray'); - const res2 = await query3.get(obj3.id); - expect(res2.get('pointerFieldArray')[0].get('aField')).toBe('aFieldValue'); - expect(spy).toHaveBeenCalledTimes(2); - }); - - it('should have access to context in include query in beforeFind hook', async () => { - let beforeFindTestObjectCalled = false; - let beforeFindTestObject2Called = false; - const obj1 = new Parse.Object('TestObject'); - const obj2 = new Parse.Object('TestObject2'); - obj2.set('aField', 'aFieldValue'); - await obj2.save(); - obj1.set('pointerField', obj2); - await obj1.save(); - Parse.Cloud.beforeFind('TestObject', req => { - expect(req.context).toBeDefined(); - expect(req.context.a).toEqual('a'); - beforeFindTestObjectCalled = true; - }); - Parse.Cloud.beforeFind('TestObject2', req => { - expect(req.context).toBeDefined(); - expect(req.context.a).toEqual('a'); - beforeFindTestObject2Called = true; - }); - const query = new Parse.Query('TestObject'); - await query.include('pointerField').find({ context: { a: 'a' } }); - expect(beforeFindTestObjectCalled).toBeTrue(); - expect(beforeFindTestObject2Called).toBeTrue(); - }); }); describe('afterFind hooks', () => { @@ -2919,7 +2813,7 @@ describe('afterFind hooks', () => { }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); }); - it_id('c16159b5-e8ee-42d5-8fe3-e2f7c006881d')(it)('should skip afterFind hooks for aggregate', done => { + it('should skip afterFind hooks for aggregate', done => { const hook = { method: function () { return Promise.reject(); @@ -2946,7 +2840,7 @@ describe('afterFind hooks', () => { }); }); - it_id('ca55c90d-36db-422c-9060-a30583ce5224')(it)('should skip afterFind hooks for distinct', done => { + it('should skip afterFind hooks for distinct', done => { const hook = { method: function () { return Promise.reject(); @@ -3033,7 +2927,7 @@ describe('afterFind hooks', () => { expect(calledAfter).toBe(false); }); - it_id('55ef1741-cf72-4a7c-a029-00cb75f53233')(it)('should expose context in beforeSave/afterSave via header', async () => { + it('should expose context in beforeSave/afterSave via header', async () => { let calledBefore = false; let calledAfter = false; Parse.Cloud.beforeSave('TestObject', req => { @@ -3347,14 +3241,14 @@ describe('beforeLogin hook', () => { expect(response).toEqual(error); }); - it_id('5656d6d7-65ef-43d1-8ca6-6942ae3614d5')(it)('should have expected data in request in beforeLogin', async done => { + it('should have expected data in request', async done => { Parse.Cloud.beforeLogin(req => { expect(req.object).toBeDefined(); expect(req.user).toBeUndefined(); expect(req.headers).toBeDefined(); expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); - expect(req.context).toBeDefined(); + expect(req.context).toBeUndefined(); }); await Parse.User.signUp('tupac', 'shakur'); @@ -3464,14 +3358,14 @@ describe('afterLogin hook', () => { done(); }); - it_id('e86155c4-62e1-4c6e-ab4a-9ac6c87c60f2')(it)('should have expected data in request in afterLogin', async done => { + it('should have expected data in request', async done => { Parse.Cloud.afterLogin(req => { expect(req.object).toBeDefined(); expect(req.user).toBeDefined(); expect(req.headers).toBeDefined(); expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); - expect(req.context).toBeDefined(); + expect(req.context).toBeUndefined(); }); await Parse.User.signUp('testuser', 'p@ssword'); @@ -3637,7 +3531,109 @@ describe('afterLogin hook', () => { }); describe('saveFile hooks', () => { - it('beforeSave(Parse.File) should return file that is already saved and not save anything to files adapter', async () => { + fit('find hooks should run', async () => { + await reconfigureServer({ silent: false }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('beforeFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + }, + afterFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('afterFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + fit('beforeFind can throw', async () => { + await reconfigureServer({ silent: false }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() { + throw 'unauthorized'; + }, + afterFind() {}, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + + expect(hooks.beforeFind).toHaveBeenCalled(); + expect(hooks.afterFind).not.toHaveBeenCalled(); + }); + + fit('afterFind can throw', async () => { + await reconfigureServer({ silent: false }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() {}, + afterFind() { + throw 'unauthorized'; + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('beforeSaveFile should return file that is already saved and not save anything to files adapter', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, () => { @@ -3653,7 +3649,7 @@ describe('saveFile hooks', () => { expect(createFileSpy).not.toHaveBeenCalled(); }); - it('beforeSave(Parse.File) should throw error', async () => { + it('beforeSaveFile should throw error', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, () => { throw new Parse.Error(400, 'some-error-message'); @@ -3666,7 +3662,7 @@ describe('saveFile hooks', () => { } }); - it('beforeSave(Parse.File) should change values of uploaded file by editing fileObject directly', async () => { + it('beforeSaveFile should change values of uploaded file by editing fileObject directly', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3695,7 +3691,7 @@ describe('saveFile hooks', () => { ); }); - it('beforeSave(Parse.File) should change values by returning new fileObject', async () => { + it('beforeSaveFile should change values by returning new fileObject', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3729,7 +3725,7 @@ describe('saveFile hooks', () => { expect(file._name.indexOf(expectedFileName)).toBe(file._name.length - expectedFileName.length); }); - it('beforeSave(Parse.File) should contain metadata and tags saved from client', async () => { + it('beforeSaveFile should contain metadata and tags saved from client', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3757,7 +3753,7 @@ describe('saveFile hooks', () => { ); }); - it('beforeSave(Parse.File) should return same file data with new file name', async () => { + it('beforeSaveFile should return same file data with new file name', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const config = Config.get('test'); config.filesController.options.preserveFileName = true; @@ -3772,7 +3768,7 @@ describe('saveFile hooks', () => { expect(result.name()).toBe('2020-04-01.txt'); }); - it('afterSave(Parse.File) should set fileSize to null if beforeSave returns an already saved file', async () => { + it('afterSaveFile should set fileSize to null if beforeSave returns an already saved file', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, req => { @@ -3792,7 +3788,7 @@ describe('saveFile hooks', () => { expect(createFileSpy).not.toHaveBeenCalled(); }); - it('afterSave(Parse.File) should throw error', async () => { + it('afterSaveFile should throw error', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.afterSave(Parse.File, async () => { throw new Parse.Error(400, 'some-error-message'); @@ -3806,7 +3802,7 @@ describe('saveFile hooks', () => { } }); - it('afterSave(Parse.File) should call with fileObject', async done => { + it('afterSaveFile should call with fileObject', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, async req => { req.file.setTags({ tagA: 'some-tag' }); @@ -3822,7 +3818,7 @@ describe('saveFile hooks', () => { await file.save({ useMasterKey: true }); }); - it('afterSave(Parse.File) should change fileSize when file data changes', async done => { + it('afterSaveFile should change fileSize when file data changes', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, async req => { expect(req.fileSize).toBe(3); @@ -3839,7 +3835,7 @@ describe('saveFile hooks', () => { await file.save({ useMasterKey: true }); }); - it('beforeDelete(Parse.File) should call with fileObject', async () => { + it('beforeDeleteFile should call with fileObject', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, req => { expect(req.file).toBeInstanceOf(Parse.File); @@ -3851,7 +3847,7 @@ describe('saveFile hooks', () => { await file.destroy({ useMasterKey: true }); }); - it('beforeDelete(Parse.File) should throw error', async done => { + it('beforeDeleteFile should throw error', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, () => { throw new Error('some error message'); @@ -3865,7 +3861,7 @@ describe('saveFile hooks', () => { } }); - it('afterDelete(Parse.File) should call with fileObject', async done => { + it('afterDeleteFile should call with fileObject', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, req => { expect(req.file).toBeInstanceOf(Parse.File); @@ -3882,7 +3878,7 @@ describe('saveFile hooks', () => { await file.destroy({ useMasterKey: true }); }); - it('beforeSave(Parse.File) should not change file if nothing is returned', async () => { + it('beforeSaveFile should not change file if nothing is returned', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, () => { return; @@ -3892,7 +3888,7 @@ describe('saveFile hooks', () => { expect(result).toBe(file); }); - it('throw custom error from beforeSave(Parse.File) ', async done => { + it('throw custom error from beforeSaveFile', async done => { Parse.Cloud.beforeSave(Parse.File, () => { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); }); @@ -3906,7 +3902,7 @@ describe('saveFile hooks', () => { } }); - it('throw empty error from beforeSave(Parse.File)', async done => { + it('throw empty error from beforeSaveFile', async done => { Parse.Cloud.beforeSave(Parse.File, () => { throw null; }); @@ -3919,160 +3915,53 @@ describe('saveFile hooks', () => { done(); } }); -}); - -describe('Cloud Config hooks', () => { - function testConfig() { - return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true }); - } - - it_id('997fe20a-96f7-454a-a5b0-c155b8d02f05')(it)('beforeSave(Parse.Config) can run hook with new config', async () => { - let count = 0; - Parse.Cloud.beforeSave(Parse.Config, (req) => { - expect(req.object).toBeDefined(); - expect(req.original).toBeUndefined(); - expect(req.user).toBeUndefined(); - expect(req.headers).toBeDefined(); - expect(req.ip).toBeDefined(); - expect(req.installationId).toBeDefined(); - expect(req.context).toBeDefined(); - const config = req.object; - expect(config.get('internal')).toBe('i'); - expect(config.get('string')).toBe('s'); - expect(config.get('number')).toBe(12); - count += 1; - }); - await testConfig(); - const config = await Parse.Config.get({ useMasterKey: true }); - expect(config.get('internal')).toBe('i'); - expect(config.get('string')).toBe('s'); - expect(config.get('number')).toBe(12); - expect(count).toBe(1); - }); - - it_id('06a9b66c-ffb4-43d1-a025-f7d2192500e7')(it)('beforeSave(Parse.Config) can run hook with existing config', async () => { - let count = 0; - Parse.Cloud.beforeSave(Parse.Config, (req) => { - if (count === 0) { - expect(req.object.get('number')).toBe(12); - expect(req.original).toBeUndefined(); - } - if (count === 1) { - expect(req.object.get('number')).toBe(13); - expect(req.original.get('number')).toBe(12); - } - count += 1; - }); - await testConfig(); - await Parse.Config.save({ number: 13 }); - expect(count).toBe(2); - }); - - it_id('ca76de8e-671b-4c2d-9535-bd28a855fa1a')(it)('beforeSave(Parse.Config) should not change config if nothing is returned', async () => { - let count = 0; - Parse.Cloud.beforeSave(Parse.Config, () => { - count += 1; - return; - }); - await testConfig(); - const config = await Parse.Config.get({ useMasterKey: true }); - expect(config.get('internal')).toBe('i'); - expect(config.get('string')).toBe('s'); - expect(config.get('number')).toBe(12); - expect(count).toBe(1); - }); - - it('beforeSave(Parse.Config) throw custom error', async () => { - Parse.Cloud.beforeSave(Parse.Config, () => { - throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); - }); - try { - await testConfig(); - fail('error should have thrown'); - } catch (e) { - expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); - expect(e.message).toBe('It should fail'); - } - }); - it('beforeSave(Parse.Config) throw string error', async () => { - Parse.Cloud.beforeSave(Parse.Config, () => { - throw 'before save failed'; - }); - try { - await testConfig(); - fail('error should have thrown'); - } catch (e) { - expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); - expect(e.message).toBe('before save failed'); - } - }); + it('legacy hooks', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + const triggers = { + beforeSaveFile(req) { + req.file.setTags({ tagA: 'some-tag' }); + req.file.setMetadata({ foo: 'bar' }); + expect(req.triggerName).toEqual('beforeSave'); + expect(req.master).toBe(true); + }, + afterSaveFile(req) { + expect(req.master).toBe(true); + expect(req.file._tags).toEqual({ tagA: 'some-tag' }); + expect(req.file._metadata).toEqual({ foo: 'bar' }); + }, + beforeDeleteFile(req) { + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file._name).toEqual('popeye.txt'); + expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); + expect(req.fileSize).toBe(null); + }, + afterDeleteFile(req) { + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file._name).toEqual('popeye.txt'); + expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); + }, + }; - it('beforeSave(Parse.Config) throw empty error', async () => { - Parse.Cloud.beforeSave(Parse.Config, () => { - throw null; - }); - try { - await testConfig(); - fail('error should have thrown'); - } catch (e) { - expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); - expect(e.message).toBe('Script failed. Unknown error.'); + for (const key in triggers) { + spyOn(triggers, key).and.callThrough(); + Parse.Cloud[key](triggers[key]); } - }); - it_id('3e7a75c0-6c2e-4c7e-b042-6eb5f23acf94')(it)('afterSave(Parse.Config) can run hook with new config', async () => { - let count = 0; - Parse.Cloud.afterSave(Parse.Config, (req) => { - expect(req.object).toBeDefined(); - expect(req.original).toBeUndefined(); - expect(req.user).toBeUndefined(); - expect(req.headers).toBeDefined(); - expect(req.ip).toBeDefined(); - expect(req.installationId).toBeDefined(); - expect(req.context).toBeDefined(); - const config = req.object; - expect(config.get('internal')).toBe('i'); - expect(config.get('string')).toBe('s'); - expect(config.get('number')).toBe(12); - count += 1; - }); - await testConfig(); - const config = await Parse.Config.get({ useMasterKey: true }); - expect(config.get('internal')).toBe('i'); - expect(config.get('string')).toBe('s'); - expect(config.get('number')).toBe(12); - expect(count).toBe(1); - }); - - it_id('5cffb28a-2924-4857-84bb-f5778d80372a')(it)('afterSave(Parse.Config) can run hook with existing config', async () => { - let count = 0; - Parse.Cloud.afterSave(Parse.Config, (req) => { - if (count === 0) { - expect(req.object.get('number')).toBe(12); - expect(req.original).toBeUndefined(); - } - if (count === 1) { - expect(req.object.get('number')).toBe(13); - expect(req.original.get('number')).toBe(12); - } - count += 1; - }); - await testConfig(); - await Parse.Config.save({ number: 13 }); - expect(count).toBe(2); - }); - - it_id('49883992-ce91-4797-85f9-7cce1f819407')(it)('afterSave(Parse.Config) should throw error', async () => { - Parse.Cloud.afterSave(Parse.Config, () => { - throw new Parse.Error(400, 'It should fail'); - }); - try { - await testConfig(); - fail('error should have thrown'); - } catch (e) { - expect(e.code).toBe(400); - expect(e.message).toBe('It should fail'); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + await new Parse.File('popeye.txt', [1, 2, 3], 'text/plain').destroy({ useMasterKey: true }); + await new Promise(resolve => setTimeout(resolve, 100)); + for (const key in triggers) { + expect(triggers[key]).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + `DeprecationWarning: Parse.Cloud.${key} is deprecated and will be removed in a future version. Use Parse.Cloud.${key.replace( + 'File', + '' + )}(Parse.File, (request) => {})` + ); } }); }); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 13aab81548..a94ff02a81 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -3,6 +3,7 @@ import BodyParser from 'body-parser'; import * as Middlewares from '../middlewares'; import Parse from 'parse/node'; import Config from '../Config'; +import mime from 'mime'; import logger from '../logger'; const triggers = require('../triggers'); const http = require('http'); @@ -74,30 +75,64 @@ export class FilesRouter { res.json({ code: err.code, error: err.message }); return; } - const filesController = config.filesController; - const filename = req.params.filename; - const mime = (await import('mime')).default; - const contentType = mime.getType(filename); - if (isFileStreamable(req, filesController)) { - filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); - } else { - filesController - .getFileData(config, filename) - .then(data => { - res.status(200); - res.set('Content-Type', contentType); - res.set('Content-Length', data.length); - res.end(data); - }) - .catch(() => { + + let filename = req.params.filename; + try { + const filesController = config.filesController; + let contentType = mime.getType(filename); + let file = new Parse.File(filename, { base64: '' }, contentType); + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + req.auth + ); + if (triggerResult?.file?._name) { + filename = triggerResult?.file?._name; + contentType = mime.getType(filename); + } + + if (isFileStreamable(req, filesController)) { + filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); }); + return; + } + + let data = await filesController.getFileData(config, filename).catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); + if (!data) { + return; + } + file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file }, + config, + req.auth + ); + + if (afterFind?.file) { + contentType = mime.getType(afterFind.file._name); + data = Buffer.from(afterFind.file._data, 'utf8'); + } + + res.status(200); + res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + res.end(data); + } catch (e) { + const err = triggers.resolveError(e, { + code: Parse.Error.SCRIPT_FAILED, + message: `Could not find file: ${filename}.`, + }); + res.status(403); + res.json({ code: err.code, error: err.message }); } } @@ -155,13 +190,13 @@ export class FilesRouter { }; let extension = contentType; if (filename && filename.includes('.')) { - extension = filename.substring(filename.lastIndexOf('.') + 1); + extension = filename.split('.')[1]; } else if (contentType && contentType.includes('/')) { extension = contentType.split('/')[1]; } - extension = extension?.split(' ')?.join(''); + extension = extension.split(' ').join(''); - if (extension && !isValidExtension(extension)) { + if (!isValidExtension(extension)) { next( new Parse.Error( Parse.Error.FILE_SAVE_ERROR, @@ -263,7 +298,7 @@ export class FilesRouter { const { filename } = req.params; // run beforeDeleteFile trigger const file = new Parse.File(filename); - file._url = await filesController.adapter.getFileLocation(req.config, filename); + file._url = filesController.adapter.getFileLocation(req.config, filename); const fileObject = { file, fileSize: null }; await triggers.maybeRunFileTrigger( triggers.Types.beforeDelete, From 213245ee3d3647dc90c16c356a479ce0096178ed Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 24 Jul 2023 14:50:58 +1000 Subject: [PATCH 2/7] add download --- spec/CloudCode.spec.js | 28 ++++++++++++++++++++++------ src/Routers/FilesRouter.js | 5 ++++- src/triggers.js | 3 +++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 116a2e528f..99be084b57 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3531,8 +3531,7 @@ describe('afterLogin hook', () => { }); describe('saveFile hooks', () => { - fit('find hooks should run', async () => { - await reconfigureServer({ silent: false }); + it('find hooks should run', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); const user = await Parse.User.signUp('username', 'password'); @@ -3550,6 +3549,7 @@ describe('saveFile hooks', () => { expect(req.triggerName).toBe('afterFind'); expect(req.master).toBeFalse(); expect(req.log).toBeDefined(); + expect(req.forceDownload).toBeFalse(); }, }; for (const hook in hooks) { @@ -3569,8 +3569,7 @@ describe('saveFile hooks', () => { } }); - fit('beforeFind can throw', async () => { - await reconfigureServer({ silent: false }); + it('beforeFind can throw', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); const user = await Parse.User.signUp('username', 'password'); @@ -3601,8 +3600,7 @@ describe('saveFile hooks', () => { expect(hooks.afterFind).not.toHaveBeenCalled(); }); - fit('afterFind can throw', async () => { - await reconfigureServer({ silent: false }); + it('afterFind can throw', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); const user = await Parse.User.signUp('username', 'password'); @@ -3633,6 +3631,24 @@ describe('saveFile hooks', () => { } }); + it('can force download', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, req => { + req.forceDownload = true; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); + }); + it('beforeSaveFile should return file that is already saved and not save anything to files adapter', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index a94ff02a81..dd8d3f997c 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -112,7 +112,7 @@ export class FilesRouter { file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); const afterFind = await triggers.maybeRunFileTrigger( triggers.Types.afterFind, - { file }, + { file, forceDownload: false }, config, req.auth ); @@ -125,6 +125,9 @@ export class FilesRouter { res.status(200); res.set('Content-Type', contentType); res.set('Content-Length', data.length); + if (afterFind.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); + } res.end(data); } catch (e) { const err = triggers.resolveError(e, { diff --git a/src/triggers.js b/src/triggers.js index 0f1b632078..2dfbeff7ac 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1004,6 +1004,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) return fileObject; } const result = await fileTrigger(request); + if (request.forceDownload) { + fileObject.forceDownload = true; + } logTriggerSuccessBeforeHook( triggerType, 'Parse.File', From ee1256c1598d596462f14cd198f5ed8261ad077f Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 24 Jul 2023 16:32:55 +1000 Subject: [PATCH 3/7] Update FilesRouter.js --- src/Routers/FilesRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index dd8d3f997c..0aa2732f93 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -119,7 +119,7 @@ export class FilesRouter { if (afterFind?.file) { contentType = mime.getType(afterFind.file._name); - data = Buffer.from(afterFind.file._data, 'utf8'); + data = Buffer.from(afterFind.file._data, 'base64'); } res.status(200); From f2ec40859c5efae9c95b0915d4cd9da4f912a0e2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 19 Mar 2025 21:12:17 +1100 Subject: [PATCH 4/7] fix merge --- spec/CloudCode.spec.js | 578 +++++++++++++++++++++++++------------ src/Routers/FilesRouter.js | 2 +- 2 files changed, 398 insertions(+), 182 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 99be084b57..97fdfdba44 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -49,6 +49,7 @@ describe('Cloud Code', () => { }); it('cloud code must be valid type', async () => { + spyOn(console, 'error').and.callFake(() => {}); await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( "argument 'cloud' must either be a string or a function" ); @@ -1362,6 +1363,7 @@ describe('Cloud Code', () => { }); it('should not encode Parse Objects', async () => { + await reconfigureServer({ encodeParseObjectInCloudFunction: false }); const user = new Parse.User(); user.setUsername('username'); user.setPassword('password'); @@ -2315,7 +2317,7 @@ describe('beforeFind hooks', () => { ); }); - it('should handle empty where', done => { + it_id('6ef0d226-af30-4dfd-8306-972a1b4becd3')(it)('should handle empty where', done => { Parse.Cloud.beforeFind('MyObject', req => { const otherQuery = new Parse.Query('MyObject'); otherQuery.equalTo('some', true); @@ -2398,6 +2400,56 @@ describe('beforeFind hooks', () => { }); }); + it('sets correct beforeFind trigger isGet parameter for Parse.Object.fetch request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(true); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const getObj = await obj.fetch(); + expect(getObj).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + + it('sets correct beforeFind trigger isGet parameter for Parse.Query.get request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(false); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const query = new Parse.Query('MyObject'); + const getObj = await query.get(obj.id); + expect(getObj).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + + it('sets correct beforeFind trigger isGet parameter for Parse.Query.find request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(false); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const query = new Parse.Query('MyObject'); + const findObjs = await query.find(); + expect(findObjs?.[0]).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + it('should have request headers', done => { Parse.Cloud.beforeFind('MyObject', req => { expect(req.headers).toBeDefined(); @@ -2431,6 +2483,60 @@ describe('beforeFind hooks', () => { }) .then(() => done()); }); + + it('should run beforeFind on pointers and array of pointers from an object', async () => { + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + const obj3 = new Parse.Object('TestObject'); + obj2.set('aField', 'aFieldValue'); + await obj2.save(); + obj1.set('pointerField', obj2); + obj3.set('pointerFieldArray', [obj2]); + await obj1.save(); + await obj3.save(); + const spy = jasmine.createSpy('beforeFindSpy'); + Parse.Cloud.beforeFind('TestObject2', spy); + const query = new Parse.Query('TestObject'); + await query.get(obj1.id); + // Pointer not included in query so we don't expect beforeFind to be called + expect(spy).not.toHaveBeenCalled(); + const query2 = new Parse.Query('TestObject'); + query2.include('pointerField'); + const res = await query2.get(obj1.id); + expect(res.get('pointerField').get('aField')).toBe('aFieldValue'); + // Pointer included in query so we expect beforeFind to be called + expect(spy).toHaveBeenCalledTimes(1); + const query3 = new Parse.Query('TestObject'); + query3.include('pointerFieldArray'); + const res2 = await query3.get(obj3.id); + expect(res2.get('pointerFieldArray')[0].get('aField')).toBe('aFieldValue'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should have access to context in include query in beforeFind hook', async () => { + let beforeFindTestObjectCalled = false; + let beforeFindTestObject2Called = false; + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + obj2.set('aField', 'aFieldValue'); + await obj2.save(); + obj1.set('pointerField', obj2); + await obj1.save(); + Parse.Cloud.beforeFind('TestObject', req => { + expect(req.context).toBeDefined(); + expect(req.context.a).toEqual('a'); + beforeFindTestObjectCalled = true; + }); + Parse.Cloud.beforeFind('TestObject2', req => { + expect(req.context).toBeDefined(); + expect(req.context.a).toEqual('a'); + beforeFindTestObject2Called = true; + }); + const query = new Parse.Query('TestObject'); + await query.include('pointerField').find({ context: { a: 'a' } }); + expect(beforeFindTestObjectCalled).toBeTrue(); + expect(beforeFindTestObject2Called).toBeTrue(); + }); }); describe('afterFind hooks', () => { @@ -2813,7 +2919,7 @@ describe('afterFind hooks', () => { }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); }); - it('should skip afterFind hooks for aggregate', done => { + it_id('c16159b5-e8ee-42d5-8fe3-e2f7c006881d')(it)('should skip afterFind hooks for aggregate', done => { const hook = { method: function () { return Promise.reject(); @@ -2840,7 +2946,7 @@ describe('afterFind hooks', () => { }); }); - it('should skip afterFind hooks for distinct', done => { + it_id('ca55c90d-36db-422c-9060-a30583ce5224')(it)('should skip afterFind hooks for distinct', done => { const hook = { method: function () { return Promise.reject(); @@ -2927,7 +3033,7 @@ describe('afterFind hooks', () => { expect(calledAfter).toBe(false); }); - it('should expose context in beforeSave/afterSave via header', async () => { + it_id('55ef1741-cf72-4a7c-a029-00cb75f53233')(it)('should expose context in beforeSave/afterSave via header', async () => { let calledBefore = false; let calledAfter = false; Parse.Cloud.beforeSave('TestObject', req => { @@ -3241,14 +3347,14 @@ describe('beforeLogin hook', () => { expect(response).toEqual(error); }); - it('should have expected data in request', async done => { + it_id('5656d6d7-65ef-43d1-8ca6-6942ae3614d5')(it)('should have expected data in request in beforeLogin', async done => { Parse.Cloud.beforeLogin(req => { expect(req.object).toBeDefined(); expect(req.user).toBeUndefined(); expect(req.headers).toBeDefined(); expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); - expect(req.context).toBeUndefined(); + expect(req.context).toBeDefined(); }); await Parse.User.signUp('tupac', 'shakur'); @@ -3358,14 +3464,14 @@ describe('afterLogin hook', () => { done(); }); - it('should have expected data in request', async done => { + it_id('e86155c4-62e1-4c6e-ab4a-9ac6c87c60f2')(it)('should have expected data in request in afterLogin', async done => { Parse.Cloud.afterLogin(req => { expect(req.object).toBeDefined(); expect(req.user).toBeDefined(); expect(req.headers).toBeDefined(); expect(req.ip).toBeDefined(); expect(req.installationId).toBeDefined(); - expect(req.context).toBeUndefined(); + expect(req.context).toBeDefined(); }); await Parse.User.signUp('testuser', 'p@ssword'); @@ -3531,125 +3637,7 @@ describe('afterLogin hook', () => { }); describe('saveFile hooks', () => { - it('find hooks should run', async () => { - const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const user = await Parse.User.signUp('username', 'password'); - const hooks = { - beforeFind(req) { - expect(req).toBeDefined(); - expect(req.file).toBeDefined(); - expect(req.triggerName).toBe('beforeFind'); - expect(req.master).toBeFalse(); - expect(req.log).toBeDefined(); - }, - afterFind(req) { - expect(req).toBeDefined(); - expect(req.file).toBeDefined(); - expect(req.triggerName).toBe('afterFind'); - expect(req.master).toBeFalse(); - expect(req.log).toBeDefined(); - expect(req.forceDownload).toBeFalse(); - }, - }; - for (const hook in hooks) { - spyOn(hooks, hook).and.callThrough(); - Parse.Cloud[hook](Parse.File, hooks[hook]); - } - await request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - }); - for (const hook in hooks) { - expect(hooks[hook]).toHaveBeenCalled(); - } - }); - - it('beforeFind can throw', async () => { - const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const user = await Parse.User.signUp('username', 'password'); - const hooks = { - beforeFind() { - throw 'unauthorized'; - }, - afterFind() {}, - }; - for (const hook in hooks) { - spyOn(hooks, hook).and.callThrough(); - Parse.Cloud[hook](Parse.File, hooks[hook]); - } - await expectAsync( - request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - }).catch(e => { - throw new Parse.Error(e.data.code, e.data.error); - }) - ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); - - expect(hooks.beforeFind).toHaveBeenCalled(); - expect(hooks.afterFind).not.toHaveBeenCalled(); - }); - - it('afterFind can throw', async () => { - const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const user = await Parse.User.signUp('username', 'password'); - const hooks = { - beforeFind() {}, - afterFind() { - throw 'unauthorized'; - }, - }; - for (const hook in hooks) { - spyOn(hooks, hook).and.callThrough(); - Parse.Cloud[hook](Parse.File, hooks[hook]); - } - await expectAsync( - request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - }).catch(e => { - throw new Parse.Error(e.data.code, e.data.error); - }) - ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); - for (const hook in hooks) { - expect(hooks[hook]).toHaveBeenCalled(); - } - }); - - it('can force download', async () => { - const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); - await file.save({ useMasterKey: true }); - const user = await Parse.User.signUp('username', 'password'); - Parse.Cloud.afterFind(Parse.File, req => { - req.forceDownload = true; - }); - const response = await request({ - url: file.url(), - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - }); - expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); - }); - - it('beforeSaveFile should return file that is already saved and not save anything to files adapter', async () => { + it('beforeSave(Parse.File) should return file that is already saved and not save anything to files adapter', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, () => { @@ -3665,7 +3653,7 @@ describe('saveFile hooks', () => { expect(createFileSpy).not.toHaveBeenCalled(); }); - it('beforeSaveFile should throw error', async () => { + it('beforeSave(Parse.File) should throw error', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, () => { throw new Parse.Error(400, 'some-error-message'); @@ -3678,7 +3666,7 @@ describe('saveFile hooks', () => { } }); - it('beforeSaveFile should change values of uploaded file by editing fileObject directly', async () => { + it('beforeSave(Parse.File) should change values of uploaded file by editing fileObject directly', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3707,7 +3695,7 @@ describe('saveFile hooks', () => { ); }); - it('beforeSaveFile should change values by returning new fileObject', async () => { + it('beforeSave(Parse.File) should change values by returning new fileObject', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3741,7 +3729,7 @@ describe('saveFile hooks', () => { expect(file._name.indexOf(expectedFileName)).toBe(file._name.length - expectedFileName.length); }); - it('beforeSaveFile should contain metadata and tags saved from client', async () => { + it('beforeSave(Parse.File) should contain metadata and tags saved from client', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, async req => { @@ -3769,7 +3757,7 @@ describe('saveFile hooks', () => { ); }); - it('beforeSaveFile should return same file data with new file name', async () => { + it('beforeSave(Parse.File) should return same file data with new file name', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const config = Config.get('test'); config.filesController.options.preserveFileName = true; @@ -3784,7 +3772,7 @@ describe('saveFile hooks', () => { expect(result.name()).toBe('2020-04-01.txt'); }); - it('afterSaveFile should set fileSize to null if beforeSave returns an already saved file', async () => { + it('afterSave(Parse.File) should set fileSize to null if beforeSave returns an already saved file', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); Parse.Cloud.beforeSave(Parse.File, req => { @@ -3804,7 +3792,7 @@ describe('saveFile hooks', () => { expect(createFileSpy).not.toHaveBeenCalled(); }); - it('afterSaveFile should throw error', async () => { + it('afterSave(Parse.File) should throw error', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.afterSave(Parse.File, async () => { throw new Parse.Error(400, 'some-error-message'); @@ -3818,7 +3806,7 @@ describe('saveFile hooks', () => { } }); - it('afterSaveFile should call with fileObject', async done => { + it('afterSave(Parse.File) should call with fileObject', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, async req => { req.file.setTags({ tagA: 'some-tag' }); @@ -3834,7 +3822,7 @@ describe('saveFile hooks', () => { await file.save({ useMasterKey: true }); }); - it('afterSaveFile should change fileSize when file data changes', async done => { + it('afterSave(Parse.File) should change fileSize when file data changes', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, async req => { expect(req.fileSize).toBe(3); @@ -3851,7 +3839,7 @@ describe('saveFile hooks', () => { await file.save({ useMasterKey: true }); }); - it('beforeDeleteFile should call with fileObject', async () => { + it('beforeDelete(Parse.File) should call with fileObject', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, req => { expect(req.file).toBeInstanceOf(Parse.File); @@ -3863,7 +3851,7 @@ describe('saveFile hooks', () => { await file.destroy({ useMasterKey: true }); }); - it('beforeDeleteFile should throw error', async done => { + it('beforeDelete(Parse.File) should throw error', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, () => { throw new Error('some error message'); @@ -3877,7 +3865,7 @@ describe('saveFile hooks', () => { } }); - it('afterDeleteFile should call with fileObject', async done => { + it('afterDelete(Parse.File) should call with fileObject', async done => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeDelete(Parse.File, req => { expect(req.file).toBeInstanceOf(Parse.File); @@ -3894,7 +3882,7 @@ describe('saveFile hooks', () => { await file.destroy({ useMasterKey: true }); }); - it('beforeSaveFile should not change file if nothing is returned', async () => { + it('beforeSave(Parse.File) should not change file if nothing is returned', async () => { await reconfigureServer({ filesAdapter: mockAdapter }); Parse.Cloud.beforeSave(Parse.File, () => { return; @@ -3904,7 +3892,7 @@ describe('saveFile hooks', () => { expect(result).toBe(file); }); - it('throw custom error from beforeSaveFile', async done => { + it('throw custom error from beforeSave(Parse.File) ', async done => { Parse.Cloud.beforeSave(Parse.File, () => { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); }); @@ -3918,7 +3906,7 @@ describe('saveFile hooks', () => { } }); - it('throw empty error from beforeSaveFile', async done => { + it('throw empty error from beforeSave(Parse.File)', async done => { Parse.Cloud.beforeSave(Parse.File, () => { throw null; }); @@ -3931,53 +3919,281 @@ describe('saveFile hooks', () => { done(); } }); +}); - it('legacy hooks', async () => { - await reconfigureServer({ filesAdapter: mockAdapter }); - const logger = require('../lib/logger').logger; - const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); - const triggers = { - beforeSaveFile(req) { - req.file.setTags({ tagA: 'some-tag' }); - req.file.setMetadata({ foo: 'bar' }); - expect(req.triggerName).toEqual('beforeSave'); - expect(req.master).toBe(true); - }, - afterSaveFile(req) { - expect(req.master).toBe(true); - expect(req.file._tags).toEqual({ tagA: 'some-tag' }); - expect(req.file._metadata).toEqual({ foo: 'bar' }); - }, - beforeDeleteFile(req) { - expect(req.file).toBeInstanceOf(Parse.File); - expect(req.file._name).toEqual('popeye.txt'); - expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); - expect(req.fileSize).toBe(null); - }, - afterDeleteFile(req) { - expect(req.file).toBeInstanceOf(Parse.File); - expect(req.file._name).toEqual('popeye.txt'); - expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); +describe('queryFile hooks', () => { + + it('find hooks should run', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('beforeFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + }, + afterFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('afterFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + expect(req.forceDownload).toBeFalse(); }, }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); - for (const key in triggers) { - spyOn(triggers, key).and.callThrough(); - Parse.Cloud[key](triggers[key]); + it('beforeFind can throw', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() { + throw 'unauthorized'; + }, + afterFind() {}, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + + expect(hooks.beforeFind).toHaveBeenCalled(); + expect(hooks.afterFind).not.toHaveBeenCalled(); + }); + it('afterFind can throw', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); - await new Parse.File('popeye.txt', [1, 2, 3], 'text/plain').destroy({ useMasterKey: true }); - await new Promise(resolve => setTimeout(resolve, 100)); - for (const key in triggers) { - expect(triggers[key]).toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith( - `DeprecationWarning: Parse.Cloud.${key} is deprecated and will be removed in a future version. Use Parse.Cloud.${key.replace( - 'File', - '' - )}(Parse.File, (request) => {})` - ); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() {}, + afterFind() { + throw 'unauthorized'; + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('can force download', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, req => { + req.forceDownload = true; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); + }); + }); + +describe('Cloud Config hooks', () => { + function testConfig() { + return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true }); + } + + it_id('997fe20a-96f7-454a-a5b0-c155b8d02f05')(it)('beforeSave(Parse.Config) can run hook with new config', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, (req) => { + expect(req.object).toBeDefined(); + expect(req.original).toBeUndefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + const config = req.object; + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + count += 1; + }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); + }); + + it_id('06a9b66c-ffb4-43d1-a025-f7d2192500e7')(it)('beforeSave(Parse.Config) can run hook with existing config', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, (req) => { + if (count === 0) { + expect(req.object.get('number')).toBe(12); + expect(req.original).toBeUndefined(); + } + if (count === 1) { + expect(req.object.get('number')).toBe(13); + expect(req.original.get('number')).toBe(12); + } + count += 1; + }); + await testConfig(); + await Parse.Config.save({ number: 13 }); + expect(count).toBe(2); + }); + + it_id('ca76de8e-671b-4c2d-9535-bd28a855fa1a')(it)('beforeSave(Parse.Config) should not change config if nothing is returned', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, () => { + count += 1; + return; + }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); + }); + + it('beforeSave(Parse.Config) throw custom error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('It should fail'); + } + }); + + it('beforeSave(Parse.Config) throw string error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw 'before save failed'; + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('before save failed'); + } + }); + + it('beforeSave(Parse.Config) throw empty error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw null; + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('Script failed. Unknown error.'); + } + }); + + it_id('3e7a75c0-6c2e-4c7e-b042-6eb5f23acf94')(it)('afterSave(Parse.Config) can run hook with new config', async () => { + let count = 0; + Parse.Cloud.afterSave(Parse.Config, (req) => { + expect(req.object).toBeDefined(); + expect(req.original).toBeUndefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + const config = req.object; + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + count += 1; + }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); + }); + + it_id('5cffb28a-2924-4857-84bb-f5778d80372a')(it)('afterSave(Parse.Config) can run hook with existing config', async () => { + let count = 0; + Parse.Cloud.afterSave(Parse.Config, (req) => { + if (count === 0) { + expect(req.object.get('number')).toBe(12); + expect(req.original).toBeUndefined(); + } + if (count === 1) { + expect(req.object.get('number')).toBe(13); + expect(req.original.get('number')).toBe(12); + } + count += 1; + }); + await testConfig(); + await Parse.Config.save({ number: 13 }); + expect(count).toBe(2); + }); + + it_id('49883992-ce91-4797-85f9-7cce1f819407')(it)('afterSave(Parse.Config) should throw error', async () => { + Parse.Cloud.afterSave(Parse.Config, () => { + throw new Parse.Error(400, 'It should fail'); + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(400); + expect(e.message).toBe('It should fail'); } }); }); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 0aa2732f93..e87d7a8edb 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -3,7 +3,6 @@ import BodyParser from 'body-parser'; import * as Middlewares from '../middlewares'; import Parse from 'parse/node'; import Config from '../Config'; -import mime from 'mime'; import logger from '../logger'; const triggers = require('../triggers'); const http = require('http'); @@ -79,6 +78,7 @@ export class FilesRouter { let filename = req.params.filename; try { const filesController = config.filesController; + const mime = (await import('mime')).default; let contentType = mime.getType(filename); let file = new Parse.File(filename, { base64: '' }, contentType); const triggerResult = await triggers.maybeRunFileTrigger( From 49ff288f78c67fd84e0b9010186773fd954b1dc9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 19 Mar 2025 21:16:08 +1100 Subject: [PATCH 5/7] Update FilesRouter.js --- src/Routers/FilesRouter.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index e87d7a8edb..c6c356f7a0 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -193,13 +193,13 @@ export class FilesRouter { }; let extension = contentType; if (filename && filename.includes('.')) { - extension = filename.split('.')[1]; + extension = filename.substring(filename.lastIndexOf('.') + 1); } else if (contentType && contentType.includes('/')) { extension = contentType.split('/')[1]; } - extension = extension.split(' ').join(''); + extension = extension?.split(' ')?.join(''); - if (!isValidExtension(extension)) { + if (extension && !isValidExtension(extension)) { next( new Parse.Error( Parse.Error.FILE_SAVE_ERROR, @@ -301,7 +301,7 @@ export class FilesRouter { const { filename } = req.params; // run beforeDeleteFile trigger const file = new Parse.File(filename); - file._url = filesController.adapter.getFileLocation(req.config, filename); + file._url = await filesController.adapter.getFileLocation(req.config, filename); const fileObject = { file, fileSize: null }; await triggers.maybeRunFileTrigger( triggers.Types.beforeDelete, From c1a1fe2ecff8fbd3f5b41186cb043ba0ef4cebd6 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Thu, 27 Mar 2025 17:55:42 +0100 Subject: [PATCH 6/7] remove empty line Signed-off-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- spec/CloudCode.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 97fdfdba44..8cb5c9f278 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3922,7 +3922,6 @@ describe('saveFile hooks', () => { }); describe('queryFile hooks', () => { - it('find hooks should run', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); From 91ac63836b7db937738e42ecede88c54aecdfcb5 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Thu, 27 Mar 2025 17:56:15 +0100 Subject: [PATCH 7/7] rename describe group Signed-off-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- spec/CloudCode.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 8cb5c9f278..edfd1d41cd 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3921,7 +3921,7 @@ describe('saveFile hooks', () => { }); }); -describe('queryFile hooks', () => { +describe('Parse.File hooks', () => { it('find hooks should run', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true });