diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 0fb61964..10ef133f 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,10 +1,10 @@ Make sure these boxes are checked before submitting your issue -- thanks for reporting issues back to Parse Server! --[ ] You've met the [prerequisites](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites). +- [ ] You've met the [prerequisites](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites). --[ ] You're running the [latest version](https://github.com/ParsePlatform/parse-server/releases) of Parse Server. +- [ ] You're running the [latest version](https://github.com/ParsePlatform/parse-server/releases) of Parse Server. --[ ] You've searched through [existing issues](https://github.com/ParsePlatform/parse-server/issues?utf8=%E2%9C%93&q=). Chances are that your issue has been reported or resolved before. +- [ ] You've searched through [existing issues](https://github.com/ParsePlatform/parse-server/issues?utf8=%E2%9C%93&q=). Chances are that your issue has been reported or resolved before. #### Environment Setup diff --git a/.travis.yml b/.travis.yml index 53dc9acc..2e85f31e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ node_js: - "4.3" env: global: - - CODE_COVERAGE=1 + - COVERAGE_OPTION='./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**' matrix: - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 diff --git a/README.md b/README.md index 84538c4f..b0a8d015 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,20 @@ PARSE_SERVER_MAX_UPLOAD_SIZE ``` +##### Configuring S3 Adapter + +You can use the following environment variable setup the S3 adapter + +```js +S3_ACCESS_KEY +S3_SECRET_KEY +S3_BUCKET +S3_REGION +S3_BUCKET_PREFIX +S3_DIRECT_ACCESS + +``` + ## Contributing We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Server guide](CONTRIBUTING.md). diff --git a/bin/parse-server b/bin/parse-server index d3ade1e8..94ffe964 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,2 +1 @@ -#!/usr/bin/env node require("../lib/cli/parse-server"); diff --git a/package.json b/package.json index b20b1432..89ceb754 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "files": [ "bin/", "lib/", + "public_html/", + "views/", "LICENSE", "PATENTS", "README.md" @@ -54,12 +56,13 @@ "nodemon": "^1.8.1" }, "scripts": { - "dev": "npm run build && bin/dev", + "dev": "npm run build && node bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $(if [ \"$CODE_COVERAGE\" = \"1\" ]; then echo './node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**'; fi;) ./node_modules/jasmine/bin/jasmine.js", - "posttest": "mongodb-runner stop", - "start": "./bin/parse-server", + "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $COVERAGE_OPTION ./node_modules/jasmine/bin/jasmine.js", + "posttest": "./node_modules/.bin/mongodb-runner stop", + "coverage": "cross-env COVERAGE_OPTION='./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**' npm test", + "start": "node ./bin/parse-server", "prepublish": "npm run build" }, "engines": { diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index f32867e0..69381fc5 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -1,6 +1,8 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; +var ParsePushAdapter = require("../src/Adapters/Push/ParsePushAdapter"); +var S3Adapter = require("../src/Adapters/Files/S3Adapter").default; describe("AdapterLoader", ()=>{ @@ -84,4 +86,27 @@ describe("AdapterLoader", ()=>{ }).not.toThrow("foo is required for that adapter"); done(); }); + + it("should load push adapter from options", (done) => { + var options = { + ios: { + bundleId: 'bundle.id' + } + } + expect(() => { + var adapter = loadAdapter(undefined, ParsePushAdapter, options); + expect(adapter.constructor).toBe(ParsePushAdapter); + expect(adapter).not.toBe(undefined); + }).not.toThrow(); + done(); + }); + + it("should load S3Adapter from direct passing", (done) => { + var s3Adapter = new S3Adapter("key", "secret", "bucket") + expect(() => { + var adapter = loadAdapter(s3Adapter, FilesAdapter); + expect(adapter).toBe(s3Adapter); + }).not.toThrow(); + done(); + }) }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 42ac3491..b4f7f8e0 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -967,6 +967,23 @@ describe('miscellaneous', function() { }); }); + it('beforeSave change propagates through the save response', (done) => { + Parse.Cloud.beforeSave('ChangingObject', function(request, response) { + request.object.set('foo', 'baz'); + response.success(); + }); + let obj = new Parse.Object('ChangingObject'); + obj.save({ foo: 'bar' }).then((objAgain) => { + expect(objAgain.get('foo')).toEqual('baz'); + Parse.Cloud._removeHook("Triggers", "beforeSave", "ChangingObject"); + done(); + }, (e) => { + Parse.Cloud._removeHook("Triggers", "beforeSave", "ChangingObject"); + fail('Should not have failed to save.'); + done(); + }); + }); + it('dedupes an installation properly and returns updatedAt', (done) => { let headers = { 'Content-Type': 'application/json', @@ -995,4 +1012,32 @@ describe('miscellaneous', function() { }); }); + it('android login providing empty authData block works', (done) => { + let headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + let data = { + username: 'pulse1989', + password: 'password1234', + authData: {} + }; + let requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify(data) + }; + request.post(requestOptions, (error, response, body) => { + expect(error).toBe(null); + requestOptions.url = 'http://localhost:8378/1/login'; + request.get(requestOptions, (error, response, body) => { + expect(error).toBe(null); + let b = JSON.parse(body); + expect(typeof b['sessionToken']).toEqual('string'); + done(); + }); + }); + }); + }); diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 8b4f989f..1b7fbcca 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -1,4 +1,4 @@ - +"use strict"; // Roles are not accessible without the master key, so they are not intended // for use by clients. We can manually test them using the master key. @@ -64,26 +64,30 @@ describe('Parse Role testing', () => { var rolesNames = ["FooRole", "BarRole", "BazRole"]; - var createRole = function(name, parent, user) { + var createRole = function(name, sibling, user) { var role = new Parse.Role(name, new Parse.ACL()); if (user) { var users = role.relation('users'); users.add(user); } - if (parent) { - role.relation('roles').add(parent); + if (sibling) { + role.relation('roles').add(sibling); } return role.save({}, { useMasterKey: true }); } var roleIds = {}; createTestUser().then( (user) => { - - return createRole(rolesNames[0], null, null).then( (aRole) => { + // Put the user on the 1st role + return createRole(rolesNames[0], null, user).then( (aRole) => { roleIds[aRole.get("name")] = aRole.id; + // set the 1st role as a sibling of the second + // user will should have 2 role now return createRole(rolesNames[1], aRole, null); }).then( (anotherRole) => { roleIds[anotherRole.get("name")] = anotherRole.id; - return createRole(rolesNames[2], anotherRole, user); + // set this role as a sibling of the last + // the user should now have 3 roles + return createRole(rolesNames[2], anotherRole, null); }).then( (lastRole) => { roleIds[lastRole.get("name")] = lastRole.id; var auth = new Auth({ config: new Config("test"), isMaster: true, user: user }); @@ -118,6 +122,80 @@ describe('Parse Role testing', () => { }); }); }); + + it("Should properly resolve roles", (done) => { + let admin = new Parse.Role("Admin", new Parse.ACL()); + let moderator = new Parse.Role("Moderator", new Parse.ACL()); + let superModerator = new Parse.Role("SuperModerator", new Parse.ACL()); + let contentManager = new Parse.Role('ContentManager', new Parse.ACL()); + let superContentManager = new Parse.Role('SuperContentManager', new Parse.ACL()); + Parse.Object.saveAll([admin, moderator, contentManager, superModerator, superContentManager], {useMasterKey: true}).then(() => { + contentManager.getRoles().add([moderator, superContentManager]); + moderator.getRoles().add([admin, superModerator]); + superContentManager.getRoles().add(superModerator); + return Parse.Object.saveAll([admin, moderator, contentManager, superModerator, superContentManager], {useMasterKey: true}); + }).then(() => { + var auth = new Auth({ config: new Config("test"), isMaster: true }); + // For each role, fetch their sibling, what they inherit + // return with result and roleId for later comparison + let promises = [admin, moderator, contentManager, superModerator].map((role) => { + return auth._getAllRoleNamesForId(role.id).then((result) => { + return Parse.Promise.as({ + id: role.id, + name: role.get('name'), + roleIds: result + }); + }) + }); + + return Parse.Promise.when(promises); + }).then((results) => { + results.forEach((result) => { + let id = result.id; + let roleIds = result.roleIds; + if (id == admin.id) { + expect(roleIds.length).toBe(2); + expect(roleIds.indexOf(moderator.id)).not.toBe(-1); + expect(roleIds.indexOf(contentManager.id)).not.toBe(-1); + } else if (id == moderator.id) { + expect(roleIds.length).toBe(1); + expect(roleIds.indexOf(contentManager.id)).toBe(0); + } else if (id == contentManager.id) { + expect(roleIds.length).toBe(0); + } else if (id == superModerator.id) { + expect(roleIds.length).toBe(3); + expect(roleIds.indexOf(moderator.id)).not.toBe(-1); + expect(roleIds.indexOf(contentManager.id)).not.toBe(-1); + expect(roleIds.indexOf(superContentManager.id)).not.toBe(-1); + } + }); + done(); + }).fail((err) => { + console.error(err); + done(); + }) + + }); + + it('can create role and query empty users', (done)=> { + var roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + var role = new Parse.Role('subscribers', roleACL); + role.save({}, {useMasterKey : true}) + .then((x)=>{ + var query = role.relation('users').query(); + query.find({useMasterKey : true}) + .then((users)=>{ + done(); + }, (e)=>{ + fail('should not have errors'); + done(); + }); + }, (e) => { + console.log(e); + fail('should not have errored'); + }); + }); }); diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index cddfd598..f9b94b37 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -175,17 +175,26 @@ describe('rest create', () => { } } }; + var newUserSignedUpByFacebookObjectId; rest.create(config, auth.nobody(config), '_User', data) .then((r) => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); + newUserSignedUpByFacebookObjectId = r.response.objectId; return rest.create(config, auth.nobody(config), '_User', data); }).then((r) => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.username).toEqual('string'); expect(typeof r.response.updatedAt).toEqual('string'); + expect(r.response.objectId).toEqual(newUserSignedUpByFacebookObjectId); + return rest.find(config, auth.master(config), + '_Session', {sessionToken: r.response.sessionToken}); + }).then((response) => { + expect(response.results.length).toEqual(1); + var output = response.results[0]; + expect(output.user.objectId).toEqual(newUserSignedUpByFacebookObjectId); done(); }); }); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index b5613c1d..6ab08d6a 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -180,20 +180,18 @@ describe('Schema', () => { it('will fail to create a class if that class was already created by an object', done => { config.database.loadSchema() - .then(schema => { - schema.validateObject('NewClass', {foo: 7}) - .then(() => { - schema.reload() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'String'} - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.message).toEqual('Class NewClass already exists.'); - done(); - }); + .then(schema => { + schema.validateObject('NewClass', { foo: 7 }) + .then(() => schema.reloadData()) + .then(() => schema.addClassIfNotExists('NewClass', { + foo: { type: 'String' } + })) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NewClass already exists.'); + done(); + }); }); - }) }); it('will resolve class creation races appropriately', done => { @@ -579,6 +577,38 @@ describe('Schema', () => { }); }); + it('can delete relation field when related _Join collection not exist', done => { + config.database.loadSchema() + .then(schema => { + schema.addClassIfNotExists('NewClass', { + relationField: {type: 'Relation', targetClass: '_User'} + }) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: 'NewClass', + objectId: 'string', + updatedAt: 'string', + createdAt: 'string', + relationField: 'relation<_User>', + }); + }) + .then(() => config.database.collectionExists('_Join:relationField:NewClass')) + .then(exist => { + expect(exist).toEqual(false); + }) + .then(() => schema.deleteField('relationField', 'NewClass', config.database)) + .then(() => schema.reloadData()) + .then(() => { + expect(schema['data']['NewClass']).toEqual({ + objectId: 'string', + updatedAt: 'string', + createdAt: 'string' + }); + done(); + }); + }); + }); + it('can delete string fields and resave as number field', done => { Parse.Object.disableSingleInstance(); var obj1 = hasAllPODobject(); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 63843e57..9a410eed 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -561,6 +561,63 @@ describe('schemas', () => { }) }); + it('lets you add fields to system schema', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true + }, (error, response, body) => { + request.put({ + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newField: {type: 'String'} + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: '_User', + fields: { + objectId: {type: 'String'}, + updatedAt: {type: 'Date'}, + createdAt: {type: 'Date'}, + username: {type: 'String'}, + password: {type: 'String'}, + authData: {type: 'Object'}, + email: {type: 'String'}, + emailVerified: {type: 'Boolean'}, + newField: {type: 'String'}, + ACL: {type: 'ACL'} + } + }); + request.get({ + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true + }, (error, response, body) => { + expect(body).toEqual({ + className: '_User', + fields: { + objectId: {type: 'String'}, + updatedAt: {type: 'Date'}, + createdAt: {type: 'Date'}, + username: {type: 'String'}, + password: {type: 'String'}, + authData: {type: 'Object'}, + email: {type: 'String'}, + emailVerified: {type: 'Boolean'}, + newField: {type: 'String'}, + ACL: {type: 'ACL'} + } + }); + done(); + }); + }); + }) + }); + it('lets you delete multiple fields and add fields', done => { var obj1 = hasAllPODobject(); obj1.save() diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index 5b46f22d..a9521f0b 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -28,15 +28,8 @@ export function loadAdapter(adapter, defaultAdapter, options) { return loadAdapter(adapter.class, undefined, adapter.options); } else if (adapter.adapter) { return loadAdapter(adapter.adapter, undefined, adapter.options); - } else { - // Try to load the defaultAdapter with the options - // The default adapter should throw if the options are - // incompatible - try { - return loadAdapter(defaultAdapter, undefined, adapter); - } catch (e) {}; } - // return the adapter as is as it's unusable otherwise + // return the adapter as provided return adapter; } diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index e21ef8db..cbdf3f11 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -8,19 +8,20 @@ import requiredParameter from '../../requiredParameter'; const DEFAULT_S3_REGION = "us-east-1"; -function parseS3AdapterOptions(...options) { - if (options.length === 1 && typeof options[0] == "object") { - return options; +function requiredOrFromEnvironment(env, name) { + let environmentVariable = process.env[env]; + if (!environmentVariable) { + requiredParameter(`S3Adapter requires an ${name}`); } - - const additionalOptions = options[3] || {}; - - return { - accessKey: options[0], - secretKey: options[1], - bucket: options[2], - region: additionalOptions.region + return environmentVariable; +} + +function fromEnvironmentOrDefault(env, defaultValue) { + let environmentVariable = process.env[env]; + if (environmentVariable) { + return environmentVariable; } + return defaultValue; } export class S3Adapter extends FilesAdapter { @@ -28,12 +29,12 @@ export class S3Adapter extends FilesAdapter { // Providing AWS access and secret keys is mandatory // Region and bucket will use sane defaults if omitted constructor( - accessKey = requiredParameter('S3Adapter requires an accessKey'), - secretKey = requiredParameter('S3Adapter requires a secretKey'), - bucket, - { region = DEFAULT_S3_REGION, - bucketPrefix = '', - directAccess = false } = {}) { + accessKey = requiredOrFromEnvironment('S3_ACCESS_KEY', 'accessKey'), + secretKey = requiredOrFromEnvironment('S3_SECRET_KEY', 'secretKey'), + bucket = fromEnvironmentOrDefault('S3_BUCKET', undefined), + { region = fromEnvironmentOrDefault('S3_REGION', DEFAULT_S3_REGION), + bucketPrefix = fromEnvironmentOrDefault('S3_BUCKET_PREFIX', ''), + directAccess = fromEnvironmentOrDefault('S3_DIRECT_ACCESS', false) } = {}) { super(); this._region = region; diff --git a/src/Auth.js b/src/Auth.js index 0b285789..b45f93f3 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -139,18 +139,18 @@ Auth.prototype._loadRoles = function() { }; // Given a role object id, get any other roles it is part of -// TODO: Make recursive to support role nesting beyond 1 level deep Auth.prototype._getAllRoleNamesForId = function(roleID) { + + // As per documentation, a Role inherits AnotherRole + // if this Role is in the roles pointer of this AnotherRole + // Let's find all the roles where this role is in a roles relation var rolePointer = { __type: 'Pointer', className: '_Role', objectId: roleID }; var restWhere = { - '$relatedTo': { - key: 'roles', - object: rolePointer - } + 'roles': rolePointer }; var query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); @@ -161,6 +161,10 @@ Auth.prototype._getAllRoleNamesForId = function(roleID) { } var roleIDs = results.map(r => r.objectId); + // we found a list of roles where the roleID + // is referenced in the roles relation, + // Get the roles where those found roles are also + // referenced the same way var parentRolesPromises = roleIDs.map( (roleId) => { return this._getAllRoleNamesForId(roleId); }); @@ -169,14 +173,9 @@ Auth.prototype._getAllRoleNamesForId = function(roleID) { }).then(function(results){ // Flatten let roleIDs = results.reduce( (memo, result) => { - if (typeof result == "object") { - memo = memo.concat(result); - } else { - memo.push(result); - } - return memo; + return memo.concat(result); }, []); - return Promise.resolve(roleIDs); + return Promise.resolve([...new Set(roleIDs)]); }); }; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 683b9be0..98243acb 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -89,7 +89,7 @@ DatabaseController.prototype.loadSchema = function(acceptor = returnsTrue) { DatabaseController.prototype.redirectClassNameForKey = function(className, key) { return this.loadSchema().then((schema) => { var t = schema.getExpectedType(className, key); - var match = t.match(/^relation<(.*)>$/); + var match = t ? t.match(/^relation<(.*)>$/) : false; if (match) { return match[1]; } else { diff --git a/src/RestQuery.js b/src/RestQuery.js index 9a4764a9..1e0f344e 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -396,6 +396,7 @@ 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; diff --git a/src/RestWrite.js b/src/RestWrite.js index a907a61c..72eda1fc 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -164,6 +164,7 @@ RestWrite.prototype.runBeforeTrigger = function() { }).then((response) => { if (response && response.object) { this.data = response.object; + this.storage['changedByTrigger'] = true; // We should delete the objectId for an update write if (this.query && this.query.objectId) { delete this.data.objectId @@ -178,7 +179,11 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function() { this.data.updatedAt = this.updatedAt; if (!this.query) { this.data.createdAt = this.updatedAt; - this.data.objectId = cryptoUtils.newObjectId(); + + // Only assign new objectId if we are creating new object + if (!this.data.objectId) { + this.data.objectId = cryptoUtils.newObjectId(); + } } } return Promise.resolve(); @@ -802,6 +807,9 @@ RestWrite.prototype.runDatabaseOperation = function() { objectId: this.data.objectId, createdAt: this.data.createdAt }; + if (this.storage['changedByTrigger']) { + Object.assign(resp, this.data); + } if (this.storage['token']) { resp.sessionToken = this.storage['token']; } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index e352bd5e..59fef02d 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -85,7 +85,7 @@ function modifySchema(req) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`); } - let existingFields = schema.data[className]; + let existingFields = Object.assign(schema.data[className], {_id: className}); Object.keys(submittedFields).forEach(name => { let field = submittedFields[name]; if (existingFields[name] && field.__op !== 'Delete') { diff --git a/src/Schema.js b/src/Schema.js index 755249bf..0ed55527 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -71,7 +71,6 @@ var defaultColumns = { } }; - var requiredColumns = { _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], _Role: ["name", "ACL"] @@ -168,54 +167,380 @@ function schemaAPITypeToMongoFieldType(type) { // '_id' indicates the className // '_metadata' is ignored for now // Everything else is expected to be a userspace field. -function Schema(collection, mongoSchema) { - this.collection = collection; +class Schema { + collection; + data; + perms; - // this.data[className][fieldName] tells you the type of that field - this.data = {}; - // this.perms[className][operation] tells you the acl-style permissions - this.perms = {}; + constructor(collection) { + this.collection = collection; - for (var obj of mongoSchema) { - var className = null; - var classData = {}; - var permsData = null; - for (var key in obj) { - var value = obj[key]; - switch(key) { - case '_id': - className = value; - break; - case '_metadata': - if (value && value['class_permissions']) { - permsData = value['class_permissions']; - } - break; - default: - classData[key] = value; - } - } - if (className) { - this.data[className] = classData; - if (permsData) { - this.perms[className] = permsData; - } - } + // this.data[className][fieldName] tells you the type of that field + this.data = {}; + // this.perms[className][operation] tells you the acl-style permissions + this.perms = {}; } + + reloadData() { + this.data = {}; + this.perms = {}; + return this.collection.find({}, {}).toArray().then(mongoSchema => { + for (let obj of mongoSchema) { + let className = null; + let classData = {}; + let permsData = null; + Object.keys(obj).forEach(key => { + let value = obj[key]; + switch (key) { + case '_id': + className = value; + break; + case '_metadata': + if (value && value['class_permissions']) { + permsData = value['class_permissions']; + } + break; + default: + classData[key] = value; + } + }); + if (className) { + this.data[className] = classData; + if (permsData) { + this.perms[className] = permsData; + } + } + } + }); + } + + // Create a new class that includes the three default fields. + // ACL is an implicit column that does not get an entry in the + // _SCHEMAS database. Returns a promise that resolves with the + // created schema, in mongo format. + // on success, and rejects with an error on fail. Ensure you + // have authorization (master key, or client class creation + // enabled) before calling this function. + addClassIfNotExists(className, fields) { + if (this.data[className]) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + } + + let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); + if (!mongoObject.result) { + return Promise.reject(mongoObject); + } + + return this.collection.insertOne(mongoObject.result) + .then(result => result.ops[0]) + .catch(error => { + if (error.code === 11000) { //Mongo's duplicate key error + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + } + return Promise.reject(error); + }); + } + + + // Returns whether the schema knows the type of all these keys. + hasKeys(className, keys) { + for (var key of keys) { + if (!this.data[className] || !this.data[className][key]) { + return false; + } + } + return true; + } + + // Returns a promise that resolves successfully to the new schema + // object or fails with a reason. + // If 'freeze' is true, refuse to update the schema. + // WARNING: this function has side-effects, and doesn't actually + // do any validation of the format of the className. You probably + // should use classNameIsValid or addClassIfNotExists or something + // like that instead. TODO: rename or remove this function. + validateClassName(className, freeze) { + if (this.data[className]) { + return Promise.resolve(this); + } + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema is frozen, cannot add: ' + className); + } + // We don't have this class. Update the schema + return this.collection.insert([{_id: className}]).then(() => { + // The schema update succeeded. Reload the schema + return this.reloadData(); + }, () => { + // The schema update failed. This can be okay - it might + // have failed because there's a race condition and a different + // client is making the exact same schema update that we want. + // So just reload the schema. + return this.reloadData(); + }).then(() => { + // Ensure that the schema now validates + return this.validateClassName(className, true); + }, (error) => { + // The schema still doesn't validate. Give up + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema class name does not revalidate'); + }); + } + + // Sets the Class-level permissions for a given className, which must exist. + setPermissions(className, perms) { + var query = {_id: className}; + var update = { + _metadata: { + class_permissions: perms + } + }; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { + // The update succeeded. Reload the schema + return this.reloadData(); + }); + } + + // Returns a promise that resolves successfully to the new schema + // object if the provided className-key-type tuple is valid. + // The className must already be validated. + // If 'freeze' is true, refuse to update the schema for this field. + validateField(className, key, type, freeze) { + // Just to check that the key is valid + transform.transformKey(this, className, key); + + if( key.indexOf(".") > 0 ) { + // subdocument key (x.y) => ok if x is of type 'object' + key = key.split(".")[ 0 ]; + type = 'object'; + } + + var expected = this.data[className][key]; + if (expected) { + expected = (expected === 'map' ? 'object' : expected); + if (expected === type) { + return Promise.resolve(this); + } else { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'schema mismatch for ' + className + '.' + key + + '; expected ' + expected + ' but got ' + type); + } + } + + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema is frozen, cannot add ' + key + ' field'); + } + + // We don't have this field, but if the value is null or undefined, + // we won't update the schema until we get a value with a type. + if (!type) { + return Promise.resolve(this); + } + + if (type === 'geopoint') { + // Make sure there are not other geopoint fields + for (var otherKey in this.data[className]) { + if (this.data[className][otherKey] === 'geopoint') { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class'); + } + } + } + + // We don't have this field. Update the schema. + // Note that we use the $exists guard and $set to avoid race + // conditions in the database. This is important! + var query = {_id: className}; + query[key] = {'$exists': false}; + var update = {}; + update[key] = type; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { + // The update succeeded. Reload the schema + return this.reloadData(); + }, () => { + // The update failed. This can be okay - it might have been a race + // condition where another client updated the schema in the same + // way that we wanted to. So, just reload the schema + return this.reloadData(); + }).then(() => { + // Ensure that the schema now validates + return this.validateField(className, key, type, true); + }, (error) => { + // The schema still doesn't validate. Give up + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema key will not revalidate'); + }); + } + + // Delete a field, and remove that data from all objects. This is intended + // to remove unused fields, if other writers are writing objects that include + // this field, the field may reappear. Returns a Promise that resolves with + // no object on success, or rejects with { code, error } on failure. + // Passing the database and prefix is necessary in order to drop relation collections + // and remove fields from objects. Ideally the database would belong to + // a database adapter and this function would close over it or access it via member. + deleteField(fieldName, className, database) { + if (!classNameIsValid(className)) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); + } + if (!fieldNameIsValid(fieldName)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`); + } + //Don't allow deleting the default fields. + if (!fieldNameIsValidForClass(fieldName, className)) { + throw new Parse.Error(136, `field ${fieldName} cannot be changed`); + } + + return this.reloadData() + .then(() => { + return this.hasClass(className) + .then(hasClass => { + if (!hasClass) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } + if (!this.data[className][fieldName]) { + throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + } + + if (this.data[className][fieldName].startsWith('relation<')) { + //For relations, drop the _Join table + return database.collectionExists(`_Join:${fieldName}:${className}`).then(exist => { + if (exist) { + return database.dropCollection(`_Join:${fieldName}:${className}`); + } + }); + } + + // for non-relations, remove all the data. + // This is necessary to ensure that the data is still gone if they add the same field. + return database.collection(className) + .then(collection => { + var mongoFieldName = this.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName; + return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true }); + }); + }) + // Save the _SCHEMA object + .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})); + }); + } + + // Validates an object provided in REST format. + // Returns a promise that resolves to the new schema if this object is + // valid. + validateObject(className, object, query) { + var geocount = 0; + var promise = this.validateClassName(className); + for (var key in object) { + if (object[key] === undefined) { + continue; + } + var expected = getType(object[key]); + if (expected === 'geopoint') { + geocount++; + } + if (geocount > 1) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class'); + } + if (!expected) { + continue; + } + promise = thenValidateField(promise, className, key, expected); + } + promise = thenValidateRequiredColumns(promise, className, object, query); + return promise; + } + + // Validates that all the properties are set for the object + validateRequiredColumns(className, object, query) { + var columns = requiredColumns[className]; + if (!columns || columns.length == 0) { + return Promise.resolve(this); + } + + var missingColumns = columns.filter(function(column){ + if (query && query.objectId) { + if (object[column] && typeof object[column] === "object") { + // Trying to delete a required column + return object[column].__op == 'Delete'; + } + // Not trying to do anything there + return false; + } + return !object[column] + }); + + if (missingColumns.length > 0) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + missingColumns[0]+' is required.'); + } + + return Promise.resolve(this); + } + + // Validates an operation passes class-level-permissions set in the schema + validatePermission(className, aclGroup, operation) { + if (!this.perms[className] || !this.perms[className][operation]) { + return Promise.resolve(); + } + var perms = this.perms[className][operation]; + // Handle the public scenario quickly + if (perms['*']) { + return Promise.resolve(); + } + // Check permissions against the aclGroup provided (array of userId/roles) + var found = false; + for (var i = 0; i < aclGroup.length && !found; i++) { + if (perms[aclGroup[i]]) { + found = true; + } + } + if (!found) { + // TODO: Verify correct error code + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied for this action.'); + } + }; + + // Returns the expected type for a className+key combination + // or undefined if the schema is not set + getExpectedType(className, key) { + if (this.data && this.data[className]) { + return this.data[className][key]; + } + return undefined; + }; + + // Checks if a given class is in the schema. Needs to load the + // schema first, which is kinda janky. Hopefully we can refactor + // and make this be a regular value. + hasClass(className) { + return this.reloadData().then(() => !!(this.data[className])); + } + + // Helper function to check if a field is a pointer, returns true or false. + isPointer(className, key) { + var expected = this.getExpectedType(className, key); + if (expected && expected.charAt(0) == '*') { + return true; + } + return false; + }; } // Returns a promise for a new Schema. function load(collection) { - return collection.find({}, {}).toArray().then((mongoSchema) => { - return new Schema(collection, mongoSchema); - }); + let schema = new Schema(collection); + return schema.reloadData().then(() => schema); } -// Returns a new, reloaded schema. -Schema.prototype.reload = function() { - return load(this.collection); -}; - // Returns { code, error } if invalid, or { result }, an object // suitable for inserting into _SCHEMA collection, otherwise function mongoSchemaFromFieldsAndClassName(fields, className) { @@ -331,218 +656,6 @@ function buildMergedSchemaObject(mongoObject, putRequest) { return newSchema; } -// Create a new class that includes the three default fields. -// ACL is an implicit column that does not get an entry in the -// _SCHEMAS database. Returns a promise that resolves with the -// created schema, in mongo format. -// on success, and rejects with an error on fail. Ensure you -// have authorization (master key, or client class creation -// enabled) before calling this function. -Schema.prototype.addClassIfNotExists = function(className, fields) { - if (this.data[className]) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); - } - - let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); - if (!mongoObject.result) { - return Promise.reject(mongoObject); - } - - return this.collection.insertOne(mongoObject.result) - .then(result => result.ops[0]) - .catch(error => { - if (error.code === 11000) { //Mongo's duplicate key error - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); - } - return Promise.reject(error); - }); -}; - -// Returns a promise that resolves successfully to the new schema -// object or fails with a reason. -// If 'freeze' is true, refuse to update the schema. -// WARNING: this function has side-effects, and doesn't actually -// do any validation of the format of the className. You probably -// should use classNameIsValid or addClassIfNotExists or something -// like that instead. TODO: rename or remove this function. -Schema.prototype.validateClassName = function(className, freeze) { - if (this.data[className]) { - return Promise.resolve(this); - } - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'schema is frozen, cannot add: ' + className); - } - // We don't have this class. Update the schema - return this.collection.insert([{_id: className}]).then(() => { - // The schema update succeeded. Reload the schema - return this.reload(); - }, () => { - // The schema update failed. This can be okay - it might - // have failed because there's a race condition and a different - // client is making the exact same schema update that we want. - // So just reload the schema. - return this.reload(); - }).then((schema) => { - // Ensure that the schema now validates - return schema.validateClassName(className, true); - }, (error) => { - // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'schema class name does not revalidate'); - }); -}; - -// Returns whether the schema knows the type of all these keys. -Schema.prototype.hasKeys = function(className, keys) { - for (var key of keys) { - if (!this.data[className] || !this.data[className][key]) { - return false; - } - } - return true; -}; - -// Sets the Class-level permissions for a given className, which must -// exist. -Schema.prototype.setPermissions = function(className, perms) { - var query = {_id: className}; - var update = { - _metadata: { - class_permissions: perms - } - }; - update = {'$set': update}; - return this.collection.findAndModify(query, {}, update, {}).then(() => { - // The update succeeded. Reload the schema - return this.reload(); - }); -}; - -// Returns a promise that resolves successfully to the new schema -// object if the provided className-key-type tuple is valid. -// The className must already be validated. -// If 'freeze' is true, refuse to update the schema for this field. -Schema.prototype.validateField = function(className, key, type, freeze) { - // Just to check that the key is valid - transform.transformKey(this, className, key); - - if( key.indexOf(".") > 0 ) { - // subdocument key (x.y) => ok if x is of type 'object' - key = key.split(".")[ 0 ]; - type = 'object'; - } - - var expected = this.data[className][key]; - if (expected) { - expected = (expected === 'map' ? 'object' : expected); - if (expected === type) { - return Promise.resolve(this); - } else { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - 'schema mismatch for ' + className + '.' + key + - '; expected ' + expected + ' but got ' + type); - } - } - - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'schema is frozen, cannot add ' + key + ' field'); - } - - // We don't have this field, but if the value is null or undefined, - // we won't update the schema until we get a value with a type. - if (!type) { - return Promise.resolve(this); - } - - if (type === 'geopoint') { - // Make sure there are not other geopoint fields - for (var otherKey in this.data[className]) { - if (this.data[className][otherKey] === 'geopoint') { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - 'there can only be one geopoint field in a class'); - } - } - } - - // We don't have this field. Update the schema. - // Note that we use the $exists guard and $set to avoid race - // conditions in the database. This is important! - var query = {_id: className}; - query[key] = {'$exists': false}; - var update = {}; - update[key] = type; - update = {'$set': update}; - return this.collection.findAndModify(query, {}, update, {}).then(() => { - // The update succeeded. Reload the schema - return this.reload(); - }, () => { - // The update failed. This can be okay - it might have been a race - // condition where another client updated the schema in the same - // way that we wanted to. So, just reload the schema - return this.reload(); - }).then((schema) => { - // Ensure that the schema now validates - return schema.validateField(className, key, type, true); - }, (error) => { - // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'schema key will not revalidate'); - }); -}; - -// Delete a field, and remove that data from all objects. This is intended -// to remove unused fields, if other writers are writing objects that include -// this field, the field may reappear. Returns a Promise that resolves with -// no object on success, or rejects with { code, error } on failure. - -// Passing the database and prefix is necessary in order to drop relation collections -// and remove fields from objects. Ideally the database would belong to -// a database adapter and this function would close over it or access it via member. -Schema.prototype.deleteField = function(fieldName, className, database) { - if (!classNameIsValid(className)) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); - } - if (!fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`); - } - //Don't allow deleting the default fields. - if (!fieldNameIsValidForClass(fieldName, className)) { - throw new Parse.Error(136, `field ${fieldName} cannot be changed`); - } - - return this.reload() - .then(schema => { - return schema.hasClass(className) - .then(hasClass => { - if (!hasClass) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); - } - if (!schema.data[className][fieldName]) { - throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); - } - - if (schema.data[className][fieldName].startsWith('relation<')) { - //For relations, drop the _Join table - return database.dropCollection(`_Join:${fieldName}:${className}`); - } - - // for non-relations, remove all the data. - // This is necessary to ensure that the data is still gone if they add the same field. - return database.collection(className) - .then(collection => { - var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName; - return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true }); - }); - }) - // Save the _SCHEMA object - .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})); - }); -}; - // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateField(schemaPromise, className, key, type) { @@ -551,34 +664,6 @@ function thenValidateField(schemaPromise, className, key, type) { }); } -// Validates an object provided in REST format. -// Returns a promise that resolves to the new schema if this object is -// valid. -Schema.prototype.validateObject = function(className, object, query) { - var geocount = 0; - var promise = this.validateClassName(className); - for (var key in object) { - if (object[key] === undefined) { - continue; - } - var expected = getType(object[key]); - if (expected === 'geopoint') { - geocount++; - } - if (geocount > 1) { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - 'there can only be one geopoint field in a class'); - } - if (!expected) { - continue; - } - promise = thenValidateField(promise, className, key, expected); - } - promise = thenValidateRequiredColumns(promise, className, object, query); - return promise; -}; - // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateRequiredColumns(schemaPromise, className, object, query) { @@ -587,85 +672,6 @@ function thenValidateRequiredColumns(schemaPromise, className, object, query) { }); } -// Validates that all the properties are set for the object -Schema.prototype.validateRequiredColumns = function(className, object, query) { - - var columns = requiredColumns[className]; - if (!columns || columns.length == 0) { - return Promise.resolve(this); - } - - var missingColumns = columns.filter(function(column){ - if (query && query.objectId) { - if (object[column] && typeof object[column] === "object") { - // Trying to delete a required column - return object[column].__op == 'Delete'; - } - // Not trying to do anything there - return false; - } - return !object[column] - }); - - if (missingColumns.length > 0) { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - missingColumns[0]+' is required.'); - } - - return Promise.resolve(this); -} - - -// Validates an operation passes class-level-permissions set in the schema -Schema.prototype.validatePermission = function(className, aclGroup, operation) { - if (!this.perms[className] || !this.perms[className][operation]) { - return Promise.resolve(); - } - var perms = this.perms[className][operation]; - // Handle the public scenario quickly - if (perms['*']) { - return Promise.resolve(); - } - // Check permissions against the aclGroup provided (array of userId/roles) - var found = false; - for (var i = 0; i < aclGroup.length && !found; i++) { - if (perms[aclGroup[i]]) { - found = true; - } - } - if (!found) { - // TODO: Verify correct error code - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Permission denied for this action.'); - } -}; - -// Returns the expected type for a className+key combination -// or undefined if the schema is not set -Schema.prototype.getExpectedType = function(className, key) { - if (this.data && this.data[className]) { - return this.data[className][key]; - } - return undefined; -}; - -// Checks if a given class is in the schema. Needs to load the -// schema first, which is kinda janky. Hopefully we can refactor -// and make this be a regular value. -Schema.prototype.hasClass = function(className) { - return this.reload().then(newSchema => !!newSchema.data[className]); -} - -// Helper function to check if a field is a pointer, returns true or false. -Schema.prototype.isPointer = function(className, key) { - var expected = this.getExpectedType(className, key); - if (expected && expected.charAt(0) == '*') { - return true; - } - return false; -}; - // Gets the type from a REST API formatted object, where 'type' is // extended past javascript types to include the rest of the Parse // type system. @@ -674,21 +680,21 @@ Schema.prototype.isPointer = function(className, key) { function getType(obj) { var type = typeof obj; switch(type) { - case 'boolean': - case 'string': - case 'number': - return type; - case 'map': - case 'object': - if (!obj) { - return undefined; - } - return getObjectType(obj); - case 'function': - case 'symbol': - case 'undefined': - default: - throw 'bad obj: ' + obj; + case 'boolean': + case 'string': + case 'number': + return type; + case 'map': + case 'object': + if (!obj) { + return undefined; + } + return getObjectType(obj); + case 'function': + case 'symbol': + case 'undefined': + default: + throw 'bad obj: ' + obj; } } @@ -730,27 +736,26 @@ function getObjectType(obj) { } if (obj.__op) { switch(obj.__op) { - case 'Increment': - return 'number'; - case 'Delete': - return null; - case 'Add': - case 'AddUnique': - case 'Remove': - return 'array'; - case 'AddRelation': - case 'RemoveRelation': - return 'relation<' + obj.objects[0].className + '>'; - case 'Batch': - return getObjectType(obj.ops[0]); - default: - throw 'unexpected op: ' + obj.__op; + case 'Increment': + return 'number'; + case 'Delete': + return null; + case 'Add': + case 'AddUnique': + case 'Remove': + return 'array'; + case 'AddRelation': + case 'RemoveRelation': + return 'relation<' + obj.objects[0].className + '>'; + case 'Batch': + return getObjectType(obj.ops[0]); + default: + throw 'unexpected op: ' + obj.__op; } } return 'object'; } - module.exports = { load: load, classNameIsValid: classNameIsValid, diff --git a/src/index.js b/src/index.js index a13d921d..86320b2f 100644 --- a/src/index.js +++ b/src/index.js @@ -134,7 +134,8 @@ function ParseServer({ const filesControllerAdapter = loadAdapter(filesAdapter, () => { return new GridStoreAdapter(databaseURI); }); - const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); + // Pass the push options too as it works with the default + const pushControllerAdapter = loadAdapter(push && push.adapter, ParsePushAdapter, push); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); const emailControllerAdapter = loadAdapter(emailAdapter); // We pass the options and the base class for the adatper, @@ -233,15 +234,18 @@ function ParseServer({ api.use(middlewares.handleParseErrors); - process.on('uncaughtException', (err) => { - if( err.code === "EADDRINUSE" ) { // user-friendly message for this common error - console.log(`Unable to listen on port ${err.port}. The port is already in use.`); - process.exit(0); - } - else { - throw err; - } - }); + //This causes tests to spew some useless warnings, so disable in test + if (!process.env.TESTING) { + process.on('uncaughtException', (err) => { + if( err.code === "EADDRINUSE" ) { // user-friendly message for this common error + console.log(`Unable to listen on port ${err.port}. The port is already in use.`); + process.exit(0); + } + else { + throw err; + } + }); + } hooksController.load(); return api;