diff --git a/spec/AudienceRouter.spec.js b/spec/AudienceRouter.spec.js new file mode 100644 index 00000000..dc9363b5 --- /dev/null +++ b/spec/AudienceRouter.spec.js @@ -0,0 +1,287 @@ +var auth = require('../src/Auth'); +var Config = require('../src/Config'); +var rest = require('../src/rest'); +var AudiencesRouter = require('../src/Routers/AudiencesRouter').AudiencesRouter; + +describe('AudiencesRouter', () => { + it('uses find condition from request.body', (done) => { + var config = new Config('test'); + var androidAudienceRequest = { + 'name': 'Android Users', + 'query': '{ "test": "android" }' + }; + var iosAudienceRequest = { + 'name': 'Iphone Users', + 'query': '{ "test": "ios" }' + }; + var request = { + config: config, + auth: auth.master(config), + body: { + where: { + query: '{ "test": "android" }' + } + }, + query: {}, + info: {} + }; + + var router = new AudiencesRouter(); + rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then((res) => { + var results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch((err) => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('uses find condition from request.query', (done) => { + var config = new Config('test'); + var androidAudienceRequest = { + 'name': 'Android Users', + 'query': '{ "test": "android" }' + }; + var iosAudienceRequest = { + 'name': 'Iphone Users', + 'query': '{ "test": "ios" }' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + where: { + 'query': '{ "test": "android" }' + } + }, + info: {} + }; + + var router = new AudiencesRouter(); + rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then((res) => { + var results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch((err) => { + fail(err); + done(); + }); + }); + + it('query installations with limit = 0', (done) => { + var config = new Config('test'); + var androidAudienceRequest = { + 'name': 'Android Users', + 'query': '{ "test": "android" }' + }; + var iosAudienceRequest = { + 'name': 'Iphone Users', + 'query': '{ "test": "ios" }' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0 + }, + info: {} + }; + + new Config('test'); + var router = new AudiencesRouter(); + rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then((res) => { + var response = res.response; + expect(response.results.length).toEqual(0); + done(); + }) + .catch((err) => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('query installations with count = 1', done => { + var config = new Config('test'); + var androidAudienceRequest = { + 'name': 'Android Users', + 'query': '{ "test": "android" }' + }; + var iosAudienceRequest = { + 'name': 'Iphone Users', + 'query': '{ "test": "ios" }' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + count: 1 + }, + info: {} + }; + + var router = new AudiencesRouter(); + rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest)) + .then(() => router.handleFind(request)) + .then((res) => { + var response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }) + }); + + it('query installations with limit = 0 and count = 1', (done) => { + var config = new Config('test'); + var androidAudienceRequest = { + 'name': 'Android Users', + 'query': '{ "test": "android" }' + }; + var iosAudienceRequest = { + 'name': 'Iphone Users', + 'query': '{ "test": "ios" }' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0, + count: 1 + }, + info: {} + }; + + var router = new AudiencesRouter(); + rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then((res) => { + var response = res.response; + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }) + .catch((err) => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should create, read, update and delete audiences throw api', (done) => { + Parse._request('POST', 'push_audiences', { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' })}, { useMasterKey: true }) + .then(() => { + Parse._request('GET', 'push_audiences', {}, { useMasterKey: true }).then((results) => { + expect(results.results.length).toEqual(1); + expect(results.results[0].name).toEqual('My Audience'); + expect(results.results[0].query.deviceType).toEqual('ios'); + Parse._request('GET', `push_audiences/${results.results[0].objectId}`, {}, { useMasterKey: true }).then((results) => { + expect(results.name).toEqual('My Audience'); + expect(results.query.deviceType).toEqual('ios'); + Parse._request('PUT', `push_audiences/${results.objectId}`, { name: 'My Audience 2' }, { useMasterKey: true }).then(() => { + Parse._request('GET', `push_audiences/${results.objectId}`, {}, { useMasterKey: true }).then((results) => { + expect(results.name).toEqual('My Audience 2'); + expect(results.query.deviceType).toEqual('ios'); + Parse._request('DELETE', `push_audiences/${results.objectId}`, {}, { useMasterKey: true }).then(() => { + Parse._request('GET', 'push_audiences', {}, { useMasterKey: true }).then((results) => { + expect(results.results.length).toEqual(0); + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + it('should only create with master key', (done) => { + Parse._request('POST', 'push_audiences', { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' })}) + .then( + () => {}, + (error) => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); + + it('should only find with master key', (done) => { + Parse._request('GET', 'push_audiences', {}) + .then( + () => {}, + (error) => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); + + it('should only get with master key', (done) => { + Parse._request('GET', `push_audiences/someId`, {}) + .then( + () => {}, + (error) => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); + + it('should only update with master key', (done) => { + Parse._request('PUT', `push_audiences/someId`, { name: 'My Audience 2' }) + .then( + () => {}, + (error) => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); + + it('should only delete with master key', (done) => { + Parse._request('DELETE', `push_audiences/someId`, {}) + .then( + () => {}, + (error) => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); +}); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index d79e87ab..62ae35ac 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -915,15 +915,15 @@ describe('Parse.Query testing', () => { it("order by descending number and string, with space", function(done) { var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); + var makeBoxedNumber = function (num, i) { + return new BoxedNumber({number: num, string: strings[i]}); }; Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( - function() { + function () { var query = new Parse.Query(BoxedNumber); query.descending("number, string"); query.find(expectSuccess({ - success: function(results) { + success: function (results) { equal(results.length, 4); equal(results[0].get("number"), 3); equal(results[0].get("string"), "c"); @@ -936,7 +936,8 @@ describe('Parse.Query testing', () => { done(); } })); - }, (err) => { + }, + (err) => { jfail(err); done(); }); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index a461232a..35fa473c 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -175,28 +175,28 @@ describe('rest query', () => { const p0 = rp.get({ headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' - + querystring.stringify({ - where: '{"foo":{"$ne": "baz"}}', - limit: 1 - }).replace('=', '%3D'), - }).then(fail, (response) => { - const error = response.error; - var b = JSON.parse(error); - expect(b.code).toEqual(Parse.Error.INVALID_QUERY); - }); + url: 'http://localhost:8378/1/classes/TestParameterEncode?' + querystring.stringify({ + where: '{"foo":{"$ne": "baz"}}', + limit: 1 + }).replace('=', '%3D'), + }) + .then(fail, (response) => { + const error = response.error; + var b = JSON.parse(error); + expect(b.code).toEqual(Parse.Error.INVALID_QUERY); + }); const p1 = rp.get({ headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' - + querystring.stringify({ - limit: 1 - }).replace('=', '%3D'), - }).then(fail, (response) => { - const error = response.error; - var b = JSON.parse(error); - expect(b.code).toEqual(Parse.Error.INVALID_QUERY); - }); + url: 'http://localhost:8378/1/classes/TestParameterEncode?' + querystring.stringify({ + limit: 1 + }).replace('=', '%3D'), + }) + .then(fail, (response) => { + const error = response.error; + var b = JSON.parse(error); + expect(b.code).toEqual(Parse.Error.INVALID_QUERY); + }); return Promise.all([p0, p1]); }).then(done).catch((err) => { jfail(err); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 4858967a..2489b3ce 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -667,11 +667,11 @@ export class PostgresStorageAdapter { const joins = results.reduce((list, schema) => { return list.concat(joinTablesForSchema(schema.schema)); }, []); - const classes = ['_SCHEMA','_PushStatus','_JobStatus','_JobSchedule','_Hooks','_GlobalConfig', ...results.map(result => result.className), ...joins]; - return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE IF EXISTS $', { className })))); + const classes = ['_SCHEMA', '_PushStatus', '_JobStatus', '_JobSchedule', '_Hooks', '_GlobalConfig', '_Audience', ...results.map(result => result.className), ...joins]; + return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE IF EXISTS $', {className})))); }, error => { if (error.code === PostgresRelationDoesNotExistError) { - // No _SCHEMA collection. Don't delete anything. + // No _SCHEMA collection. Don't delete anything. return; } else { throw error; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 595b4876..81b37162 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -690,11 +690,12 @@ DatabaseController.prototype.reduceRelationKeys = function(className, query) { return this.relatedIds( relatedTo.object.className, relatedTo.key, - relatedTo.object.objectId).then((ids) => { - delete query['$relatedTo']; - this.addInObjectIdsIds(ids, query); - return this.reduceRelationKeys(className, query); - }); + relatedTo.object.objectId) + .then((ids) => { + delete query['$relatedTo']; + this.addInObjectIdsIds(ids, query); + return this.reduceRelationKeys(className, query); + }); } }; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 59b306a2..643febce 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -114,6 +114,11 @@ const defaultColumns = Object.freeze({ _GlobalConfig: { "objectId": {type: 'String'}, "params": {type: 'Object'} + }, + _Audience: { + "objectId": {type:'String'}, + "name": {type:'String'}, + "query": {type:'String'} //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error } }); @@ -122,9 +127,9 @@ const requiredColumns = Object.freeze({ _Role: ["name", "ACL"] }); -const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule']); +const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule', '_Audience']); -const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule']); +const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule', '_Audience']); // 10 alpha numberic chars + uppercase const userIdRegex = /^[a-zA-Z0-9]{10}$/; @@ -306,7 +311,11 @@ const _JobScheduleSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ fields: {}, classLevelPermissions: {} })); -const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema]; +const _AudienceSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ + className: "_Audience", + fields: defaultColumns._Audience +})); +const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema, _AudienceSchema]; const dbTypeMatchesObjectType = (dbType, objectType) => { if (dbType.type !== objectType.type) return false; diff --git a/src/ParseServer.js b/src/ParseServer.js index 282dc289..fa8ddd1d 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -49,6 +49,7 @@ import { SessionsRouter } from './Routers/SessionsRouter'; import { UserController } from './Controllers/UserController'; import { UsersRouter } from './Routers/UsersRouter'; import { PurgeRouter } from './Routers/PurgeRouter'; +import { AudiencesRouter } from './Routers/AudiencesRouter'; import DatabaseController from './Controllers/DatabaseController'; import SchemaCache from './Controllers/SchemaCache'; @@ -379,7 +380,8 @@ class ParseServer { new GlobalConfigRouter(), new PurgeRouter(), new HooksRouter(), - new CloudCodeRouter() + new CloudCodeRouter(), + new AudiencesRouter() ]; const routes = routers.reduce((memo, router) => { diff --git a/src/RestQuery.js b/src/RestQuery.js index 82697fec..5acb19d8 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -197,11 +197,11 @@ RestQuery.prototype.redirectClassNameForKey = function() { } // We need to change the class name based on the schema - return this.config.database.redirectClassNameForKey( - this.className, this.redirectKey).then((newClassName) => { - this.className = newClassName; - this.redirectClassName = newClassName; - }); + return this.config.database.redirectClassNameForKey(this.className, this.redirectKey) + .then((newClassName) => { + this.className = newClassName; + this.redirectClassName = newClassName; + }); }; // Validates this operation against the allowClientClassCreation config. @@ -491,24 +491,24 @@ RestQuery.prototype.runFind = function(options = {}) { if (options.op) { findOptions.op = options.op; } - return this.config.database.find( - this.className, this.restWhere, findOptions).then((results) => { - if (this.className === '_User') { - for (var result of results) { - cleanResultOfSensitiveUserInfo(result, this.auth, this.config); - cleanResultAuthData(result); + return this.config.database.find(this.className, this.restWhere, findOptions) + .then((results) => { + if (this.className === '_User') { + for (var result of results) { + cleanResultOfSensitiveUserInfo(result, this.auth, this.config); + cleanResultAuthData(result); + } } - } - this.config.filesController.expandFilesInObject(this.config, results); + this.config.filesController.expandFilesInObject(this.config, results); - if (this.redirectClassName) { - for (var r of results) { - r.className = this.redirectClassName; + if (this.redirectClassName) { + for (var r of results) { + r.className = this.redirectClassName; + } } - } - this.response = {results: results}; - }); + this.response = {results: results}; + }); }; // Returns a promise for whether it was successful. @@ -520,10 +520,10 @@ RestQuery.prototype.runCount = function() { this.findOptions.count = true; delete this.findOptions.skip; delete this.findOptions.limit; - return this.config.database.find( - this.className, this.restWhere, this.findOptions).then((c) => { - this.response.count = c; - }); + return this.config.database.find(this.className, this.restWhere, this.findOptions) + .then((c) => { + this.response.count = c; + }); }; // Augments this.response with data at the paths provided in this.include. diff --git a/src/Routers/AudiencesRouter.js b/src/Routers/AudiencesRouter.js new file mode 100644 index 00000000..347abc11 --- /dev/null +++ b/src/Routers/AudiencesRouter.js @@ -0,0 +1,71 @@ +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import * as middleware from '../middlewares'; + +export class AudiencesRouter extends ClassesRouter { + handleFind(req) { + const body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); + var options = {}; + + if (body.skip) { + options.skip = Number(body.skip); + } + if (body.limit || body.limit === 0) { + options.limit = Number(body.limit); + } + if (body.order) { + options.order = String(body.order); + } + if (body.count) { + options.count = true; + } + if (body.include) { + options.include = String(body.include); + } + + return rest.find(req.config, req.auth, '_Audience', body.where, options, req.info.clientSDK) + .then((response) => { + + response.results.forEach((item) => { + item.query = JSON.parse(item.query); + }); + + return {response: response}; + }); + } + + handleGet(req) { + req.params.className = '_Audience'; + return super.handleGet(req) + .then((data) => { + data.response.query = JSON.parse(data.response.query); + + return data; + }); + } + + handleCreate(req) { + req.params.className = '_Audience'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Audience'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Audience'; + return super.handleDelete(req); + } + + mountRoutes() { + this.route('GET','/push_audiences', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleFind(req); }); + this.route('GET','/push_audiences/:objectId', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleGet(req); }); + this.route('POST','/push_audiences', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleCreate(req); }); + this.route('PUT','/push_audiences/:objectId', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleUpdate(req); }); + this.route('DELETE','/push_audiences/:objectId', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleDelete(req); }); + } +} + +export default AudiencesRouter; diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index a82488ab..38096250 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -32,7 +32,7 @@ export class FeaturesRouter extends PromiseRouter { immediatePush: req.config.hasPushSupport, scheduledPush: req.config.hasPushScheduledSupport, storedPushData: req.config.hasPushSupport, - pushAudiences: false, + pushAudiences: true, }, schemas: { addField: true, diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index efe1d6fd..ead8c28b 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -128,26 +128,29 @@ export class FunctionsRouter extends PromiseRouter { var response = FunctionsRouter.createResponseObject((result) => { try { const cleanResult = logger.truncateLogMessage(JSON.stringify(result.response.result)); - logger.info(`Ran cloud function ${functionName} for user ${userString} ` - + `with:\n Input: ${cleanInput }\n Result: ${cleanResult }`, { - functionName, - params, - user: userString, - }); + logger.info( + `Ran cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput }\n Result: ${cleanResult }`, + { + functionName, + params, + user: userString, + } + ); resolve(result); } catch (e) { reject(e); } }, (error) => { try { - logger.error(`Failed running cloud function ${functionName} for ` - + `user ${userString} with:\n Input: ${cleanInput}\n Error: ` - + JSON.stringify(error), { - functionName, - error, - params, - user: userString - }); + logger.error( + `Failed running cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Error: ` + JSON.stringify(error), + { + functionName, + error, + params, + user: userString + } + ); reject(error); } catch (e) { reject(e);