From c2f2281e6de60b06af43213e0184bfb90acbec95 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 26 Oct 2020 04:36:54 +1100 Subject: [PATCH] Validation Handler Update (#6968) * Initial Commit * Update FunctionsRouter.js * Update FunctionsRouter.js * Change params to fields * Changes requested * Fix failing tests * More tests * More tests * Remove existing functionality * Remove legacy tests * fix array typo * Update triggers.js * Docs * Allow requireUserKeys to be object * validateMasterKey * Improve documentation Co-authored-by: Diamond Lewis --- spec/CloudCode.Validator.spec.js | 1174 ++++++++++++++++++++++++++++++ spec/CloudCode.spec.js | 43 ++ spec/ParseAPI.spec.js | 118 ++- spec/ParseLiveQuery.spec.js | 79 ++ src/Routers/FilesRouter.js | 25 +- src/Routers/FunctionsRouter.js | 37 +- src/cloud-code/Parse.Cloud.js | 213 ++++-- src/triggers.js | 239 +++++- 8 files changed, 1752 insertions(+), 176 deletions(-) create mode 100644 spec/CloudCode.Validator.spec.js diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js new file mode 100644 index 0000000000..b21c71e101 --- /dev/null +++ b/spec/CloudCode.Validator.spec.js @@ -0,0 +1,1174 @@ +'use strict'; +const Parse = require('parse/node'); +const validatorFail = () => { + throw 'you are not authorized'; +}; +const validatorSuccess = () => { + return true; +}; + +describe('cloud validator', () => { + it('complete validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => {} + ); + try { + const result = await Parse.Cloud.run('myFunction', {}); + expect(result).toBe('myFunc'); + done(); + } catch (e) { + fail('should not have thrown error'); + } + }); + + it('Throw from validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw 'error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validator can throw parse error', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('It should fail'); + done(); + } + }); + + it('validator can throw parse error with no message', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED); + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBeUndefined(); + done(); + } + }); + + it('async validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + async () => { + await new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + throw 'async error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + expect(e.message).toBe('async error'); + done(); + } + }); + + it('pass function to validator', async done => { + const validator = request => { + expect(request).toBeDefined(); + expect(request.params).toBeDefined(); + expect(request.master).toBe(false); + expect(request.user).toBeUndefined(); + expect(request.installationId).toBeDefined(); + expect(request.log).toBeDefined(); + expect(request.headers).toBeDefined(); + expect(request.functionName).toBeDefined(); + expect(request.context).toBeDefined(); + done(); + }; + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + validator + ); + await Parse.Cloud.run('myFunction'); + }); + + it('require user on cloud functions', done => { + Parse.Cloud.define( + 'hello1', + () => { + return 'Hello world!'; + }, + { + requireUser: true, + } + ); + + Parse.Cloud.run('hello1', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please login to continue.' + ); + done(); + }); + }); + + it('require master on cloud functions', done => { + Parse.Cloud.define( + 'hello2', + () => { + return 'Hello world!'; + }, + { + requireMaster: true, + } + ); + Parse.Cloud.run('hello2', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Master key is required to complete this request.' + ); + done(); + }); + }); + + it('set params on cloud functions', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: ['a'], + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please specify data for a.' + ); + done(); + }); + }); + + it('set params on cloud functions', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: ['a'], + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please specify data for a.' + ); + done(); + }); + }); + + it('allow params on cloud functions', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.a).toEqual('yolo'); + return 'Hello world!'; + }, + { + fields: ['a'], + } + ); + Parse.Cloud.run('hello', { a: 'yolo' }) + .then(() => { + done(); + }) + .catch(() => { + fail('Error should not have been called.'); + }); + }); + + it('set params type array', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: Array, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: '' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Invalid type for data. Expected: array' + ); + done(); + }); + }); + + it('set params type', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: [] }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Invalid type for data. Expected: string' + ); + done(); + }); + }); + + it('set params default', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + default: 'yolo', + }, + }, + } + ); + Parse.Cloud.run('hello') + .then(() => { + done(); + }) + .catch(() => { + fail('function should not have failed.'); + }); + }); + + it('set params required', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + }, + }, + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please specify data for data.' + ); + done(); + }); + }); + + it('set params option', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + options: 'a', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'f' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Invalid option for data. Expected: a' + ); + done(); + }); + }); + + it('set params options', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + options: ['a', 'b'], + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'f' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Invalid option for data. Expected: a, b' + ); + done(); + }); + }); + + it('set params options function', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: Number, + required: true, + options: val => { + return val > 1 && val < 5; + }, + error: 'Validation failed. Expected data to be between 1 and 5.', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 7 }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Expected data to be between 1 and 5.' + ); + done(); + }); + }); + + it('can run params function on null', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: val => { + return val.length > 5; + }, + error: 'Validation failed. String should be at least 5 characters', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: null }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. String should be at least 5 characters' + ); + done(); + }); + }); + + it('can throw from options validator', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: () => { + throw 'validation failed.'; + }, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'a' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('validation failed.'); + done(); + }); + }); + + it('can throw null from options validator', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: () => { + throw null; + }, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'a' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Invalid value for data.' + ); + done(); + }); + }); + + it('can create functions', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + requireUser: false, + requireMaster: false, + fields: { + data: { + type: String, + }, + data1: { + type: String, + default: 'default', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'str' }).then(result => { + expect(result).toEqual('Hello world!'); + done(); + }); + }); + + it('basic beforeSave requireUserKey', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + user.set('name', 'foo'); + await user.save(null, { sessionToken: user.getSessionToken() }); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + expect(obj.get('foo')).toBe('bar'); + done(); + }); + + it('basic beforeSave requireUserKey on User Class', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('name', 'foo'); + expect(user.get('name')).toBe('foo'); + done(); + }); + + it('basic beforeSave requireUserKey rejection', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + try { + await obj.save(null, { sessionToken: user.getSessionToken() }); + fail('should not have been able to save without userkey'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please set data for name on your account.' + ); + done(); + } + }); + + it('basic beforeSave requireUserKey without user', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUserKeys: ['name'], + }); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + try { + await obj.save(); + fail('should not have been able to save without user'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Please login to make this request.'); + done(); + } + }); + + it('basic beforeSave requireUserKey as admin', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + admin: { + default: false, + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + admin: { + options: true, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('admin', true); + await user.signUp(); + expect(user.get('admin')).toBe(false); + try { + await Parse.Cloud.run('secureFunction'); + fail('function should only be available to admin users'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Unauthorized.'); + } + done(); + }); + + it('basic beforeSave requireUserKey as custom function', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + accType: { + default: 'normal', + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + accType: { + options: val => { + return ['admin', 'admin2'].includes(val); + }, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('accType', 'admin'); + await user.signUp(); + expect(user.get('accType')).toBe('normal'); + try { + await Parse.Cloud.run('secureFunction'); + fail('function should only be available to admin users'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Unauthorized.'); + } + done(); + }); + + it('basic beforeSave allow requireUserKey as custom function', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + accType: { + default: 'admin', + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + accType: { + options: val => { + return ['admin', 'admin2'].includes(val); + }, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + await user.signUp(); + expect(user.get('accType')).toBe('admin'); + const result = await Parse.Cloud.run('secureFunction'); + expect(result).toBe("Here's all the secure data!"); + done(); + }); + + it('basic beforeSave requireUser', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please login to continue.' + ); + done(); + }); + }); + + it('basic beforeSave requireMaster', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireMaster: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Master key is required to complete this request.' + ); + done(); + }); + }); + + it('basic beforeSave master', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + await obj.save(null, { useMasterKey: true }); + done(); + }); + + it('basic beforeSave validateMasterKey', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + validateMasterKey: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save(null, { useMasterKey: true }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please login to continue.' + ); + done(); + }); + }); + + it('basic beforeSave requireKeys', function (done) { + Parse.Cloud.beforeSave('beforeSaveRequire', () => {}, { + fields: { + foo: { + required: true, + }, + bar: { + required: true, + }, + }, + }); + const obj = new Parse.Object('beforeSaveRequire'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Please specify data for bar.' + ); + done(); + }); + }); + + it('basic beforeSave constantKeys', async function (done) { + Parse.Cloud.beforeSave('BeforeSave', () => {}, { + fields: { + foo: { + constant: true, + default: 'bar', + }, + }, + }); + const obj = new Parse.Object('BeforeSave'); + obj.set('foo', 'far'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + obj.set('foo', 'yolo'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + done(); + }); + + it('basic beforeSave defaultKeys', async function (done) { + Parse.Cloud.beforeSave('BeforeSave', () => {}, { + fields: { + foo: { + default: 'bar', + }, + }, + }); + const obj = new Parse.Object('BeforeSave'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + obj.set('foo', 'yolo'); + await obj.save(); + expect(obj.get('foo')).toBe('yolo'); + done(); + }); + + it('validate beforeSave', async done => { + Parse.Cloud.beforeSave('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + done(); + } catch (e) { + fail('before save should not have failed.'); + } + }); + + it('validate beforeSave fail', async done => { + Parse.Cloud.beforeSave('MyObject', () => {}, validatorFail); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterSave', async done => { + Parse.Cloud.afterSave( + 'MyObject', + () => { + done(); + }, + validatorSuccess + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + } catch (e) { + fail('before save should not have failed.'); + } + }); + + it('validate afterSave fail', async done => { + Parse.Cloud.afterSave( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + setTimeout(() => { + done(); + }, 1000); + }); + + it('validate beforeDelete', async done => { + Parse.Cloud.beforeDelete('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + done(); + } catch (e) { + fail('before delete should not have failed.'); + } + }); + + it('validate beforeDelete fail', async done => { + Parse.Cloud.beforeDelete( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterDelete', async done => { + Parse.Cloud.afterDelete( + 'MyObject', + () => { + done(); + }, + validatorSuccess + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + } catch (e) { + fail('after delete should not have failed.'); + } + }); + + it('validate afterDelete fail', async done => { + Parse.Cloud.afterDelete( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeFind', async done => { + Parse.Cloud.beforeFind('MyObject', () => {}, validatorSuccess); + try { + const MyObject = Parse.Object.extend('MyObject'); + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + done(); + } catch (e) { + fail('beforeFind should not have failed.'); + } + }); + it('validate beforeFind fail', async done => { + Parse.Cloud.beforeFind('MyObject', () => {}, validatorFail); + try { + const MyObject = Parse.Object.extend('MyObject'); + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterFind', async done => { + Parse.Cloud.afterFind('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + done(); + } catch (e) { + fail('beforeFind should not have failed.'); + } + }); + + it('validate afterFind fail', async done => { + Parse.Cloud.afterFind('MyObject', () => {}, validatorFail); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeSaveFile', async done => { + Parse.Cloud.beforeSaveFile(() => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + done(); + }); + + it('validate beforeSaveFile fail', async done => { + Parse.Cloud.beforeSaveFile(() => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterSaveFile', async done => { + Parse.Cloud.afterSaveFile(() => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + done(); + }); + + it('validate afterSaveFile fail', async done => { + Parse.Cloud.beforeSaveFile(() => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeDeleteFile', async done => { + Parse.Cloud.beforeDeleteFile(() => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + done(); + }); + + it('validate beforeDeleteFile fail', async done => { + Parse.Cloud.beforeDeleteFile(() => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterDeleteFile', async done => { + Parse.Cloud.afterDeleteFile(() => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + done(); + }); + + it('validate afterDeleteFile fail', async done => { + Parse.Cloud.afterDeleteFile(() => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('Should have validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => {}, + () => { + throw 'error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); +}); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 0255de352d..a5a89f334d 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -113,6 +113,21 @@ describe('Cloud Code', () => { ); }); + it('returns an empty error', done => { + Parse.Cloud.define('cloudCodeWithError', () => { + throw null; + }); + + Parse.Cloud.run('cloudCodeWithError').then( + () => done.fail('should not succeed'), + e => { + expect(e.code).toEqual(141); + expect(e.message).toEqual('Script failed.'); + done(); + } + ); + }); + it('beforeSave rejection with custom error code', function (done) { Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function () { throw new Parse.Error(999, 'Nope'); @@ -2675,6 +2690,34 @@ describe('beforeLogin hook', () => { expect(result).toBe(file); }); + it('throw custom error from beforeSaveFile', async done => { + Parse.Cloud.beforeSaveFile(() => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + }); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + done(); + } + }); + + it('throw empty error from beforeSaveFile', async done => { + Parse.Cloud.beforeSaveFile(() => { + throw null; + }); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(130); + done(); + } + }); + 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/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 8e0af10ac6..dd98716458 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -24,7 +24,7 @@ const headers = { }; describe_only_db('mongo')('miscellaneous', () => { - it('test rest_create_app', function(done) { + it('test rest_create_app', function (done) { let appId; Parse._request('POST', 'rest_create_app') .then(res => { @@ -57,19 +57,19 @@ describe_only_db('mongo')('miscellaneous', () => { }); }); -describe('miscellaneous', function() { - it('create a GameScore object', function(done) { +describe('miscellaneous', function () { + it('create a GameScore object', function (done) { const obj = new Parse.Object('GameScore'); obj.set('score', 1337); - obj.save().then(function(obj) { + obj.save().then(function (obj) { expect(typeof obj.id).toBe('string'); expect(typeof obj.createdAt.toGMTString()).toBe('string'); done(); }, done.fail); }); - it('get a TestObject', function(done) { - create({ bloop: 'blarg' }, async function(obj) { + it('get a TestObject', function (done) { + create({ bloop: 'blarg' }, async function (obj) { const t2 = new TestObject({ objectId: obj.id }); const obj2 = await t2.fetch(); expect(obj2.get('bloop')).toEqual('blarg'); @@ -79,8 +79,8 @@ describe('miscellaneous', function() { }); }); - it('create a valid parse user', function(done) { - createTestUser().then(function(data) { + it('create a valid parse user', function (done) { + createTestUser().then(function (data) { expect(data.id).not.toBeUndefined(); expect(data.getSessionToken()).not.toBeUndefined(); expect(data.get('password')).toBeUndefined(); @@ -297,8 +297,8 @@ describe('miscellaneous', function() { }); }); - it('succeed in logging in', function(done) { - createTestUser().then(async function(u) { + it('succeed in logging in', function (done) { + createTestUser().then(async function (u) { expect(typeof u.id).toEqual('string'); const user = await Parse.User.logIn('test', 'moon-y'); @@ -310,7 +310,7 @@ describe('miscellaneous', function() { }, fail); }); - it('increment with a user object', function(done) { + it('increment with a user object', function (done) { createTestUser() .then(user => { user.increment('foo'); @@ -338,7 +338,7 @@ describe('miscellaneous', function() { ); }); - it('save various data types', function(done) { + it('save various data types', function (done) { const obj = new TestObject(); obj.set('date', new Date()); obj.set('array', [1, 2, 3]); @@ -358,7 +358,7 @@ describe('miscellaneous', function() { }); }); - it('query with limit', function(done) { + it('query with limit', function (done) { const baz = new TestObject({ foo: 'baz' }); const qux = new TestObject({ foo: 'qux' }); baz @@ -383,7 +383,7 @@ describe('miscellaneous', function() { ); }); - it('query without limit get default 100 records', function(done) { + it('query without limit get default 100 records', function (done) { const objects = []; for (let i = 0; i < 150; i++) { objects.push(new TestObject({ name: 'name' + i })); @@ -404,7 +404,7 @@ describe('miscellaneous', function() { ); }); - it('basic saveAll', function(done) { + it('basic saveAll', function (done) { const alpha = new TestObject({ letter: 'alpha' }); const beta = new TestObject({ letter: 'beta' }); Parse.Object.saveAll([alpha, beta]) @@ -425,26 +425,26 @@ describe('miscellaneous', function() { ); }); - it('test beforeSave set object acl success', function(done) { + it('test beforeSave set object acl success', function (done) { const acl = new Parse.ACL({ '*': { read: true, write: false }, }); - Parse.Cloud.beforeSave('BeforeSaveAddACL', function(req) { + Parse.Cloud.beforeSave('BeforeSaveAddACL', function (req) { req.object.setACL(acl); }); const obj = new Parse.Object('BeforeSaveAddACL'); obj.set('lol', true); obj.save().then( - function() { + function () { const query = new Parse.Query('BeforeSaveAddACL'); query.get(obj.id).then( - function(objAgain) { + function (objAgain) { expect(objAgain.get('lol')).toBeTruthy(); expect(objAgain.getACL().equals(acl)); done(); }, - function(error) { + function (error) { fail(error); done(); } @@ -667,10 +667,10 @@ describe('miscellaneous', function() { }); }); - it('test afterSave get full object on create and update', function(done) { + it('test afterSave get full object on create and update', function (done) { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.id).not.toBeUndefined(); @@ -694,29 +694,29 @@ describe('miscellaneous', function() { obj.set('fooAgain', 'barAgain'); obj .save() - .then(function() { + .then(function () { // We only update foo obj.set('foo', 'baz'); return obj.save(); }) .then( - function() { + function () { // Make sure the checking has been triggered expect(triggerTime).toBe(2); done(); }, - function(error) { + function (error) { fail(error); done(); } ); }); - it('test afterSave get original object on update', function(done) { + it('test afterSave get original object on update', function (done) { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); @@ -750,18 +750,18 @@ describe('miscellaneous', function() { obj.set('fooAgain', 'barAgain'); obj .save() - .then(function() { + .then(function () { // We only update foo obj.set('foo', 'baz'); return obj.save(); }) .then( - function() { + function () { // Make sure the checking has been triggered expect(triggerTime).toBe(2); done(); }, - function(error) { + function (error) { jfail(error); done(); } @@ -771,7 +771,7 @@ describe('miscellaneous', function() { it('test afterSave get full original object even req auth can not query it', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { const object = req.object; const originalObject = req.original; if (triggerTime == 0) { @@ -802,18 +802,18 @@ describe('miscellaneous', function() { obj.setACL(acl); obj .save() - .then(function() { + .then(function () { // We only update foo obj.set('foo', 'baz'); return obj.save(); }) .then( - function() { + function () { // Make sure the checking has been triggered expect(triggerTime).toBe(2); done(); }, - function(error) { + function (error) { jfail(error); done(); } @@ -823,7 +823,7 @@ describe('miscellaneous', function() { it('afterSave flattens custom operations', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); const originalObject = req.original; @@ -865,7 +865,7 @@ describe('miscellaneous', function() { it('beforeSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', function(req) { + Parse.Cloud.beforeSave('GameScore', function (req) { const object = req.object; if (triggerTime == 0) { const acl = object.getACL(); @@ -909,7 +909,7 @@ describe('miscellaneous', function() { it('afterSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { const object = req.object; if (triggerTime == 0) { const acl = object.getACL(); @@ -1057,14 +1057,14 @@ describe('miscellaneous', function() { ); }); - it('test beforeSave/afterSave get installationId', function(done) { + it('test beforeSave/afterSave get installationId', function (done) { let triggerTime = 0; - Parse.Cloud.beforeSave('GameScore', function(req) { + Parse.Cloud.beforeSave('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); }); - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(2); expect(req.installationId).toEqual('yolo'); @@ -1087,14 +1087,14 @@ describe('miscellaneous', function() { }); }); - it('test beforeDelete/afterDelete get installationId', function(done) { + it('test beforeDelete/afterDelete get installationId', function (done) { let triggerTime = 0; - Parse.Cloud.beforeDelete('GameScore', function(req) { + Parse.Cloud.beforeDelete('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); }); - Parse.Cloud.afterDelete('GameScore', function(req) { + Parse.Cloud.afterDelete('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(2); expect(req.installationId).toEqual('yolo'); @@ -1170,33 +1170,6 @@ describe('miscellaneous', function() { }); }); - it('test cloud function parameter validation', done => { - // Register a function with validation - Parse.Cloud.define( - 'functionWithParameterValidationFailure', - () => { - return 'noway'; - }, - request => { - return request.params.success === 100; - } - ); - - Parse.Cloud.run('functionWithParameterValidationFailure', { - success: 500, - }).then( - () => { - fail('Validation should not have succeeded'); - done(); - }, - e => { - expect(e.code).toEqual(142); - expect(e.message).toEqual('Validation failed.'); - done(); - } - ); - }); - it('can handle null params in cloud functions (regression test for #1742)', done => { Parse.Cloud.define('func', request => { expect(request.params.nullParam).toEqual(null); @@ -1715,10 +1688,7 @@ describe('miscellaneous', function() { it('purge empty class', done => { const testSchema = new Parse.Schema('UnknownClass'); - testSchema - .purge() - .then(done) - .catch(done.fail); + testSchema.purge().then(done).catch(done.fail); }); it('should not update schema beforeSave #2672', done => { diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index bb98306e97..cb43b32d97 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -2,6 +2,10 @@ const UserController = require('../lib/Controllers/UserController') .UserController; const Config = require('../lib/Config'); +const validatorFail = () => { + throw 'you are not authorized'; +}; + describe('ParseLiveQuery', function () { it('can subscribe to query', async done => { await reconfigureServer({ @@ -231,6 +235,7 @@ describe('ParseLiveQuery', function () { object.set({ foo: 'bar' }); await object.save(); }); + it('can handle afterEvent throw', async done => { await reconfigureServer({ liveQuery: { @@ -300,6 +305,7 @@ describe('ParseLiveQuery', function () { object.set({ foo: 'bar' }); await object.save(); }); + it('expect afterEvent create', async done => { await reconfigureServer({ liveQuery: { @@ -551,6 +557,79 @@ describe('ParseLiveQuery', function () { await object.save(); }); + it('can handle beforeConnect validation function', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + Parse.Cloud.beforeConnect(() => {}, validatorFail); + let complete = false; + Parse.LiveQuery.on('error', error => { + if (complete) { + return; + } + complete = true; + expect(error).toBe('you are not authorized'); + done(); + }); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await query.subscribe(); + }); + + it('can handle beforeSubscribe validation function', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.beforeSubscribe(TestObject, () => {}, validatorFail); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('error', error => { + expect(error).toBe('you are not authorized'); + done(); + }); + }); + + it('can handle afterEvent validation function', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', () => {}, validatorFail); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('error', error => { + expect(error).toBe('you are not authorized'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + it('can handle beforeConnect error', async done => { await reconfigureServer({ liveQuery: { diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 705d7d0c84..01d1103f48 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -33,15 +33,6 @@ const addFileDataIfNeeded = async file => { return file; }; -const errorMessageFromError = e => { - if (typeof e === 'string') { - return e; - } else if (e && e.message) { - return e.message; - } - return undefined; -}; - export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { var router = express.Router(); @@ -192,10 +183,11 @@ export class FilesRouter { res.json(saveResult); } catch (e) { logger.error('Error creating a file: ', e); - const errorMessage = - errorMessageFromError(e) || - `Could not store file: ${fileObject.file._name}.`; - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, errorMessage)); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_SAVE_ERROR, + message: `Could not store file: ${fileObject.file._name}.`, + }); + next(error); } } @@ -227,8 +219,11 @@ export class FilesRouter { res.end(); } catch (e) { logger.error('Error deleting a file: ', e); - const errorMessage = errorMessageFromError(e) || `Could not delete file.`; - next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR, errorMessage)); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_DELETE_ERROR, + message: 'Could not delete file.', + }); + next(error); } } diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index e40ede0502..85f5960196 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -109,37 +109,17 @@ export class FunctionsRouter extends PromiseRouter { }); }, error: function (message) { - // parse error, process away - if (message instanceof Parse.Error) { - return reject(message); - } - - const code = Parse.Error.SCRIPT_FAILED; - // If it's an error, mark it as a script failed - if (typeof message === 'string') { - return reject(new Parse.Error(code, message)); - } - const error = new Parse.Error( - code, - (message && message.message) || message - ); - if (message instanceof Error) { - error.stack = message.stack; - } + const error = triggers.resolveError(message); reject(error); }, message: message, }; } - static handleCloudFunction(req) { const functionName = req.params.functionName; const applicationId = req.config.applicationId; const theFunction = triggers.getFunction(functionName, applicationId); - const theValidator = triggers.getValidator( - req.params.functionName, - applicationId - ); + if (!theFunction) { throw new Parse.Error( Parse.Error.SCRIPT_FAILED, @@ -160,16 +140,6 @@ export class FunctionsRouter extends PromiseRouter { context: req.info.context, }; - if (theValidator && typeof theValidator === 'function') { - var result = theValidator(request); - if (!result) { - throw new Parse.Error( - Parse.Error.VALIDATION_ERROR, - 'Validation failed.' - ); - } - } - return new Promise(function (resolve, reject) { const userString = req.auth && req.auth.user ? req.auth.user.id : undefined; @@ -212,6 +182,9 @@ export class FunctionsRouter extends PromiseRouter { } ); return Promise.resolve() + .then(() => { + return triggers.maybeRunValidator(request, functionName); + }) .then(() => { return theFunction(request, { message }); }) diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 01d1569bc1..ddd31bd2e5 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -32,11 +32,24 @@ var ParseCloud = {}; * Defines a Cloud Function. * * **Available in Cloud Code only.** - + * + * ``` + * Parse.Cloud.define('functionName', (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.define('functionName', (request) => { + * // code here + * }, { ...validationObject }); + * ``` + * * @static * @memberof Parse.Cloud * @param {String} name The name of the Cloud Function * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.define = function (functionName, handler, validationHandler) { triggers.addFunction( @@ -73,25 +86,29 @@ ParseCloud.job = function (functionName, handler) { * ``` * Parse.Cloud.beforeSave('MyCustomClass', (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.beforeSave(Parse.User, (request) => { * // code here - * }) + * }, { ...validationObject }) * ``` * * @method beforeSave * @name Parse.Cloud.beforeSave * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after save function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run before a save. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeSave = function (parseClass, handler) { +ParseCloud.beforeSave = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.beforeSave, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -104,25 +121,29 @@ ParseCloud.beforeSave = function (parseClass, handler) { * ``` * Parse.Cloud.beforeDelete('MyCustomClass', (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.beforeDelete(Parse.User, (request) => { * // code here - * }) + * }, { ...validationObject }) *``` * * @method beforeDelete * @name Parse.Cloud.beforeDelete * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before delete function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run before a delete. This function can be async and should take one parameter, a {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeDelete = function (parseClass, handler) { +ParseCloud.beforeDelete = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.beforeDelete, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -177,8 +198,7 @@ ParseCloud.beforeLogin = function (handler) { * ``` * Parse.Cloud.afterLogin((request) => { * // code here - * }) - * + * }); * ``` * * @method afterLogin @@ -212,8 +232,7 @@ ParseCloud.afterLogin = function (handler) { * ``` * Parse.Cloud.afterLogout((request) => { * // code here - * }) - * + * }); * ``` * * @method afterLogout @@ -246,25 +265,29 @@ ParseCloud.afterLogout = function (handler) { * ``` * Parse.Cloud.afterSave('MyCustomClass', async function(request) { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.afterSave(Parse.User, async function(request) { * // code here - * }) + * }, { ...validationObject }); * ``` * * @method afterSave * @name Parse.Cloud.afterSave * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after save function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run after a save. This function can be an async function and should take just one parameter, {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterSave = function (parseClass, handler) { +ParseCloud.afterSave = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.afterSave, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -277,25 +300,29 @@ ParseCloud.afterSave = function (parseClass, handler) { * ``` * Parse.Cloud.afterDelete('MyCustomClass', async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.afterDelete(Parse.User, async (request) => { * // code here - * }) + * }, { ...validationObject }); *``` * * @method afterDelete * @name Parse.Cloud.afterDelete * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after delete function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run after a delete. This function can be async and should take just one parameter, {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterDelete = function (parseClass, handler) { +ParseCloud.afterDelete = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.afterDelete, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -308,25 +335,29 @@ ParseCloud.afterDelete = function (parseClass, handler) { * ``` * Parse.Cloud.beforeFind('MyCustomClass', async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.beforeFind(Parse.User, async (request) => { * // code here - * }) + * }, { ...validationObject }); *``` * * @method beforeFind * @name Parse.Cloud.beforeFind * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before find function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run before a find. This function can be async and should take just one parameter, {@link Parse.Cloud.BeforeFindRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.BeforeFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeFind = function (parseClass, handler) { +ParseCloud.beforeFind = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.beforeFind, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -339,25 +370,29 @@ ParseCloud.beforeFind = function (parseClass, handler) { * ``` * Parse.Cloud.afterFind('MyCustomClass', async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.afterFind(Parse.User, async (request) => { * // code here - * }) + * }, { ...validationObject }); *``` * * @method afterFind * @name Parse.Cloud.afterFind * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after find function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run before a find. This function can be async and should take just one parameter, {@link Parse.Cloud.AfterFindRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.AfterFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterFind = function (parseClass, handler) { +ParseCloud.afterFind = function (parseClass, handler, validationHandler) { const className = getClassName(parseClass); triggers.addTrigger( triggers.Types.afterFind, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -369,18 +404,26 @@ ParseCloud.afterFind = function (parseClass, handler) { * ``` * Parse.Cloud.beforeSaveFile(async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeSaveFile(async (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method beforeSaveFile * @name Parse.Cloud.beforeSaveFile * @param {Function} func The function to run before saving a file. This function can be async and should take just one parameter, {@link Parse.Cloud.FileTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FileTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeSaveFile = function (handler) { +ParseCloud.beforeSaveFile = function (handler, validationHandler) { triggers.addFileTrigger( triggers.Types.beforeSaveFile, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -392,18 +435,26 @@ ParseCloud.beforeSaveFile = function (handler) { * ``` * Parse.Cloud.afterSaveFile(async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterSaveFile(async (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method afterSaveFile * @name Parse.Cloud.afterSaveFile * @param {Function} func The function to run after saving a file. This function can be async and should take just one parameter, {@link Parse.Cloud.FileTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FileTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterSaveFile = function (handler) { +ParseCloud.afterSaveFile = function (handler, validationHandler) { triggers.addFileTrigger( triggers.Types.afterSaveFile, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -415,18 +466,26 @@ ParseCloud.afterSaveFile = function (handler) { * ``` * Parse.Cloud.beforeDeleteFile(async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeDeleteFile(async (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method beforeDeleteFile * @name Parse.Cloud.beforeDeleteFile * @param {Function} func The function to run before deleting a file. This function can be async and should take just one parameter, {@link Parse.Cloud.FileTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FileTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeDeleteFile = function (handler) { +ParseCloud.beforeDeleteFile = function (handler, validationHandler) { triggers.addFileTrigger( triggers.Types.beforeDeleteFile, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -438,18 +497,26 @@ ParseCloud.beforeDeleteFile = function (handler) { * ``` * Parse.Cloud.afterDeleteFile(async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterDeleteFile(async (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method afterDeleteFile * @name Parse.Cloud.afterDeleteFile * @param {Function} func The function to after before deleting a file. This function can be async and should take just one parameter, {@link Parse.Cloud.FileTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FileTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterDeleteFile = function (handler) { +ParseCloud.afterDeleteFile = function (handler, validationHandler) { triggers.addFileTrigger( triggers.Types.afterDeleteFile, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -461,18 +528,26 @@ ParseCloud.afterDeleteFile = function (handler) { * ``` * Parse.Cloud.beforeConnect(async (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeConnect(async (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method beforeConnect * @name Parse.Cloud.beforeConnect * @param {Function} func The function to before connection is made. This function can be async and should take just one parameter, {@link Parse.Cloud.ConnectTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.ConnectTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeConnect = function (handler) { +ParseCloud.beforeConnect = function (handler, validationHandler) { triggers.addConnectTrigger( triggers.Types.beforeConnect, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -485,25 +560,29 @@ ParseCloud.beforeConnect = function (handler) { * ``` * Parse.Cloud.beforeSubscribe('MyCustomClass', (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); * * Parse.Cloud.beforeSubscribe(Parse.User, (request) => { * // code here - * }) + * }, { ...validationObject }); *``` * * @method beforeSubscribe * @name Parse.Cloud.beforeSubscribe * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before subscription function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run before a subscription. This function can be async and should take one parameter, a {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.beforeSubscribe = function (parseClass, handler) { +ParseCloud.beforeSubscribe = function (parseClass, handler, validationHandler) { var className = getClassName(parseClass); triggers.addTrigger( triggers.Types.beforeSubscribe, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -519,21 +598,33 @@ ParseCloud.onLiveQueryEvent = function (handler) { * ``` * Parse.Cloud.afterLiveQueryEvent('MyCustomClass', (request) => { * // code here - * }) + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterLiveQueryEvent('MyCustomClass', (request) => { + * // code here + * }, { ...validationObject }); *``` * * @method afterLiveQueryEvent * @name Parse.Cloud.afterLiveQueryEvent * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after live query event function for. This can instead be a String that is the className of the subclass. * @param {Function} func The function to run after a live query event. This function can be async and should take one parameter, a {@link Parse.Cloud.LiveQueryEventTrigger}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.LiveQueryEventTrigger}, or a {@link Parse.Cloud.ValidatorObject}. */ -ParseCloud.afterLiveQueryEvent = function (parseClass, handler) { +ParseCloud.afterLiveQueryEvent = function ( + parseClass, + handler, + validationHandler +) { const className = getClassName(parseClass); triggers.addTrigger( triggers.Types.afterEvent, className, handler, - Parse.applicationId + Parse.applicationId, + validationHandler ); }; @@ -642,3 +733,23 @@ module.exports = ParseCloud; * @property {Object} params The params passed to the background job. * @property {function} message If message is called with a string argument, will update the current message to be stored in the job status. */ + +/** + * @interface Parse.Cloud.ValidatorObject + * @property {Boolean} requireUser whether the cloud trigger requires a user. + * @property {Boolean} requireMaster whether the cloud trigger requires a master key. + * @property {Boolean} validateMasterKey whether the validator should run if masterKey is provided. Defaults to false. + * + * @property {Array|Object} requireUserKeys If set, keys required on request.user to make the request. + * @property {String} requireUserKeys.field If requireUserKeys is an object, name of field to validate on request user + * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. + * @property {String} requireUserKeys.field.error custom error message if field is invalid. + * + * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. + * @property {String} fields.field name of field to validate. + * @property {String} fields.field.type expected type of data for field. + * @property {Boolean} fields.field.constant whether the field can be modified on the object. + * @property {Any} fields.field.default default value if field is `null`, or initial value `constant` is `true`. + * @property {Array|function|Any} fields.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. + * @property {String} fields.field.error custom error message if field is invalid. + */ diff --git a/src/triggers.js b/src/triggers.js index a806d095c3..19e8a20536 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -25,7 +25,10 @@ const FileClassName = '@File'; const ConnectClassName = '@Connect'; const baseStore = function () { - const Validators = {}; + const Validators = Object.keys(Types).reduce(function (base, key) { + base[key] = {}; + return base; + }, {}); const Functions = {}; const Jobs = {}; const LiveQuery = []; @@ -132,17 +135,51 @@ export function addJob(jobName, handler, applicationId) { add(Category.Jobs, jobName, handler, applicationId); } -export function addTrigger(type, className, handler, applicationId) { +export function addTrigger( + type, + className, + handler, + applicationId, + validationHandler +) { validateClassNameForTriggers(className, type); add(Category.Triggers, `${type}.${className}`, handler, applicationId); + add( + Category.Validators, + `${type}.${className}`, + validationHandler, + applicationId + ); } -export function addFileTrigger(type, handler, applicationId) { +export function addFileTrigger( + type, + handler, + applicationId, + validationHandler +) { add(Category.Triggers, `${type}.${FileClassName}`, handler, applicationId); + add( + Category.Validators, + `${type}.${FileClassName}`, + validationHandler, + applicationId + ); } -export function addConnectTrigger(type, handler, applicationId) { +export function addConnectTrigger( + type, + handler, + applicationId, + validationHandler +) { add(Category.Triggers, `${type}.${ConnectClassName}`, handler, applicationId); + add( + Category.Validators, + `${type}.${ConnectClassName}`, + validationHandler, + applicationId + ); } export function addLiveQueryEventHandler(handler, applicationId) { @@ -455,6 +492,9 @@ export function maybeRunAfterFindTrigger( return Parse.Object.fromJSON(object); }); return Promise.resolve() + .then(() => { + return maybeRunValidator(request, `${triggerType}.${className}`); + }) .then(() => { const response = trigger(request); if (response && typeof response.then === 'function') { @@ -514,6 +554,9 @@ export function maybeRunQueryTrigger( isGet ); return Promise.resolve() + .then(() => { + return maybeRunValidator(requestObject, `${triggerType}.${className}`); + }) .then(() => { return trigger(requestObject); }) @@ -588,6 +631,184 @@ export function maybeRunQueryTrigger( ); } +export function resolveError(message, defaultOpts) { + if (!defaultOpts) { + defaultOpts = {}; + } + if (!message) { + return new Parse.Error( + defaultOpts.code || Parse.Error.SCRIPT_FAILED, + defaultOpts.message || 'Script failed.' + ); + } + if (message instanceof Parse.Error) { + return message; + } + + const code = defaultOpts.code || Parse.Error.SCRIPT_FAILED; + // If it's an error, mark it as a script failed + if (typeof message === 'string') { + return new Parse.Error(code, message); + } + const error = new Parse.Error(code, message.message || message); + if (message instanceof Error) { + error.stack = message.stack; + } + return error; +} +export function maybeRunValidator(request, functionName) { + const theValidator = getValidator(functionName, Parse.applicationId); + if (!theValidator) { + return; + } + return new Promise((resolve, reject) => { + return Promise.resolve() + .then(() => { + return typeof theValidator === 'object' + ? builtInTriggerValidator(theValidator, request) + : theValidator(request); + }) + .then(() => { + resolve(); + }) + .catch(e => { + const error = resolveError(e, { + code: Parse.Error.VALIDATION_ERROR, + message: 'Validation failed.', + }); + reject(error); + }); + }); +} +function builtInTriggerValidator(options, request) { + if (request.master && !options.validateMasterKey) { + return; + } + let reqUser = request.user; + if ( + !reqUser && + request.object && + request.object.className === '_User' && + !request.object.existed() + ) { + reqUser = request.object; + } + if (options.requireUser && !reqUser) { + throw 'Validation failed. Please login to continue.'; + } + if (options.requireMaster && !request.master) { + throw 'Validation failed. Master key is required to complete this request.'; + } + let params = request.params || {}; + if (request.object) { + params = request.object.toJSON(); + } + const requiredParam = key => { + const value = params[key]; + if (value == null) { + throw `Validation failed. Please specify data for ${key}.`; + } + }; + + const validateOptions = (opt, key, val) => { + let opts = opt.options; + if (typeof opts === 'function') { + try { + const result = opts(val); + if (!result && result != null) { + throw opt.error || `Validation failed. Invalid value for ${key}.`; + } + } catch (e) { + if (!e) { + throw opt.error || `Validation failed. Invalid value for ${key}.`; + } + + throw opt.error || e.message || e; + } + return; + } + if (!Array.isArray(opts)) { + opts = [opt.options]; + } + + if (!opts.includes(val)) { + throw ( + opt.error || + `Validation failed. Invalid option for ${key}. Expected: ${opts.join( + ', ' + )}` + ); + } + }; + + const getType = fn => { + const match = fn && fn.toString().match(/^\s*function (\w+)/); + return (match ? match[1] : '').toLowerCase(); + }; + if (Array.isArray(options.fields)) { + for (const key of options.fields) { + requiredParam(key); + } + } else { + for (const key in options.fields) { + const opt = options.fields[key]; + let val = params[key]; + if (typeof opt === 'string') { + requiredParam(opt); + } + if (typeof opt === 'object') { + if (opt.default != null && val == null) { + val = opt.default; + params[key] = val; + if (request.object) { + request.object.set(key, val); + } + } + if (opt.constant && request.object) { + if (request.original) { + request.object.set(key, request.original.get(key)); + } else if (opt.default != null) { + request.object.set(key, opt.default); + } + } + if (opt.required) { + requiredParam(key); + } + if (opt.type) { + const type = getType(opt.type); + if (type == 'array' && !Array.isArray(val)) { + throw `Validation failed. Invalid type for ${key}. Expected: array`; + } else if (typeof val !== type) { + throw `Validation failed. Invalid type for ${key}. Expected: ${type}`; + } + } + if (opt.options) { + validateOptions(opt, key, val); + } + } + } + } + const userKeys = options.requireUserKeys || []; + if (Array.isArray(userKeys)) { + for (const key of userKeys) { + if (!reqUser) { + throw 'Please login to make this request.'; + } + + if (reqUser.get(key) == null) { + throw `Validation failed. Please set data for ${key} on your account.`; + } + } + } else if (typeof userKeys === 'object') { + for (const key in options.requireUserKeys) { + const opt = options.requireUserKeys[key]; + if (opt.options) { + validateOptions(opt, key, reqUser.get(key)); + } + } + } +} + // To be used as part of the promise chain when saving/deleting an object // Will resolve successfully if no trigger is configured // Resolves to an object, empty or containing an object key. A beforeSave @@ -657,6 +878,12 @@ export function maybeRunTrigger( // If triggers do not return a promise, they can run async code parallel // to the RestWrite.execute() call. return Promise.resolve() + .then(() => { + return maybeRunValidator( + request, + `${triggerType}.${parseObject.className}` + ); + }) .then(() => { const promise = trigger(request); if ( @@ -755,6 +982,7 @@ export async function maybeRunFileTrigger( fileObject, config ); + await maybeRunValidator(request, `${triggerType}.${FileClassName}`); const result = await fileTrigger(request); logTriggerSuccessBeforeHook( triggerType, @@ -788,6 +1016,7 @@ export async function maybeRunConnectTrigger(triggerType, request) { return; } request.user = await userForSessionToken(request.sessionToken); + await maybeRunValidator(request, `${triggerType}.${ConnectClassName}`); return trigger(request); } @@ -804,6 +1033,7 @@ export async function maybeRunSubscribeTrigger( parseQuery.withJSON(request.query); request.query = parseQuery; request.user = await userForSessionToken(request.sessionToken); + await maybeRunValidator(request, `${triggerType}.${className}`); await trigger(request); const query = request.query.toJSON(); if (query.keys) { @@ -828,6 +1058,7 @@ export async function maybeRunAfterEventTrigger( request.original = Parse.Object.fromJSON(request.original); } request.user = await userForSessionToken(request.sessionToken); + await maybeRunValidator(request, `${triggerType}.${className}`); return trigger(request); }