diff --git a/.babelrc b/.babelrc index b1ff51d47d..1eb1208aa6 100644 --- a/.babelrc +++ b/.babelrc @@ -1,14 +1,14 @@ { "plugins": [ - "transform-flow-strip-types" + "@babel/plugin-transform-flow-strip-types", + "@babel/plugin-proposal-object-rest-spread" ], "presets": [ - "es2015", - "stage-3", - ["env", { + ["@babel/preset-env", { "targets": { - "node": "4.6" + "node": "8" } }] - ] + ], + "sourceMaps": "inline" } diff --git a/.eslintignore b/.eslintignore index b7c67dd1d3..d7cee1f6c5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,3 @@ lib coverage - +out diff --git a/.eslintrc.json b/.eslintrc.json index 2a14b903f9..7e6bdec06a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,7 +14,7 @@ "sourceType": "module" }, "rules": { - "indent": ["error", 2], + "indent": ["error", 2, { "SwitchCase": 1 }], "linebreak-style": ["error", "unix"], "no-trailing-spaces": 2, "eol-last": 2, @@ -22,6 +22,7 @@ "no-multiple-empty-lines": 1, "prefer-const": "error", "space-infix-ops": "error", - "no-useless-escape": "off" + "no-useless-escape": "off", + "require-atomic-updates": "off" } } diff --git a/.flowconfig b/.flowconfig index c13f93b6a4..e36f404424 100644 --- a/.flowconfig +++ b/.flowconfig @@ -7,3 +7,4 @@ [libs] [options] +suppress_comment= \\(.\\|\n\\)*\\@flow-disable-next diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..23b2493344 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.js text +*.html text +*.less text +*.json text +*.css text +*.xml text +*.md text +*.txt text +*.yml text +*.sql text +*.sh text + +*.png binary \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 0eb87ca6cc..bb6766d8aa 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,28 +1,30 @@ + ### Issue Description -Describe your issue in as much detail as possible. + ### Steps to reproduce -Please include a detailed list of steps that reproduce the issue. Include curl commands when applicable. + #### Expected Results -What you expected to happen. + #### Actual Outcome -What is happening instead. + ### Environment Setup diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md new file mode 100644 index 0000000000..a19d228731 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -0,0 +1,17 @@ +--- +name: "\U0001F4A1 Feature request" +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/---getting-help.md b/.github/ISSUE_TEMPLATE/---getting-help.md new file mode 100644 index 0000000000..331bb3021e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---getting-help.md @@ -0,0 +1,5 @@ +--- +name: "🙋‍Getting Help" +about: Join https://community.parseplatform.org + +--- diff --git a/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md b/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md new file mode 100644 index 0000000000..49e9f447ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---parse-server-3-0-0.md @@ -0,0 +1,56 @@ +--- +name: "\U0001F525 parse-server 3.0.0" +about: Report an issue while migrating to parse-server 3.0.0 + +--- + + + +# Before opening the issue please ensure that you have: + +- [ ] [Read the migration guide](https://github.com/parse-community/parse-server/blob/master/3.0.0.md) to parse-server 3.0.0 +- [ ] [Read the migration guide](https://github.com/parse-community/Parse-SDK-JS/blob/master/2.0.0.md) to Parse SDK JS 2.0.0 + +### Issue Description + + + +### Steps to reproduce + + + +### Expected Results + + + +### Actual Outcome + + + +### Environment Setup + +- **Server** + - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] + - Operating System: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] + +- **Database** + - MongoDB version: [FILL THIS OUT] + - Storage engine: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] + +### Logs/Trace + + diff --git a/.github/ISSUE_TEMPLATE/---push-notifications.md b/.github/ISSUE_TEMPLATE/---push-notifications.md new file mode 100644 index 0000000000..43998b70f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---push-notifications.md @@ -0,0 +1,49 @@ +--- +name: "\U0001F4F2 Push Notifications" +about: Issues with setting up or delivering push notifications + +--- + + + +### Issue Description + + + +### Push Configuration + +Please provide a copy of your `push` configuration here, obfuscating any sensitive part. + +### Environment Setup + +- **Server** + - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] + - Operating System: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] + +- **Database** + - MongoDB version: [FILL THIS OUT] + - Storage engine: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] + +### Logs/Trace + + + diff --git a/.github/ISSUE_TEMPLATE/---report-an-issue.md b/.github/ISSUE_TEMPLATE/---report-an-issue.md new file mode 100644 index 0000000000..78583e73d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---report-an-issue.md @@ -0,0 +1,51 @@ +--- +name: "\U0001F41B Report an issue" +about: Report an issue on parse-server + +--- + + +### Issue Description + + + +### Steps to reproduce + + + +### Expected Results + + + +### Actual Outcome + + + +### Environment Setup + +- **Server** + - parse-server version (Be specific! Don't say 'latest'.) : [FILL THIS OUT] + - Operating System: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] + +- **Database** + - MongoDB version: [FILL THIS OUT] + - Storage engine: [FILL THIS OUT] + - Hardware: [FILL THIS OUT] + - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] + +### Logs/Trace + + diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..e08c0e16f9 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,29 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 45 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - bug + - enhancement + - feature request + - good first issue + - hacktoberfest + - help wanted + - needs investigation + - needs more info + - question + - pinned + - security + - up-for-grabs +# Label to use when marking an issue as stale +staleLabel: wontfix +# Limit to only `issues` not `pulls` +only: issues +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore index 4e4ee21cae..e4e19156c2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ lib-cov coverage .nyc_output +# docs output +out +docs + # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -52,3 +56,6 @@ lib/ # Folder created by FileSystemAdapter /files + +# Redis Dump +dump.rdb diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..43708bb6b4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +10.14.2 + diff --git a/.nycrc b/.nycrc index 60afab1c96..82a1fc5f1e 100644 --- a/.nycrc +++ b/.nycrc @@ -4,8 +4,7 @@ "text-summary" ], "exclude": [ - "**/spec/**", - "lib/" + "**/spec/**" ] } diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..f0ef52a9dc --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +semi: true +trailingComma: "es5" +singleQuote: true diff --git a/.travis.yml b/.travis.yml index 0ea8f9933b..7727843028 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,70 +1,77 @@ language: node_js -dist: trusty +os: linux +dist: xenial services: - - mongodb - - postgresql - - redis-server - - docker -addons: - postgresql: '9.5' - apt_packages: - - postgresql-9.5-postgis-2.3 +- redis +- docker branches: only: - master - - /^[0-9]+.[0-9]+.[0-9]+(-.*)?$/ - - /^greenkeeper/.*$/ + - "/^[0-9]+.[0-9]+.[0-9]+(-.*)?$/" + - 3.x + - 4.x + - "/^greenkeeper/.*$/" cache: directories: - - node_modules - - "$HOME/.mongodb/versions" - -# Test stage -stage: test + - "$HOME/.npm" + - ".eslintcache" env: global: - COVERAGE_OPTION='./node_modules/.bin/nyc' - - NODE_VERSION=6.10 - matrix: - - MONGODB_VERSION=3.2.13 - - MONGODB_VERSION=3.4.4 - - PARSE_SERVER_TEST_DB=postgres + jobs: + - MONGODB_VERSION=4.0.4 MONGODB_TOPOLOGY=replicaset MONGODB_STORAGE_ENGINE=wiredTiger + - MONGODB_VERSION=3.6.9 - PARSE_SERVER_TEST_CACHE=redis - - NODE_VERSION=8.5 + - NODE_VERSION=12.12.0 before_install: - nvm install $NODE_VERSION - nvm use $NODE_VERSION +- npm install -g greenkeeper-lockfile@1 before_script: - node -e 'require("./lib/index.js")' -- psql -c 'create database parse_server_postgres_adapter_test_database;' -U postgres -- psql -c 'CREATE EXTENSION postgis;' -U postgres -d parse_server_postgres_adapter_test_database -- psql -c 'CREATE EXTENSION postgis_topology;' -U postgres -d parse_server_postgres_adapter_test_database -- silent=1 mongodb-runner --start +- greenkeeper-lockfile-update +script: +- npm run lint +- npm run pretest && npm run coverage after_script: +- greenkeeper-lockfile-upload - bash <(curl -s https://codecov.io/bash) - jobs: + allow_failures: + - env: NODE_VERSION=12.12.0 include: - # release on github latest branch - - stage: release - node_js: '4.6' - env: - before_script: skip - after_script: skip - script: npm install -g nsp && nsp check - deploy: - - provider: script - skip_cleanup: true - script: ./resources/npm-git.sh - on: - branch: master - - provider: npm - skip_cleanup: true - email: - secure: "YweTGc22uqFWpzbfiUa5ptBLRAy6tt6d9TZLwEkPtmnsWzN9dguGyKWmXiw0qL+848FWQ5PWzUgBn5XdigV9tF3rJY6RGs8i38WulNjwSoGuRZa3AChsQHAb1KenANcJybzhnwgEj9gRsrGZPEsyI2whfake/xLDtG91kHjpJANsd4gseOh6fdS4FIYCbyXvSbC7S0yZzIJkgSkMqJO7RJ8r0HYQ7srYIw31dM3ZXSmUYu+GaMDUUu3RLAGYoKaROxhDRnzkjdeLLiwZH8hQ/6CaqwqX54iJ6OS+MOQU3fi5ZXalA7RZvtC4RmrcCkaTf3i0f+5xejYIFgzXdYGMVm8DUc82tDw1s4b6Pb19bgi1xXOQ0IKzRmZuGxnvkRN61dnYdnpnnNuG97HXgVjiOigZXVLZkWazUdnf9zXqmkC+KxPfa4Ldqg0TMjQ9J14n6TXxRti8Tt0xMa1Uzho7VdsxdJy35Bghy398O6X8VdX6parfzEkX7c/JzcA3TIMJ9+S1dy3J4Tb2URB5367W6h7cDeXtGmwLTFu75Q1CNqRJkUGbSrX2NyMqG5tc8oaTM+OWvLxcbKPRy9T6qN7x2JmCHHaapf8/8VR9wCy2PwE5j+KWhmctEHqqOgrtG5gsjC0eCPJsposxZWyM7M6aUpXe3w+olBfKq9apUGStUSU=" - api_key: - secure: "QprkaqQ+WCvZQR4qIEs5iS6peMCbRd8Hgt0s6HfdmhavNXwDFY8Bkdf6zJwWHLiqs4pyClXDZ2f6QiOs7y9IvJZ+wOIbsf4N5V6s06zOxJ0NAOwhe0mgWS3Us0zgXIfmW4BpmGnU4ql/qGL+9vNfyQJ7wxEJxVK7hiYh9Epu49E2jmefDqTX+SNSrDCg4HkRcxerxYGnAJDCP50QaNlyLSciODD6wHaddrSYkdvmISLMnLHug61OkE4OBIOWXfYV+e31kDj+zgczAfVQgekDKtaimCQclHFrmaEPl0KIm6wsDQAw5HWkepA/WZfv4SbCrDaKJDZw+LBI7dR0ezmiOH/zmWWrRW7D4wjkDGiumWjA8etuf8I4GRyC/d1RS+hnlvPr0Bu+WljuVxLoK3nhZOdiK5t4QlVDoGankkRjLylwFQgo8tzu5N0dc26z3ClowTwcKsjStmFr53gjCD7l3qoFjyPot1JlW3LPhG9Nch7rK33/7ONqVai3zxb1xB9ynd8TSzKi4/66LeYEDcGVM5A9Vmkp+egCnOhkOAXdI8O4jid98NANir+U4xmUYZ2PAMNmSeHlpSpwH2pC1/BHpDKA9RZEuEdr3sgmtuXXwwOCp+xvuVwxZZ6+gVEjG4nGTqSNxUIq1fzjGih8ElJtbM1Uhh2dVE9uxW7EC/oCuuk=" - on: - tags: true - all_branches: true - repo: parse-community/parse-server + - stage: + addons: + postgresql: '11' + apt: + packages: + - postgresql-11-postgis-2.5 + - postgresql-11-postgis-2.5-scripts + env: POSTGRES_MAJOR_VERSION=11 PARSE_SERVER_TEST_DB=postgres + before_install: bash scripts/before_install_postgres.sh + before_script: bash scripts/before_script_postgres.sh + - stage: release + node_js: '10' + env: + before_script: skip + after_script: skip + script: + - "./release_docs.sh" + deploy: + - provider: pages + skip_cleanup: true + token: + secure: YU2lUqmW036AHBRu7xO/AwEeQ900Q/5O6FL96ZqWEfD7Gadaq4iapkNvhPR0HcZYRHqQV2/2LCHVnhd0dbj0ShkmVIHdQE7O+MF+j0v0GIVc8FPTPe1/d2Hy4apSWNe6FeCrYKVeliAu+ZqvNuFLhVmYvIeJdlJrMGOb6P76UZXRDv2srXhq4uBcCVUJuTajyyd5ttJfcNapymTP+xzEDYc7Hr4LJaubmv/wVD/xwPBbvfYFuBqysUpkPKi/ODlQbB0ybYh2fFnX71WUyFUGbtB5xI9ma951Zp4v3t73c31uUl27dJaHzO62EtVTdcUKAa804EtAtsvpeJWMVWKUgigm9UZcXdEwKa79fl5nLaq34lrSktAYOkexwPqYj+vbS6sn52JrluSxLE+4cEke7tYbnJ9X12SAQXKgcXY3n30+6gKf9RVqYdlNsYpEAqKVIDb6SlEkk86dP+uIg/XXb5RKEwxbDXnb6xdl9JRc+GTbeeY3/vg2h9QTdmFVblPtBhHrNenQYP/BS0n+EfUnAIBKqRmQgyhao3SiY5FMACM8higI2Lvvhpq46pDhXqsexYCz5F008C6YXGDh5gC93rJFec0pjh55DNdQu6uw3YMQ5jtf3QUXoPAoMFud3cTulMlnjC1WZG8QbqER8dzUZ4TcaQUdzribJI/mRriheBg= + local_dir: docs/ + on: + all_branches: true + - provider: npm + skip_cleanup: true + api_token: + secure: Yrng6jsnMAtzrrln9DwRuY4xpcxl/WYS/1A5fckyQF6DsLmNlvqVtu3MWF+zdJgANF63G3jIee11tNBpYRecHl8FjPGwO0kc1vEgvIRVnveyR0bwIIDH6s/mR9dg4rZikflwG4XExsLyaQd7ko8aTIOIayfxJiv/u0yqwuBuBW2bFrL/41b1cKGK5+Iq2a+PdFENUPenKXISkACGaMnQF4Y/KVF98UwCiGLf957yFWc2sD6TFbjNDbAENSccvg1J3fBb+djbtzKzldl29ntp2mKVGSVASiKCRa6hSfgQulHiidFqFIKgpYJ1uATRr3UFr/NRVt1WbrgLnzY74OCX7y02c/xiYQMMEPRl/P8hHJu+KjQ3PBWsBvmsRN0QMUJv3PEjUPE3AY8sw49NoxiJZzJr7574vUBuM/dk0byYI6K/gwmUrPIhQjljzZqinS8JJJ4FjGjCdzXRhT5Q+PZvt5bF1rMOZXayNJZa+cICtmiJoU3vO2Yf6TXC3wY1veKvmcs24nws5lp4keJePTKAi1Ig7VoSxwAlgs3EGANIh7oBKx9zWnXQYMdmxbYsvLXAWcnnM+PcSCvooLmXWso3BQBeRuUUSS2oLaBpsFMsiniLNbA1cW4fwMkgUGz4oEDRi8wTD89E/1J2oNxzqtWRPawcVKpMpnBbDJHrWJPEHMw= + email: + secure: lomzDl71N995SzRczm7VE9OZE+PzMo4X8t3zU97agu/FMH5Qcj8BLwE+uVDTnA9Vblyj62ZsFKsNjP2Qp53Vcd+jHM4EJNWNRZYpEMIRO3LngX43r83qoFEHUvPu9s1oaa04a6FojcsJx0wl6B6Ke2AX74MXnJDLb9iZBy1mkpLUMVccVhSfhdoIzhkq3dhUw+6d8C024tNMHcgDW3VnRAsWFtiL7dCMpjLOdI+UxlkeGkQkxXXuRsZ0ZdjoSoM8NSkiYMc8x6EnekyRDoHTujX3OFxuU6+GAjrUmVzNmJWrBIqHVb0DXBQxjEaG3d/cNu5UsQyZYq5sxRRH0BaLs7F4oIQg95etasEtTtUkmsZ3pshVlsweiLU366UdbfuAf5hrJjqLrU12BKZyLjaAwyeKz031r8dA4sJtGIp5uVdXobQQTH6r958A88byJ20uaYSqhqjhZo3hkWXIQP0WQN2Ej/g57HbVNLB/nPKkMILfk/tpp7nBDLT0QrjbZxeo1dwCHqsBEV6z7ZyWyFf4xwpDsir4txL4t8ElzeGdlACjCqAJvIh5w9YzfrwijtoVMvvP6pWvn/iI640d4rsdIDe8egxgqZ1R/TMd/tdHYX+eI+ZfFmCVGj36/uXwdG7KIoIZVjRQ2tvWr9ZuydEPWPSRVGT4ycFeu6wbm3elM3I= + on: + tags: true + all_branches: true + repo: parse-community/parse-server diff --git a/3.0.0.md b/3.0.0.md new file mode 100644 index 0000000000..e34d2eeb9f --- /dev/null +++ b/3.0.0.md @@ -0,0 +1,226 @@ +# Upgrading Parse Server to version 3.0.0 + +parse-server 3.0.0 comes also with the JS SDK version 2.0 which requires a migration. Follow the migration guide [here](https://github.com/parse-community/Parse-SDK-JS/blob/master/2.0.0.md). + +With the 3.0.0, parse-server has completely revamped it's cloud code interface. Gone are the backbone style calls, welcome promises and async/await. + +In order to leverage those new nodejs features, you'll need to run at least node 8.10. We've dropped the support of node 6 a few months ago, so if you haven't made the change yet, now would be a really good time to take the plunge. + +## Migrating Cloud Code Hooks + +### Synchronous validations: + +```js +// before +Parse.Cloud.beforeSave('MyClassName', function(request, response) { + if (!passesValidation(request.object)) { + response.error('Ooops something went wrong'); + } else { + response.success(); + } +}); + +// after +Parse.Cloud.beforeSave('MyClassName', (request) => { + if (!passesValidation(request.object)) { + throw 'Ooops something went wrong'; + } +}); +``` + +All methods are wrapped in promises, so you can freely `throw` `Error`, `Parse.Error` or `string` to mark an error. + +### Asynchronous validations: + +For asynchronous code, you can use promises or async / await. + +Consider the following beforeSave call that would replace the contents of a fileURL with a proper copy of the image as a Parse.File. + +```js +// before +Parse.Cloud.beforeSave('Post', function(request, response) { + Parse.Cloud.httpRequest({ + url: request.object.get('fileURL'), + success: function(contents) { + const file = new Parse.File('image.png', { base64: contents.buffer.toString('base64') }); + file.save({ + success: function() { + request.object.set('file', file); + response.success(); + }, + error: response.error + }); + }, + error: response.error + }); +}); +``` + +As we can see the current way, with backbone style callbacks is quite tough to read and maintain. +It's also not really trivial to handle errors, as you need to pass the response.error to each error handlers. + +Now it can be modernized with promises: + +```js +// after (with promises) +Parse.Cloud.beforeSave('MyClassName', (request) => { + return Parse.Cloud.httpRequest({ + url: request.object.get('fileURL') + }).then((contents) => { + const file = new Parse.File('image.png', { base64: contents.buffer.toString('base64') }); + request.object.set('file', file); + return file.save(); + }); +}); +``` + +And you can even make it better with async/await. + +```js +// after with async/await +Parse.Cloud.beforeSave('MyClassName', async (request) => { + const contents = await Parse.Cloud.httpRequest({ + url: request.object.get('fileURL') + }); + + const file = new Parse.File('image.png', { base64: contents.buffer.toString('base64') }); + request.object.set('file', file); + await file.save(); +}); +``` + +## Aborting hooks and functions + +In order to abort a `beforeSave` or any hook, you now need to throw an error: + +```js +// after with async/await +Parse.Cloud.beforeSave('MyClassName', async (request) => { + // valid, will result in a Parse.Error(SCRIPT_FAILED, 'Something went wrong') + throw 'Something went wrong' + // also valid, will fail with Parse.Error(SCRIPT_FAILED, 'Something else went wrong') + throw new Error('Something else went wrong') + // using a Parse.Error is also valid + throw new Parse.Error(1001, 'My error') +}); +``` + +## Migrating Functions + +Cloud Functions can be migrated the same way as cloud code hooks. +In functions, the response object is not passed anymore, as it is in cloud code hooks + +Continuing with the image downloader example: + +```js +// before +Parse.Cloud.define('downloadImage', function(request, response) { + Parse.Cloud.httpRequest({ + url: request.params.url, + success: function(contents) { + const file = new Parse.File(request.params.name, { base64: contents.buffer.toString('base64') }); + file.save({ + success: function() { + response.success(file); + }, + error: response.error + }); + }, + error: response.error + }); +}); +``` + +You would call this method with: + +```js +Parse.Cloud.run('downloadImage',{ + url: 'https://example.com/file', + name: 'my-file' +}); +``` + +To migrate this function you would follow the same practices as the ones before, and we'll jump right away to the async/await implementation: + +```js +// after with async/await +Parse.Cloud.define('downloadImage', async (request) => { + const { + url, name + } = request.params; + const response = await Parse.Cloud.httpRequest({ url }); + + const file = new Parse.File(name, { base64: response.buffer.toString('base64') }); + await file.save(); + return file; +}); +``` + +## Migrating jobs + +As with hooks and functions, jobs don't have a status object anymore. +The message method has been moved to the request object. + +```js +// before +Parse.Cloud.job('downloadImageJob', function(request, status) { + var query = new Parse.Query('ImagesToDownload'); + query.find({ + success: function(images) { + var done = 0; + for (var i = 0; i { + request.message('Doing ' + image.get('url')); + const contents = await Parse.Cloud.httpRequest({ + url: image.get('url') + }); + request.message('Got ' + image.get('url')); + const file = new Parse.File(image.get('name'), { base64: contents.buffer.toString('base64') }); + await file.save(); + request.message('Saved ' + image.get('url')); + }); + await Promise.all(promises); +}); + +``` + +As you can see the new implementation is more concise, easier to read and maintain. + +If you encounter a problem or discover an issue with this guide, or with any Parse Community project, feel free to [reach out on github](https://github.com/parse-community/parse-server/issues/new/choose) + diff --git a/CHANGELOG.md b/CHANGELOG.md index 02cf5dc4e0..f9d3b528fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,602 @@ ## Parse Server Changelog ### master -[Full Changelog](https://github.com/ParsePlatform/parse-server/compare/2.6.5...master) +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.1.0...master) + +### 4.1.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.0.2...4.1.0) + +_SECURITY RELEASE_: see [advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-h4mf-75hf-67w4) for details +- SECURITY FIX: Patch Regex vulnerabilities. See [3a3a5ee](https://github.com/parse-community/parse-server/commit/3a3a5eee5ffa48da1352423312cb767de14de269). Special thanks to [W0lfw00d](https://github.com/W0lfw00d) for identifying and [responsibly reporting](https://github.com/parse-community/parse-server/blob/master/SECURITY.md) the vulnerability. Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) for the speedy fix. + +### 4.0.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.0.1...4.0.2) + +__BREAKING CHANGES:__ +1. Remove Support for Mongo 3.2 & 3.4. The new minimum supported version is Mongo 3.6. +2. Change username and email validation to be case insensitive. This change should be transparent in most use cases. The validation behavior should now behave 'as expected'. See [#5634](https://github.com/parse-community/parse-server/pull/5634) for details. + +> __Special Note on Upgrading to Parse Server 4.0.0 and above__ +> +> In addition to the breaking changes noted above, [#5634](https://github.com/parse-community/parse-server/pull/5634) introduces a two new case insensitive indexes on the `User` collection. Special care should be taken when upgrading to this version to ensure that: +> +> 1. The new indexes can be successfully created (see issue [#6465](https://github.com/parse-community/parse-server/issues/6465) for details on a potential issue for your installation). +> +> 2. Care is taken ensure that there is adequate compute capacity to create the index in the background while still servicing requests. + +- FIX: attempt to get travis to deploy to npmjs again. See [#6475](https://github.com/parse-community/parse-server/pull/6457). Thanks to [Arthur Cinader](https://github.com/acinader). + +### 4.0.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/4.0.0...4.0.1) +- FIX: correct 'new' travis config to properly deploy. See [#6452](https://github.com/parse-community/parse-server/pull/6452). Thanks to [Arthur Cinader](https://github.com/acinader). +- FIX: Better message on not allowed to protect default fields. See [#6439](https://github.com/parse-community/parse-server/pull/6439).Thanks to [Old Grandpa](https://github.com/BufferUnderflower) + +### 4.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.10.0...4.0.0) + +> __Special Note on Upgrading to Parse Server 4.0.0 and above__ +> +> In addition to the breaking changes noted below, [#5634](https://github.com/parse-community/parse-server/pull/5634) introduces a two new case insensitive indexes on the `User` collection. Special care should be taken when upgrading to this version to ensure that: +> +> 1. The new indexes can be successfully created (see issue [#6465](https://github.com/parse-community/parse-server/issues/6465) for details on a potential issue for your installation). +> +> 2. Care is taken ensure that there is adequate compute capacity to create the index in the background while still servicing requests. + +- NEW: add hint option to Parse.Query [#6322](https://github.com/parse-community/parse-server/pull/6322). Thanks to [Steve Stencil](https://github.com/stevestencil) +- FIX: CLP objectId size validation fix [#6332](https://github.com/parse-community/parse-server/pull/6332). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Add volumes to Docker command [#6356](https://github.com/parse-community/parse-server/pull/6356). Thanks to [Kasra Bigdeli](https://github.com/githubsaturn) +- NEW: GraphQL 3rd Party LoginWith Support [#6371](https://github.com/parse-community/parse-server/pull/6371). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: GraphQL Geo Queries [#6363](https://github.com/parse-community/parse-server/pull/6363). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: GraphQL Nested File Upload [#6372](https://github.com/parse-community/parse-server/pull/6372). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Granular CLP pointer permissions [#6352](https://github.com/parse-community/parse-server/pull/6352). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Add missing colon for customPages [#6393](https://github.com/parse-community/parse-server/pull/6393). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon) +- NEW: `afterLogin` cloud code hook [#6387](https://github.com/parse-community/parse-server/pull/6387). Thanks to [David Corona](https://github.com/davesters) +- FIX: __BREAKING CHANGE__ Prevent new usernames or emails that clash with existing users' email or username if it only differs by case. For example, don't allow a new user with the name 'Jane' if we already have a user 'jane'. [#5634](https://github.com/parse-community/parse-server/pull/5634). Thanks to [Arthur Cinader](https://github.com/acinader) +- FIX: Support Travis CI V2. [#6414](https://github.com/parse-community/parse-server/pull/6414). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Prevent crashing on websocket error. [#6418](https://github.com/parse-community/parse-server/pull/6418). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Allow protectedFields for Authenticated users and Public. [$6415](https://github.com/parse-community/parse-server/pull/6415). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Correct bug in determining GraphQL pointer errors when mutating. [#6413](https://github.com/parse-community/parse-server/pull/6431). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Allow true GraphQL Schema Customization. [#6360](https://github.com/parse-community/parse-server/pull/6360). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- __BREAKING CHANGE__: Remove Support for Mongo version < 3.6 [#6445](https://github.com/parse-community/parse-server/pull/6445). Thanks to [Arthur Cinader](https://github.com/acinader) + +### 3.10.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.9.0...3.10.0) +- FIX: correct and cover ordering queries in GraphQL [#6316](https://github.com/parse-community/parse-server/pull/6316). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL support for reset password email [#6301](https://github.com/parse-community/parse-server/pull/6301). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Add default limit to GraphQL fetch [#6304](https://github.com/parse-community/parse-server/pull/6304). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- DOCS: use bash syntax highlighting [#6302](https://github.com/parse-community/parse-server/pull/6302). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon) +- NEW: Add max log file option [#6296](https://github.com/parse-community/parse-server/pull/6296). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: support user supplied objectId [#6101](https://github.com/parse-community/parse-server/pull/6101). Thanks to [Ruhan](https://github.com/rhuanbarretos) +- FIX: Add missing encodeURIComponent on username [#6278](https://github.com/parse-community/parse-server/pull/6278). Thanks to [Christopher Brookes](https://github.com/Klaitos) +- NEW: update PostgresStorageAdapter.js to use async/await [#6275](https://github.com/parse-community/parse-server/pull/6275). Thanks to [Vitaly Tomilov](https://github.com/vitaly-t) +- NEW: Support required fields on output type for GraphQL [#6279](https://github.com/parse-community/parse-server/pull/6279). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Support required fields for GraphQL [#6271](https://github.com/parse-community/parse-server/pull/6279). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: use mongodb 3.3.5 [#6263](https://github.com/parse-community/parse-server/pull/6263). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: GraphQL: DX Relational Where Query [#6255](https://github.com/parse-community/parse-server/pull/6255). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: test against Postgres 11 [#6260](https://github.com/parse-community/parse-server/pull/6260). Thanks to [Diamond Lewis](https://github.com/dplewis) +- CHANGE: test against Postgres 11 [#6260](https://github.com/parse-community/parse-server/pull/6260). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: GraphQL alias for mutations in classConfigs [#6258](https://github.com/parse-community/parse-server/pull/6258). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- NEW: GraphQL classConfig query alias [#6257](https://github.com/parse-community/parse-server/pull/6257). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- NEW: Allow validateFilename to return a string or Parse Error [#6246](https://github.com/parse-community/parse-server/pull/6246). Thanks to [Mike Patnode](https://github.com/mpatnode) +- NEW: Relay Spec [#6089](https://github.com/parse-community/parse-server/pull/6089). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Set default ACL for GraphQL [#6249](https://github.com/parse-community/parse-server/pull/6249). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: LDAP auth Adapter [#6226](https://github.com/parse-community/parse-server/pull/6226). Thanks to [Julian Dax](https://github.com/brodo) +- FIX: improve beforeFind to include Query info [#6237](https://github.com/parse-community/parse-server/pull/6237). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: improve websocket error handling [#6230](https://github.com/parse-community/parse-server/pull/6230). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: addition of an afterLogout trigger [#6217](https://github.com/parse-community/parse-server/pull/6217). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Initialize default logger [#6186](https://github.com/parse-community/parse-server/pull/6186). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add funding link [#6192](https://github.com/parse-community/parse-server/pull/6192 ). Thanks to [Tom Fox](https://github.com/TomWFox) +- FIX: installationId on LiveQuery connect [#6180](https://github.com/parse-community/parse-server/pull/6180). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add exposing port in docker container [#6165](https://github.com/parse-community/parse-server/pull/6165). Thanks to [Priyash Patil](https://github.com/priyashpatil) +- NEW: Support Google Play Games Service [#6147](https://github.com/parse-community/parse-server/pull/6147). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOC: Throw error when setting authData to null [#6154](https://github.com/parse-community/parse-server/pull/6154). Thanks to [Manuel](https://github.com/mtrezza) +- CHANGE: Move filename validation out of the Router and into the FilesAdaptor [#6157](https://github.com/parse-community/parse-server/pull/6157). Thanks to [Mike Patnode](https://github.com/mpatnode) +- NEW: Added warning for special URL sensitive characters for appId [#6159](https://github.com/parse-community/parse-server/pull/6159). Thanks to [Saimoom Safayet Akash](https://github.com/saimoomsafayet) +- NEW: Support Apple Game Center Auth [#6143](https://github.com/parse-community/parse-server/pull/6143). Thanks to [Diamond Lewis](https://github.com/dplewis) +- CHANGE: test with Node 12 [#6133](https://github.com/parse-community/parse-server/pull/6133). Thanks to [Arthur Cinader](https://github.com/acinader) +- FIX: prevent after find from firing when saving objects [#6127](https://github.com/parse-community/parse-server/pull/6127). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: GraphQL Mutations not returning updated information [6130](https://github.com/parse-community/parse-server/pull/6130). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- CHANGE: Cleanup Schema cache per request [#6216](https://github.com/parse-community/parse-server/pull/6216). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOC: Improve installation instructions [#6120](https://github.com/parse-community/parse-server/pull/6120). Thanks to [Andres Galante](https://github.com/andresgalante) +- DOC: add code formatting to contributing guidelines [#6119](https://github.com/parse-community/parse-server/pull/6119). Thanks to [Andres Galante](https://github.com/andresgalante) +- NEW: Add GraphQL ACL Type + Input [#5957](https://github.com/parse-community/parse-server/pull/5957). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: replace public key [#6099](https://github.com/parse-community/parse-server/pull/6099). Thanks to [Arthur Cinader](https://github.com/acinader) +- NEW: Support microsoft authentication in GraphQL [#6051](https://github.com/parse-community/parse-server/pull/6051). Thanks to [Alann Maulana](https://github.com/alann-maulana) +- NEW: Install parse-server 3.9.0 instead of 2.2 [#6069](https://github.com/parse-community/parse-server/pull/6069). Thanks to [Julian Dax](https://github.com/brodo) +- NEW: Use #!/bin/bash instead of #!/bin/sh [#6062](https://github.com/parse-community/parse-server/pull/6062). Thanks to [Julian Dax](https://github.com/brodo) +- DOC: Update GraphQL readme section [#6030](https://github.com/parse-community/parse-server/pull/6030). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +### 3.9.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.8.0...3.9.0) +- NEW: Add allowHeaders to Options [#6044](https://github.com/parse-community/parse-server/pull/6044). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- CHANGE: Introduce ReadOptionsInput to GraphQL API [#6030](https://github.com/parse-community/parse-server/pull/6030). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Stream video with GridFSBucketAdapter (implements byte-range requests) [#6028](https://github.com/parse-community/parse-server/pull/6028). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Aggregate not matching null values [#6043](https://github.com/parse-community/parse-server/pull/6043). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Improve callCloudCode mutation to receive a CloudCodeFunction enum instead of a String in the GraphQL API [#6029](https://github.com/parse-community/parse-server/pull/6029). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- TEST: Add more tests to transactions [#6022](https://github.com/parse-community/parse-server/pull/6022). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Pointer constraint input type as ID in the GraphQL API [#6020](https://github.com/parse-community/parse-server/pull/6020). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- CHANGE: Remove underline from operators of the GraphQL API [#6024](https://github.com/parse-community/parse-server/pull/6024). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Make method async as expected in usage [#6025](https://github.com/parse-community/parse-server/pull/6025). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- DOC: Added breaking change note to 3.8 release [#6023](https://github.com/parse-community/parse-server/pull/6023). Thanks to [Manuel](https://github.com/mtrezza) +- NEW: Added support for line auth [#6007](https://github.com/parse-community/parse-server/pull/6007). Thanks to [Saimoom Safayet Akash](https://github.com/saimoomsafayet) +- FIX: Fix aggregate group id [#5994](https://github.com/parse-community/parse-server/pull/5994). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Schema operations instead of generic operations in the GraphQL API [#5993](https://github.com/parse-community/parse-server/pull/5993). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- DOC: Fix changelog formatting[#6009](https://github.com/parse-community/parse-server/pull/6009). Thanks to [Tom Fox](https://github.com/TomWFox) +- CHANGE: Rename objectId to id in the GraphQL API [#5985](https://github.com/parse-community/parse-server/pull/5985). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- FIX: Fix beforeLogin trigger when user has a file [#6001](https://github.com/parse-community/parse-server/pull/6001). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- DOC: Update GraphQL Docs with the latest changes [#5980](https://github.com/parse-community/parse-server/pull/5980). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +### 3.8.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.7.2...3.8.0) +- NEW: Protected fields pointer-permissions support [#5951](https://github.com/parse-community/parse-server/pull/5951). Thanks to [Dobbias Nan](https://github.com/Dobbias) +- NEW: GraphQL DX: Relation/Pointer [#5946](https://github.com/parse-community/parse-server/pull/5946). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Master Key Only Config Properties [#5953](https://github.com/parse-community/parse-server/pull/5954). Thanks to [Manuel](https://github.com/mtrezza) +- FIX: Better validation when creating a Relation fields [#5922](https://github.com/parse-community/parse-server/pull/5922). Thanks to [Lucas Alencar](https://github.com/alencarlucas) +- NEW: enable GraphQL file upload [#5944](https://github.com/parse-community/parse-server/pull/5944). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Handle shutdown on grid adapters [#5943](https://github.com/parse-community/parse-server/pull/5943). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Fix GraphQL max upload size [#5940](https://github.com/parse-community/parse-server/pull/5940). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove Buffer() deprecation notice [#5942](https://github.com/parse-community/parse-server/pull/5942). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove MongoDB unified topology deprecation notice from the grid adapter [#5941](https://github.com/parse-community/parse-server/pull/5941). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: add callback for serverCloseComplete [#5937](https://github.com/parse-community/parse-server/pull/5937). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOCS: Add Cloud Code guide to README [#5936](https://github.com/parse-community/parse-server/pull/5936). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Remove nested operations from GraphQL API [#5931](https://github.com/parse-community/parse-server/pull/5931). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Improve Live Query Monitoring [#5927](https://github.com/parse-community/parse-server/pull/5927). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: GraphQL: Fix undefined Array [#5296](https://github.com/parse-community/parse-server/pull/5926). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Added array support for pointer-permissions [#5921](https://github.com/parse-community/parse-server/pull/5921). Thanks to [Dobbias Nan](https://github.com/Dobbias) +- GraphQL: Renaming Types/Inputs [#5921](https://github.com/parse-community/parse-server/pull/5921). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Lint no-prototype-builtins [#5920](https://github.com/parse-community/parse-server/pull/5920). Thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Inline Fragment on Array Fields [#5908](https://github.com/parse-community/parse-server/pull/5908). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- DOCS: Add instructions to launch a compatible Docker Postgres [](). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- Fix: Undefined dot notation in matchKeyInQuery [#5917](https://github.com/parse-community/parse-server/pull/5917). Thanks to [Diamond Lewis](https://github.com/dplewis) +- Fix: Logger print JSON and Numbers [#5916](https://github.com/parse-community/parse-server/pull/5916). Thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Return specific Type on specific Mutation [#5893](https://github.com/parse-community/parse-server/pull/5893). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Apple sign-in authAdapter [#5891](https://github.com/parse-community/parse-server/pull/5891). Thanks to [SebC](https://github.com/SebC99). +- DOCS: Add GraphQL beta notice [#5886](https://github.com/parse-community/parse-server/pull/5886). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- GraphQL: Remove "password" output field from _User class [#5889](https://github.com/parse-community/parse-server/pull/5889). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- GraphQL: Object constraints [#5715](https://github.com/parse-community/parse-server/pull/5715). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- DOCS: README top section overhaul + add sponsors [#5876](https://github.com/parse-community/parse-server/pull/5876). Thanks to [Tom Fox](https://github.com/TomWFox) +- FIX: Return a Promise from classUpdate method [#5877](https://github.com/parse-community/parse-server/pull/5877). Thanks to [Lucas Alencar](https://github.com/alencarlucas) +- FIX: Use UTC Month in aggregate tests [#5879](https://github.com/parse-community/parse-server/pull/5879). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Transaction was aborting before all promises have either resolved or rejected [#5878](https://github.com/parse-community/parse-server/pull/5878). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Use transactions for batch operation [#5849](https://github.com/parse-community/parse-server/pull/5849). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +#### Breaking Changes: +- If you are running Parse Server on top of a MongoDB deployment which does not fit the [Retryable Writes Requirements](https://docs.mongodb.com/manual/core/retryable-writes/#prerequisites), you will have to add `retryWrites=false` to your connection string in order to upgrade to Parse Server 3.8. + +### 3.7.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.7.1...3.7.2) + +- FIX: Live Query was failing on release 3.7.1 + +### 3.7.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.7.0...3.7.1) + +- FIX: Missing APN module +- FIX: Set falsy values as default to schema fields [#5868](https://github.com/parse-community/parse-server/pull/5868), thanks to [Lucas Alencar](https://github.com/alencarlucas) +- NEW: Implement WebSocketServer Adapter [#5866](https://github.com/parse-community/parse-server/pull/5866), thanks to [Diamond Lewis](https://github.com/dplewis) + +### 3.7.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.6.0...3.7.0) + +- FIX: Prevent linkWith sessionToken from generating new session [#5801](https://github.com/parse-community/parse-server/pull/5801), thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Improve session token error messages [#5753](https://github.com/parse-community/parse-server/pull/5753), thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- NEW: GraphQL { functions { call } } generic mutation [#5818](https://github.com/parse-community/parse-server/pull/5818), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL Custom Schema [#5821](https://github.com/parse-community/parse-server/pull/5821), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL custom schema on CLI [#5828](https://github.com/parse-community/parse-server/pull/5828), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL @mock directive [#5836](https://github.com/parse-community/parse-server/pull/5836), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: GraphQL _or operator not working [#5840](https://github.com/parse-community/parse-server/pull/5840), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Add "count" to CLP initial value [#5841](https://github.com/parse-community/parse-server/pull/5841), thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- NEW: Add ability to alter the response from the after save trigger [#5814](https://github.com/parse-community/parse-server/pull/5814), thanks to [BrunoMaurice](https://github.com/brunoMaurice) +- FIX: Cache apple public key for the case it fails to fetch again [#5848](https://github.com/parse-community/parse-server/pull/5848), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL Configuration Options [#5782](https://github.com/parse-community/parse-server/pull/5782), thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- NEW: Required fields and default values [#5835](https://github.com/parse-community/parse-server/pull/5835), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Postgres safely escape strings in nested objects [#5855](https://github.com/parse-community/parse-server/pull/5855), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Support PhantAuth authentication [#5850](https://github.com/parse-community/parse-server/pull/5850), thanks to [Ivan SZKIBA](https://github.com/szkiba) +- FIX: Remove uws package [#5860](https://github.com/parse-community/parse-server/pull/5860), thanks to [Zeal Murapa](https://github.com/GoGross) + +### 3.6.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.5.0...3.6.0) + +- SECURITY FIX: Address [Security Advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5) of a potential [Enumeration Attack](https://www.owasp.org/index.php/Testing_for_User_Enumeration_and_Guessable_User_Account_(OWASP-AT-002)#Description_of_the_Issue) [73b0f9a](https://github.com/parse-community/parse-server/commit/73b0f9a339b81f5d757725dc557955a7b670a3ec), big thanks to [Fabian Strachanski](https://github.com/fastrde) for identifying the problem, creating a fix and following the [vulnerability disclosure guidelines](https://github.com/parse-community/parse-server/blob/master/SECURITY.md#parse-community-vulnerability-disclosure-program) +- NEW: Added rest option: excludeKeys [#5737](https://github.com/parse-community/parse-server/pull/5737), thanks to [Raschid J.F. Rafeally](https://github.com/RaschidJFR) +- FIX: LiveQuery create event with fields [#5790](https://github.com/parse-community/parse-server/pull/5790), thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Generate sessionToken with linkWith [#5799](https://github.com/parse-community/parse-server/pull/5799), thanks to [Diamond Lewis](https://github.com/dplewis) + +### 3.5.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.4.4...3.5.0) + +- NEW: GraphQL Support [#5674](https://github.com/parse-community/parse-server/pull/5674), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +[GraphQL Guide](https://github.com/parse-community/parse-server#graphql) + +- NEW: Sign in with Apple [#5694](https://github.com/parse-community/parse-server/pull/5694), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: AppSecret to Facebook Auth [#5695](https://github.com/parse-community/parse-server/pull/5695), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Postgres: Regex support foreign characters [#5598](https://github.com/parse-community/parse-server/pull/5598), thanks to [Jeff Gu Kang](https://github.com/JeffGuKang) +- FIX: Winston Logger string interpolation [#5729](https://github.com/parse-community/parse-server/pull/5729), thanks to [Diamond Lewis](https://github.com/dplewis) + +### 3.4.4 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.4.3...3.4.4) + +Fix: Commit changes + +### 3.4.3 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.4.2...3.4.3) + +Fix: Use changes in master to travis configuration to enable pushing to npm and gh_pages. See diff for details. + +### 3.4.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.4.1...3.4.2) + +Fix: In my haste to get a [Security Fix](https://github.com/parse-community/parse-server/security/advisories/GHSA-2479-qvv7-47qq) out, I added [8709daf](https://github.com/parse-community/parse-server/commit/8709daf698ea69b59268cb66f0f7cee75b52daa5) to master instead of to 3.4.1. This commit fixes that. [Arthur Cinader](https://github.com/acinader) + +### 3.4.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.4.0...3.4.1) + +Security Fix: see Advisory: [GHSA-2479-qvv7-47q](https://github.com/parse-community/parse-server/security/advisories/GHSA-2479-qvv7-47qq) for details [8709daf](https://github.com/parse-community/parse-server/commit/8709daf698ea69b59268cb66f0f7cee75b52daa5). Big thanks to: [Benjamin Simonsson](https://github.com/BenniPlejd) for identifying the issue and promptly bringing it to the Parse Community's attention and also big thanks to the indefatigable [Diamond Lewis](https://github.com/dplewis) for crafting a failing test and then a solution within an hour of the report. + +### 3.4.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.3.0...3.4.0) +- NEW: Aggregate supports group by date fields [#5538](https://github.com/parse-community/parse-server/pull/5538) thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: API for Read Preferences [#3963](https://github.com/parse-community/parse-server/pull/3963) thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Add Redis options for LiveQuery [#5584](https://github.com/parse-community/parse-server/pull/5584) thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add Direct Access option for Server Config [#5550](https://github.com/parse-community/parse-server/pull/5550) thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: updating mixed array in Postgres [#5552](https://github.com/parse-community/parse-server/pull/5552) thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: notEqualTo GeoPoint Query in Postgres [#5549](https://github.com/parse-community/parse-server/pull/5549), thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: put the timestamp back in logs that was lost after Winston upgrade [#5571](https://github.com/parse-community/parse-server/pull/5571), thanks to [Steven Rowe](https://github.com/mrowe009) and [Arthur Cinader](https://github.com/acinader) +- FIX: Validates permission before calling beforeSave [#5546](https://github.com/parse-community/parse-server/pull/5546), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove userSensitiveFields default value. [#5588](https://github.com/parse-community/parse-server/pull/5588), thanks to [William George](https://github.com/awgeorge) +- FIX: Decode Date JSON value in LiveQuery. [#5540](https://github.com/parse-community/parse-server/pull/5540), thanks to [ananfang](https://github.com/ananfang) + + +### 3.3.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.2.3...3.3.0) +- NEW: beforeLogin trigger with support for auth providers ([#5445](https://github.com/parse-community/parse-server/pull/5445)), thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- NEW: RFC 7662 compliant OAuth2 auth adapter ([#4910](https://github.com/parse-community/parse-server/pull/4910)), thanks to [Müller Zsolt](https://github.com/zsmuller) +- FIX: cannot change password when maxPasswordHistory is 1 ([#5191](https://github.com/parse-community/parse-server/pull/5191)), thanks to [Tulsi Sapkota](https://github.com/Tolsee) +- FIX (Postgres): count being very slow on large Parse Classes' collections ([#5330](https://github.com/parse-community/parse-server/pull/5330)), thanks to [CoderickLamar](https://github.com/CoderickLamar) +- FIX: using per-key basis queue ([#5420](https://github.com/parse-community/parse-server/pull/5420)), thanks to [Georges Jamous](https://github.com/georgesjamous) +- FIX: issue on count with Geo constraints and mongo ([#5286](https://github.com/parse-community/parse-server/pull/5286)), thanks to [Julien Quéré](https://github.com/jlnquere) + +### 3.2.3 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.2.2...3.2.3) +- Correct previous release with patch that is fully merged + +### 3.2.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.2.1...3.2.2) +- Security fix to properly process userSensitiveFields when parse-server is started with + ../lib/cli/parse-server [#5463](https://github.com/parse-community/parse-server/pull/5463 + ) + +### 3.2.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.2.0...3.2.1) +- Increment package.json version to match the deployment tag + +### 3.2.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.1.3...3.2.0) +- NEW: Support accessing sensitive fields with an explicit ACL. Not documented yet, see [tests](https://github.com/parse-community/parse-server/blob/f2c332ea6a984808ad5b2e3ce34864a20724f72b/spec/UserPII.spec.js#L526) for examples +- Upgrade Parse SDK JS to 2.3.1 [#5457](https://github.com/parse-community/parse-server/pull/5457) +- Hides token contents in logStartupOptions if they arrive as a buffer [#6a9380](https://github.com/parse-community/parse-server/commit/6a93806c62205a56a8f4e3b8765848c552510337) +- Support custom message for password requirements [#5399](https://github.com/parse-community/parse-server/pull/5399) +- Support for Ajax password reset [#5332](https://github.com/parse-community/parse-server/pull/5332) +- Postgres: Refuse to build unsafe JSON lists for contains [#5337](https://github.com/parse-community/parse-server/pull/5337) +- Properly handle return values in beforeSave [#5228](https://github.com/parse-community/parse-server/pull/5228) +- Fixes issue when querying user roles [#5276](https://github.com/parse-community/parse-server/pull/5276) +- Fixes issue affecting update with CLP [#5269](https://github.com/parse-community/parse-server/pull/5269) + +### 3.1.3 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.1.2...3.1.3) + +- Postgres: Fixes support for global configuration +- Postgres: Fixes support for numeric arrays +- Postgres: Fixes issue affecting queries on empty arrays +- LiveQuery: Adds support for transmitting the original object +- Queries: Use estimated count if query is empty +- Docker: Reduces the size of the docker image to 154Mb + + +### 3.1.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.1.1...3.1.2) + +- Removes dev script, use TDD instead of server. +- Removes nodemon and problematic dependencies. +- Addressed event-stream security debacle. + +### 3.1.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.1.0...3.1.1) + +#### Improvements: +* Fixes issue that would prevent users with large number of roles to resolve all of them [Antoine Cormouls](https://github.com/Moumouls) (#5131, #5132) +* Fixes distinct query on special fields ([#5144](https://github.com/parse-community/parse-server/pull/5144)) + + +### 3.1.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/3.0.0...3.1.0) + +#### Breaking Changes: +* Return success on sendPasswordResetEmail even if email not found. (#7fe4030) +#### Security Fix: +* Expire password reset tokens on email change (#5104) +#### Improvements: +* Live Query CLPs (#4387) +* Reduces number of calls to injectDefaultSchema (#5107) +* Remove runtime dependency on request (#5076) +#### Bug fixes: +* Fixes issue with vkontatke authentication (#4977) +* Use the correct function when validating google auth tokens (#5018) +* fix unexpected 'delete' trigger issue on LiveQuery (#5031) +* Improves performance for roles and ACL's in live query server (#5126) + + +### 3.0.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.4...3.0.0) + +`parse-server` 3.0.0 comes with brand new handlers for cloud code. It now fully supports promises and async / await. +For more informations, visit the v3.0.0 [migration guide](https://github.com/parse-community/parse-server/blob/master/3.0.0.md). + +#### Breaking changes: +* Cloud Code handlers have a new interface based on promises. +* response.success / response.error are removed in Cloud Code +* Cloud Code runs with Parse-SDK 2.0 +* The aggregate now require aggregates to be passed in the form: `{"pipeline": [...]}` (REST Only) + +#### Improvements: +* Adds Pipeline Operator to Aggregate Router. +* Adds documentations for parse-server's adapters, constructors and more. +* Adds ability to pass a context object between `beforeSave` and `afterSave` affecting the same object. + +#### Bug Fixes: +* Fixes issue that would crash the server when mongo objects had undefined values [#4966](https://github.com/parse-community/parse-server/issues/4966) +* Fixes issue that prevented ACL's from being used with `select` (see [#571](https://github.com/parse-community/Parse-SDK-JS/issues/571)) + +#### Dependency updates: +* [@parse/simple-mailgun-adapter@1.1.0](https://www.npmjs.com/package/@parse/simple-mailgun-adapter) +* [mongodb@3.1.3](https://www.npmjs.com/package/mongodb) +* [request@2.88.0](https://www.npmjs.com/package/request) + +##### Devevelopment Dependencies Updates: +* [@parse/minami@1.0.0](https://www.npmjs.com/package/@parse/minami) +* [deep-diff@1.0.2](https://www.npmjs.com/package/deep-diff) +* [flow-bin@0.79.0](https://www.npmjs.com/package/flow-bin) +* [jsdoc@3.5.5](https://www.npmjs.com/package/jsdoc) +* [jsdoc-babel@0.4.0](https://www.npmjs.com/package/jsdoc-babel) + +### 2.8.4 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.3...2.8.4) + +#### Improvements: +* Adds ability to forward errors to express handler (#4697) +* Adds ability to increment the push badge with an arbitrary value (#4889) +* Adds ability to preserve the file names when uploading (#4915) +* `_User` now follow regular ACL policy. Letting administrator lock user out. (#4860) and (#4898) +* Ensure dates are properly handled in aggregates (#4743) +* Aggregates: Improved support for stages sharing the same name +* Add includeAll option +* Added verify password to users router and tests. (#4747) +* Ensure read preference is never overriden, so DB config prevails (#4833) +* add support for geoWithin.centerSphere queries via withJSON (#4825) +* Allow sorting an object field (#4806) +* Postgres: Don't merge JSON fields after save() to keep same behaviour as MongoDB (#4808) (#4815) + +#### Dependency updates +* [commander@2.16.0](https://www.npmjs.com/package/commander) +* [mongodb@3.1.1](https://www.npmjs.com/package/mongodb) +* [pg-promise@8.4.5](https://www.npmjs.com/package/pg-promise) +* [ws@6.0.0](https://www.npmjs.com/package/ws) +* [bcrypt@3.0.0](https://www.npmjs.com/package/bcrypt) +* [uws@10.148.1](https://www.npmjs.com/package/uws) + +##### Devevelopment Dependencies Updates: +* [cross-env@5.2.0](https://www.npmjs.com/package/cross-env) +* [eslint@5.0.0](https://www.npmjs.com/package/eslint) +* [flow-bin@0.76.0](https://www.npmjs.com/package/flow-bin) +* [mongodb-runner@4.0.0](https://www.npmjs.com/package/mongodb-runner) +* [nodemon@1.18.1](https://www.npmjs.com/package/nodemon) +* [nyc@12.0.2](https://www.npmjs.com/package/nyc) +* [request-promise@4.2.2](https://www.npmjs.com/package/request-promise) +* [supports-color@5.4.0](https://www.npmjs.com/package/supports-color) + +### 2.8.3 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.2...2.8.3) + +#### Improvements: + +* Adds support for JS SDK 2.0 job status header +* Removes npm-git scripts as npm supports using git repositories that build, thanks to [Florent Vilmart](https://github.com/flovilmart) + + +### 2.8.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.1...2.8.2) + +##### Bug Fixes: +* Ensure legacy users without ACL's are not locked out, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements: +* Use common HTTP agent to increase webhooks performance, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Adds withinPolygon support for Polygon objects, thanks to [Mads Bjerre](https://github.com/madsb) + +#### Dependency Updates: +* [ws@5.2.0](https://www.npmjs.com/package/ws) +* [commander@2.15.1](https://www.npmjs.com/package/commander) +* [nodemon@1.17.5](https://www.npmjs.com/package/nodemon) + +##### Devevelopment Dependencies Updates: +* [flow-bin@0.73.0](https://www.npmjs.com/package/flow-bin) +* [cross-env@5.1.6](https://www.npmjs.com/package/cross-env) +* [gaze@1.1.3](https://www.npmjs.com/package/gaze) +* [deepcopy@1.0.0](https://www.npmjs.com/package/deepcopy) +* [deep-diff@1.0.1](https://www.npmjs.com/package/deep-diff) + + +### 2.8.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.1...2.8.0) + +Ensure all the files are properly exported to the final package. + +### 2.8.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.8.0...2.7.4) + +#### New Features +* Adding Mongodb element to add `arrayMatches` the #4762 (#4766), thanks to [Jérémy Piednoel](https://github.com/jeremypiednoel) +* Adds ability to Lockout users (#4749), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug fixes: +* Fixes issue when using afterFind with relations (#4752), thanks to [Florent Vilmart](https://github.com/flovilmart) +* New query condition support to match all strings that starts with some other given strings (#3864), thanks to [Eduard Bosch Bertran](https://github.com/eduardbosch) +* Allow creation of indices on default fields (#4738), thanks to [Claire Neveu](https://github.com/ClaireNeveu) +* Purging empty class (#4676), thanks to [Diamond Lewis](https://github.com/dplewis) +* Postgres: Fixes issues comparing to zero or false (#4667), thanks to [Diamond Lewis](https://github.com/dplewis) +* Fix Aggregate Match Pointer (#4643), thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Improvements: +* Allow Parse.Error when returning from Cloud Code (#4695), thanks to [Saulo Tauil](https://github.com/saulogt) +* Fix typo: "requrest" -> "request" (#4761), thanks to [Joseph Frazier](https://github.com/josephfrazier) +* Send version for Vkontakte API (#4725), thanks to [oleg](https://github.com/alekoleg) +* Ensure we respond with invalid password even if email is unverified (#4708), thanks to [dblythy](https://github.com/dblythy) +* Add _password_history to default sensitive data (#4699), thanks to [Jong Eun Lee](https://github.com/yomybaby) +* Check for node version in postinstall script (#4657), thanks to [Diamond Lewis](https://github.com/dplewis) +* Remove FB Graph API version from URL to use the oldest non deprecated version, thanks to [SebC](https://github.com/SebC99) + +#### Dependency Updates: +* [@parse/push-adapter@2.0.3](https://www.npmjs.com/package/@parse/push-adapter) +* [@parse/simple-mailgun-adapter@1.0.2](https://www.npmjs.com/package/@parse/simple-mailgun-adapter) +* [uws@10.148.0](https://www.npmjs.com/package/uws) +* [body-parser@1.18.3](https://www.npmjs.com/package/body-parser) +* [mime@2.3.1](https://www.npmjs.com/package/mime) +* [request@2.85.0](https://www.npmjs.com/package/request) +* [mongodb@3.0.7](https://www.npmjs.com/package/mongodb) +* [bcrypt@2.0.1](https://www.npmjs.com/package/bcrypt) +* [ws@5.1.1](https://www.npmjs.com/package/ws) + +##### Devevelopment Dependencies Updates: +* [cross-env@5.1.5](https://www.npmjs.com/package/cross-env) +* [flow-bin@0.71.0](https://www.npmjs.com/package/flow-bin) +* [deep-diff@1.0.0](https://www.npmjs.com/package/deep-diff) +* [nodemon@1.17.3](https://www.npmjs.com/package/nodemon) + + +### 2.7.4 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.7.4...2.7.3) + +#### Bug Fixes: +* Fixes an issue affecting polygon queries, thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Dependency Updates: +* [pg-promise@8.2.1](https://www.npmjs.com/package/pg-promise) + +##### Development Dependencies Updates: +* [nodemon@1.17.1](https://www.npmjs.com/package/nodemon) + +### 2.7.3 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.7.3...2.7.2) + +#### Improvements: +* Improve documentation for LiveQuery options, thanks to [Arthur Cinader](https://github.com/acinader) +* Improve documentation for using cloud code with docker, thanks to [Stephen Tuso](https://github.com/stephentuso) +* Adds support for Facebook's AccountKit, thanks to [6thfdwp](https://github.com/6thfdwp) +* Disable afterFind routines when running aggregates, thanks to [Diamond Lewis](https://github.com/dplewis) +* Improve support for distinct aggregations of nulls, thanks to [Diamond Lewis](https://github.com/dplewis) +* Regenreate the email verification token when requesting a new email, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) + +#### Bug Fixes: +* Fix issue affecting readOnly masterKey and purge command, thanks to [AreyouHappy](https://github.com/AreyouHappy) +* Fixes Issue unsetting in beforeSave doesn't allow object creation, thanks to [Diamond Lewis](https://github.com/dplewis) +* Fixes issue crashing server on invalid live query payload, thanks to [fridays](https://github.com/fridays) +* Fixes issue affecting postgres storage adapter "undefined property '__op'", thanks to [Tyson Andre](https://github,com/TysonAndre) + +#### Dependency Updates: +* [winston@2.4.1](https://www.npmjs.com/package/winston) +* [pg-promise@8.2.0](https://www.npmjs.com/package/pg-promise) +* [commander@2.15.0](https://www.npmjs.com/package/commander) +* [lru-cache@4.1.2](https://www.npmjs.com/package/lru-cache) +* [parse@1.11.1](https://www.npmjs.com/package/parse) +* [ws@5.0.0](https://www.npmjs.com/package/ws) +* [mongodb@3.0.4](https://www.npmjs.com/package/mongodb) +* [lodash@4.17.5](https://www.npmjs.com/package/lodash) + +##### Devevelopment Dependencies Updates: +* [cross-env@5.1.4](https://www.npmjs.com/package/cross-env) +* [flow-bin@0.67.1](https://www.npmjs.com/package/flow-bin) +* [jasmine@3.1.0](https://www.npmjs.com/package/jasmine) +* [parse@1.11.1](https://www.npmjs.com/package/parse) +* [babel-eslint@8.2.2](https://www.npmjs.com/package/babel-eslint) +* [nodemon@1.15.0](https://www.npmjs.com/package/nodemon) + +### 2.7.2 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.7.2...2.7.1) + +#### Improvements: +* Improved match aggregate +* Do not mark the empty push as failed +* Support pointer in aggregate query +* Introduces flow types for storage +* Postgres: Refactoring of Postgres Storage Adapter +* Postgres: Support for multiple projection in aggregate +* Postgres: performance optimizations +* Adds infos about vulnerability disclosures +* Adds ability to login with email when provided as username + +#### Bug Fixes +* Scrub Passwords with URL Encoded Characters +* Fixes issue affecting using sorting in beforeFind + +#### Dependency Updates: +* [commander@2.13.0](https://www.npmjs.com/package/commander) +* [semver@5.5.0](https://www.npmjs.com/package/semver) +* [pg-promise@7.4.0](https://www.npmjs.com/package/pg-promise) +* [ws@4.0.0](https://www.npmjs.com/package/ws) +* [mime@2.2.0](https://www.npmjs.com/package/mime) +* [parse@1.11.0](https://www.npmjs.com/package/parse) + +##### Devevelopment Dependencies Updates: +* [nodemon@1.14.11](https://www.npmjs.com/package/nodemon) +* [flow-bin@0.64.0](https://www.npmjs.com/package/flow-bin) +* [jasmine@2.9.0](https://www.npmjs.com/package/jasmine) +* [cross-env@5.1.3](https://www.npmjs.com/package/cross-env) + +### 2.7.1 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.7.1...2.7.0) + +:warning: Fixes a security issue affecting Class Level Permissions + +* Adds support for dot notation when using matchesKeyInQuery, thanks to [Henrik](https://github.com/bohemima) and [Arthur Cinader](https://github.com/acinader) + +### 2.7.0 +[Full Changelog](https://github.com/parse-community/parse-server/compare/2.7.0...2.6.5) + +:warning: This version contains an issue affecting Class Level Permissions on mongoDB. Please upgrade to 2.7.1. + +Starting parse-server 2.7.0, the minimun nodejs version is 6.11.4, please update your engines before updating parse-server + +#### New Features: +* Aggregation endpoints, thanks to [Diamond Lewis](https://github.com/dplewis) +* Adds indexation options onto Schema endpoints, thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Bug fixes: +* Fixes sessionTokens being overridden in 'find' (#4332), thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Proper `handleShutdown()` feature to close database connections (#4361), thanks to [CHANG, TZU-YEN](https://github.com/trylovetom) +* Fixes issue affecting state of _PushStatus objects, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Fixes issue affecting calling password reset password pages with wrong appid, thanks to [Bryan de Leon](https://github.com/bryandel) +* Fixes issue affecting duplicates _Sessions on successive logins, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements: +* Updates contributing guides, and improves windows support, thanks to [Addison Elliott](https://github.com/addisonelliott) +* Uses new official scoped packaged, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Improves health checks responses, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Add password confirmation to choose_password, thanks to [Worathiti Manosroi](https://github.com/pungme) +* Improve performance of relation queries, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Dependency Updates: +* [commander@2.12.1](https://www.npmjs.com/package/commander) +* [ws@3.3.2](https://www.npmjs.com/package/ws) +* [uws@9.14.0](https://www.npmjs.com/package/uws) +* [pg-promise@7.3.2](https://www.npmjs.com/package/pg-promise) +* [parse@1.10.2](https://www.npmjs.com/package/parse) +* [pg-promise@7.3.1](https://www.npmjs.com/package/pg-promise) + +##### Devevelopment Dependencies Updates: +* [cross-env@5.1.1](https://www.npmjs.com/package/cross-env) + + ### 2.6.5 [Full Changelog](https://github.com/ParsePlatform/parse-server/compare/2.6.5...2.6.4) @@ -955,7 +1550,7 @@ Other fixes by [Mathias Rangel Wulff](https://github.com/mathiasrw) * Fix: Improve compatability of cloud code beforeSave hook for newly created object * Fix: ACL creation for master key only objects * Fix: Allow uploading files without Content-Type -* Fix: Add features to http requrest to match Parse.com +* Fix: Add features to http request to match Parse.com * Fix: Bugs in development script when running from locations other than project root * Fix: Can pass query constraints in URL * Fix: Objects with legacy "_tombstone" key now don't cause issues. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 8a5e2e8807..5f6271c8e5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at florent@flovilmart.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at codeofconduct@parseplatform.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bfedf9b32..756a2efeab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,95 @@ -### Contributing to Parse Server +# Contributing to Parse Server -#### Pull Requests Welcome! +We really want Parse to be yours, to see it grow and thrive in the open source community. -We really want Parse to be yours, to see it grow and thrive in the open source community. +If you are not familiar with Pull Requests and want to know more about them, you can visit the [Creating a pull request](https://help.github.com/articles/creating-a-pull-request/) article. It contains detailed informations about the process. -##### Please Do's +## Setting up the project for debugging and contributing: -* Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `npm test spec/MyFile.spec.js` -* Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html -* Lint your code by running `npm run lint` to make sure all your code is not gonna be rejected by the CI. -* Never publish the lib folder. +### Recommended setup: -##### Run your tests against Postgres (optional) +* [vscode](https://code.visualstudio.com), the popular IDE. +* [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline. + +### Setting up you local machine: + +* [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine: + +```sh +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server # go into the clone directory +$ npm install # install all the node dependencies +$ code . # launch vscode +$ npm run watch # run babel watching for local file changes +``` + +> To launch VS Code from the terminal with the `code` command you first need to follow the [launching from the command line section](https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line) in the VS Code setup documentation. + +Once you have babel running in watch mode, you can start making changes to parse-server. + +### Good to know: + +* The `lib/` folder is not commited, so never make changes in there. +* Always make changes to files in the `src/` folder. +* All the tests should point to sources in the `lib/` folder. + +### Troubleshooting: + +*Question*: I modify the code in the src folder but it doesn't seem to have any effect.
+*Answer*: Check that `npm run watch` is running + +*Question*: How do I use breakpoints and debug step by step?
+*Answer*: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them. + +*Question*: How do I deploy my forked version on my servers?
+*Answer*: In your `package.json`, update the `parse-server` dependency to `https://github.com/MY_USERNAME/parse-server#MY_FEATURE`. Run `npm install`, commit the changes and deploy to your servers. + + +### Please Do's + +* Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server. +* Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage` +* Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js` +* Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at */coverage/lcov-report/index.html*. +* Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI. +* **Do not** publish the *lib* folder. + +### Run your tests against Postgres (optional) If your pull request introduces a change that may affect the storage or retrieval of objects, you may want to make sure it plays nice with Postgres. -* Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres npm test`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L37) +* Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres npm test`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L37) or use [`Docker`](#run-a-parse-postgres-with-docker). +* The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug` * If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with: - `describe_only_db('mongo')` // will create a `describe` that runs only on mongoDB - `it_only_db('mongo')` // will make a test that only runs on mongo - `it_exclude_dbs(['postgres'])` // will make a test that runs against all DB's but postgres -##### Code of Conduct +#### Run a Parse Postgres with Docker + +To launch the compatible Postgres instance, copy and paste the following line into your shell: + +```sh +docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_USER=$USER --rm mdillon/postgis:11-alpine && sleep 5 && docker exec -it parse-postgres psql -U $USER -c 'create database parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U $USER -c 'CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U $USER -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database +``` +To stop the Postgres instance: + +```sh +docker stop parse-postgres +``` + +### Generate Parse Server Config Definition + +If you want to make changes to [Parse Server Configuration][config] add the desired configuration to [src/Options/index.js][config-index] and run `npm run definitions`. This will output [src/Options/Definitions.js][config-def] and [src/Options/docs.js][config-docs]. + +To view docs run `npm run docs` and check the `/out` directory. + +### Code of Conduct This project adheres to the [Contributor Covenant Code of Conduct](https://github.com/parse-community/parse-server/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to honor this code. + +[config]: http://parseplatform.org/parse-server/api/master/ParseServerOptions.html +[config-def]: https://github.com/parse-community/parse-server/blob/master/src/Options/Definitions.js +[config-docs]: https://github.com/parse-community/parse-server/blob/master/src/Options/docs.js +[config-index]: https://github.com/parse-community/parse-server/blob/master/src/Options/index.js diff --git a/Dockerfile b/Dockerfile index bf30a303d1..bfc6b98d84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,36 @@ -FROM node:boron +# Build stage +FROM node:lts-alpine as build -RUN mkdir -p /parse-server -COPY ./ /parse-server/ +RUN apk update; \ + apk add git; +WORKDIR /tmp +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build -RUN mkdir -p /parse-server/config -VOLUME /parse-server/config +# Release stage +FROM node:lts-alpine as release -RUN mkdir -p /parse-server/cloud -VOLUME /parse-server/cloud +RUN apk update; \ + apk add git; + +VOLUME /parse-server/cloud /parse-server/config WORKDIR /parse-server -RUN npm install && \ - npm run build +COPY package*.json ./ -ENV PORT=1337 +RUN npm ci --production --ignore-scripts +COPY bin bin +COPY public_html public_html +COPY views views +COPY --from=build /tmp/lib lib +RUN mkdir -p logs && chown -R node: logs + +ENV PORT=1337 +USER node EXPOSE $PORT -ENTRYPOINT ["npm", "start", "--"] +ENTRYPOINT ["node", "./bin/parse-server"] diff --git a/README.md b/README.md index 7b1bc1fc9e..b935b83e03 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,104 @@ -![Parse Server logo](.github/parse-server-logo.png?raw=true) - -[![Backers on Open Collective](https://opencollective.com/parse-server/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/parse-server/sponsors/badge.svg)](#sponsors) -[![Build Status](https://img.shields.io/travis/parse-community/parse-server/master.svg?style=flat)](https://travis-ci.org/parse-community/parse-server) -[![Coverage Status](https://img.shields.io/codecov/c/github/parse-community/parse-server/master.svg)](https://codecov.io/github/parse-community/parse-server?branch=master) -[![npm version](https://img.shields.io/npm/v/parse-server.svg?style=flat)](https://www.npmjs.com/package/parse-server) - -[![Join Chat](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/ParsePlatform/Chat) - -Parse Server is an [open source version of the Parse backend](http://blog.parse.com/announcements/introducing-parse-server-and-the-database-migration-tool/) that can be deployed to any infrastructure that can run Node.js. +

+ Parse Server + +

+ +

+ Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. +

+ + +

+ Follow on Twitter + Build status + Coverage status + npm version + Join the conversation + Greenkeeper badge +

+ +

+ MongoDB 3.6 + MongoDB 4.0 +

+ +

Our Sponsors

+

+

Our backers and sponsors help to ensure the quality and timely development of the Parse Platform.

+
+ 🥉 Bronze Sponsors + +
+ +

+

+ Backers on Open Collective + Sponsors on Open Collective +

+
Parse Server works with the Express web application framework. It can be added to existing web applications, or run by itself. -# Getting Started +The full documentation for Parse Server is available in the [wiki](https://github.com/parse-community/parse-server/wiki). The [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/) is a good place to get started. An [API reference](http://parseplatform.org/parse-server/api/) and [Cloud Code guide](https://docs.parseplatform.org/cloudcode/guide/) are also available. If you're interested in developing for Parse Server, the [Development guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) will help you get set up. + +- [Getting Started](#getting-started) + - [Running Parse Server](#running-parse-server) + - [Locally](#locally) + - [Docker](#inside-a-docker-container) + - [Saving an Object](#saving-your-first-object) + - [Connect an SDK](#connect-your-app-to-parse-server) + - [Running elsewhere](#running-parse-server-elsewhere) + - [Sample Application](#parse-server-sample-application) + - [Parse Server + Express](#parse-server--express) + - [Configuration](#configuration) + - [Basic Options](#basic-options) + - [Client Key Options](#client-key-options) + - [Email Verification & Password Reset](#email-verification-and-password-reset) + - [Custom Pages](#custom-pages) + - [Using Environment Variables](#using-environment-variables-to-configure-parse-server) + - [Available Adapters](#available-adapters) + - [Configuring File Adapters](#configuring-file-adapters) + - [Logging](#logging) +- [Live Queries](#live-queries) +- [GraphQL (beta)](#graphql-beta) +- [Upgrading to 3.0.0](#upgrading-to-300) +- [Support](#support) +- [Ride the Bleeding Edge](#want-to-ride-the-bleeding-edge) +- [Contributing](#contributing) +- [Contributors](#contributors) +- [Sponsors](#sponsors) +- [Backers](#backers) -[![Greenkeeper badge](https://badges.greenkeeper.io/parse-community/parse-server.svg)](https://greenkeeper.io/) - -April 2016 - We created a series of video screencasts, please check them out here: [http://blog.parse.com/learn/parse-server-video-series-april-2016/](http://blog.parse.com/learn/parse-server-video-series-april-2016/) +# Getting Started The fastest and easiest way to get started is to run MongoDB and Parse Server locally. ## Running Parse Server +Before you start make sure you have installed: + +- [NodeJS](https://www.npmjs.com/) that includes `npm` +- [MongoDB](https://www.mongodb.com/) or [PostgreSQL](https://www.postgresql.org/) +- Optionally [Docker](https://www.docker.com/) + ### Locally -``` +```bash $ npm install -g parse-server mongodb-runner $ mongodb-runner start $ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test ``` ***Note:*** *If installation with* `-g` *fails due to permission problems* (`npm ERR! code 'EACCES'`), *please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions).* - + ### Inside a Docker container -``` +```bash +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server $ docker build --tag parse-server . $ docker run --name my-mongo -d mongo -$ docker run --name my-parse-server --link my-mongo:mongo -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test -``` +$ docker run --name my-parse-server -v cloud-code-vol:/parse-server/cloud -v config-vol:/parse-server/config -p 1337:1337 --link my-mongo:mongo -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test +``` You can use any arbitrary string as your application id and master key. These will be used by your clients to authenticate with the Parse Server. @@ -49,7 +111,7 @@ That's it! You are now running a standalone version of Parse Server on your mach Now that you're running Parse Server, it is time to save your first object. We'll use the [REST API](http://docs.parseplatform.org/rest/guide), but you can easily do the same using any of the [Parse SDKs](http://parseplatform.org/#sdks). Run the following: ```bash -curl -X POST \ +$ curl -X POST \ -H "X-Parse-Application-Id: APPLICATION_ID" \ -H "Content-Type: application/json" \ -d '{"score":1337,"playerName":"Sean Plott","cheatMode":false}' \ @@ -86,7 +148,7 @@ $ curl -X GET \ Keeping tracks of individual object ids is not ideal, however. In most cases you will want to run a query over the collection, like so: -``` +```bash $ curl -X GET \ -H "X-Parse-Application-Id: APPLICATION_ID" \ http://localhost:1337/parse/classes/GameScore @@ -112,7 +174,7 @@ To learn more about using saving and querying objects on Parse Server, check out ### Connect your app to Parse Server -Parse provides SDKs for all the major platforms. Refer to the Parse Server guide to [learn how to connect your app to Parse Server](https://github.com/parse-community/parse-server/wiki/Parse-Server-Guide#using-parse-sdks-with-parse-server). +Parse provides SDKs for all the major platforms. Refer to the Parse Server guide to [learn how to connect your app to Parse Server](https://docs.parseplatform.org/parse-server/guide/#using-parse-sdks-with-parse-server). ## Running Parse Server elsewhere @@ -130,7 +192,7 @@ We have provided a basic [Node.js application](https://github.com/parse-communit * [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04) * [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server) * [Back4app](http://blog.back4app.com/2016/03/01/quick-wizard-migration/) -* [Gomix](https://gomix.com/#!/project/parse-server) +* [Glitch](https://glitch.com/edit/#!/parse-server) * [Flynn](https://flynn.io/blog/parse-apps-on-flynn) ### Parse Server + Express @@ -159,35 +221,15 @@ app.listen(1337, function() { }); ``` -For a full list of available options, run `parse-server --help`. - -## Logging - -Parse Server will, by default, log: -* to the console -* daily rotating files as new line delimited JSON - -Logs are also be viewable in Parse Dashboard. - -**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` - -**Want logs to be in placed in other folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` - -**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL` - -**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc.)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` - -# Documentation - -The full documentation for Parse Server is available in the [wiki](https://github.com/parse-community/parse-server/wiki). The [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/) is a good place to get started. If you're interested in developing for Parse Server, the [Development guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) will help you get set up. +For a full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations](http://parseplatform.org/parse-server/api/master/ParseServerOptions.html). ## Configuration Parse Server can be configured using the following options. You may pass these as parameters when running a standalone `parse-server`, or by loading a configuration file in JSON format using `parse-server path/to/configuration.json`. If you're using Parse Server on Express, you may also pass these to the `ParseServer` object as options. -For the full list of available options, run `parse-server --help`. +For the full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations](http://parseplatform.org/parse-server/api/master/ParseServerOptions.html). -#### Basic options +### Basic options * `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app. * `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app. @@ -197,7 +239,7 @@ For the full list of available options, run `parse-server --help`. * `cloud` - The absolute path to your cloud code `main.js` file. * `push` - Configuration options for APNS and GCM push. See the [Push Notifications quick start](http://docs.parseplatform.org/parse-server/guide/#push-notifications_push-notifications-quick-start). -#### Client key options +### Client key options The client keys used with Parse are no longer necessary with Parse Server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys. @@ -206,39 +248,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo * `restAPIKey` * `dotNetKey` -#### Advanced options - -* `fileKey` - For migrated apps, this is necessary to provide access to files already hosted on Parse. -* `allowClientClassCreation` - Set to false to disable client class creation. Defaults to true. -* `enableAnonymousUsers` - Set to false to disable anonymous users. Defaults to true. -* `auth` - Used to configure support for [3rd party authentication](http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication). -* `facebookAppIds` - An array of valid Facebook application IDs that users may authenticate with. -* `mountPath` - Mount path for the server. Defaults to `/parse`. -* `filesAdapter` - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/parse-community/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)). -* `maxUploadSize` - Max file size for uploads. Defaults to 20 MB. -* `loggerAdapter` - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/parse-community/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)). -* `logLevel` - Set the specific level you want to log. Defaults to `info`. The default logger uses the npm log levels as defined by the underlying winston logger. Check [Winston logging levels](https://github.com/winstonjs/winston#logging-levels) for details on values to specify. -* `sessionLength` - The length of time in seconds that a session should be valid for. Defaults to 31536000 seconds (1 year). -* `maxLimit` - The maximum value supported for the limit option on queries. Defaults to unlimited. -* `revokeSessionOnPasswordReset` - When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. -* `accountLockout` - Lock account when a malicious user is attempting to determine an account password by trial and error. -* `passwordPolicy` - Optional password policy rules to enforce. -* `customPages` - A hash with urls to override email verification links, password reset links and specify frame url for masking user-facing pages. Available keys: `parseFrameURL`, `invalidLink`, `choosePassword`, `passwordResetSuccess`, `verifyEmailSuccess`. -* `middleware` - (CLI only), a module name, function that is an express middleware. When using the CLI, the express app will load it just **before** mounting parse-server on the mount path. This option is useful for injecting a monitoring middleware. -* `masterKeyIps` - The array of ip addresses where masterKey usage will be restricted to only these ips. (Default to [] which means allow all ips). If you're using this feature and have `useMasterKey: true` in cloudcode, make sure that you put your own ip in this list. -* `readOnlyMasterKey` - A masterKey that has full read access to the data, but no write access. This key should be treated the same way as your masterKey, keeping it private. - -##### Logging - -Use the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server` to save your server logfiles to the specified folder. - -Usage: - -``` -PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY -``` - -##### Email verification and password reset +### Email verification and password reset Verifying user email addresses and enabling password reset via email requires an email adapter. As part of the `parse-server` package we provide an adapter for sending email through Mailgun. To use it, sign up for Mailgun, and add this to your initialization code: @@ -271,7 +281,7 @@ var server = ParseServer({ appName: 'Parse App', // The email adapter emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', + module: '@parse/simple-mailgun-adapter', options: { // The address that your emails come from fromAddress: 'parse@example.com', @@ -290,14 +300,15 @@ var server = ParseServer({ }, // optional settings to enforce password policies passwordPolicy: { - // Two optional settings to enforce strong passwords. Either one or both can be specified. + // Two optional settings to enforce strong passwords. Either one or both can be specified. // If both are specified, both checks must pass to accept the password - // 1. a RegExp object or a regex string representing the pattern to enforce + // 1. a RegExp object or a regex string representing the pattern to enforce validatorPattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})/, // enforce password with at least 8 char with at least 1 lower case, 1 upper case and 1 digit - // 2. a callback function to be invoked to validate the password - validatorCallback: (password) => { return validatePassword(password) }, + // 2. a callback function to be invoked to validate the password + validatorCallback: (password) => { return validatePassword(password) }, + validationError: 'Password must contain at least 1 digit.' // optional error message to be sent instead of the default "Password does not meet the Password Policy requirements." message. doNotAllowUsername: true, // optional setting to disallow username in passwords - maxPasswordAge: 90, // optional setting in days for password expiry. Login fails if user does not reset the password within this period after signup/last reset. + maxPasswordAge: 90, // optional setting in days for password expiry. Login fails if user does not reset the password within this period after signup/last reset. maxPasswordHistory: 5, // optional setting to prevent reuse of previous n passwords. Maximum value that can be specified is 20. Not specifying it or specifying 0 will not enforce history. //optional setting to set a validity duration for password reset links (in seconds) resetTokenValidityDuration: 24*60*60, // expire after 24 hours @@ -306,13 +317,37 @@ var server = ParseServer({ ``` You can also use other email adapters contributed by the community such as: +- [parse-smtp-template (Multi Language and Multi Template)](https://www.npmjs.com/package/parse-smtp-template) - [parse-server-postmark-adapter](https://www.npmjs.com/package/parse-server-postmark-adapter) - [parse-server-sendgrid-adapter](https://www.npmjs.com/package/parse-server-sendgrid-adapter) - [parse-server-mandrill-adapter](https://www.npmjs.com/package/parse-server-mandrill-adapter) - [parse-server-simple-ses-adapter](https://www.npmjs.com/package/parse-server-simple-ses-adapter) - [parse-server-mailgun-adapter-template](https://www.npmjs.com/package/parse-server-mailgun-adapter-template) +- [parse-server-sendinblue-adapter](https://www.npmjs.com/package/parse-server-sendinblue-adapter) - [parse-server-mailjet-adapter](https://www.npmjs.com/package/parse-server-mailjet-adapter) - [simple-parse-smtp-adapter](https://www.npmjs.com/package/simple-parse-smtp-adapter) +- [parse-server-generic-email-adapter](https://www.npmjs.com/package/parse-server-generic-email-adapter) + +### Custom Pages + +It’s possible to change the default pages of the app and redirect the user to another path or domain. + +```js +var server = ParseServer({ + ...otherOptions, + + customPages: { + passwordResetSuccess: "http://yourapp.com/passwordResetSuccess", + verifyEmailSuccess: "http://yourapp.com/verifyEmailSuccess", + parseFrameURL: "http://yourapp.com/parseFrameURL", + linkSendSuccess: "http://yourapp.com/linkSendSuccess", + linkSendFail: "http://yourapp.com/linkSendFail", + invalidLink: "http://yourapp.com/invalidLink", + invalidVerificationLink: "http://yourapp.com/invalidVerificationLink", + choosePassword: "http://yourapp.com/choosePassword" + } +}) +``` ### Using environment variables to configure Parse Server @@ -324,7 +359,7 @@ PARSE_SERVER_APPLICATION_ID PARSE_SERVER_MASTER_KEY PARSE_SERVER_DATABASE_URI PARSE_SERVER_URL -PARSE_SERVER_CLOUD_CODE_MAIN +PARSE_SERVER_CLOUD ``` The default port is 1337, to use a different port set the PORT environment variable: @@ -333,24 +368,376 @@ The default port is 1337, to use a different port set the PORT environment varia $ PORT=8080 parse-server --appId APPLICATION_ID --masterKey MASTER_KEY ``` -For the full list of configurable environment variables, run `parse-server --help`. +For the full list of configurable environment variables, run `parse-server --help` or take a look at [Parse Server Configuration](https://github.com/parse-community/parse-server/blob/master/src/Options/Definitions.js). ### Available Adapters -[Parse Server Modules (Adapters)](https://github.com/parse-server-modules) + +All official adapters are distributed as scoped pacakges on [npm (@parse)](https://www.npmjs.com/search?q=scope%3Aparse). + +Some well maintained adapters are also available on the [Parse Server Modules](https://github.com/parse-server-modules) organization. + +You can also find more adapters maintained by the community by searching on [npm](https://www.npmjs.com/search?q=parse-server%20adapter&page=1&ranking=optimal). ### Configuring File Adapters Parse Server allows developers to choose from several options when hosting files: -* `GridStoreAdapter`, which is backed by MongoDB; +* `GridFSBucketAdapter`, which is backed by MongoDB; * `S3Adapter`, which is backed by [Amazon S3](https://aws.amazon.com/s3/); or * `GCSAdapter`, which is backed by [Google Cloud Storage](https://cloud.google.com/storage/) -`GridStoreAdapter` is used by default and requires no setup, but if you're interested in using S3 or Google Cloud Storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). +`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using S3 or Google Cloud Storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). + +### Logging + +Parse Server will, by default, log: +* to the console +* daily rotating files as new line delimited JSON + +Logs are also viewable in Parse Dashboard. + +**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +**Want logs to be in placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL` + +**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +# Live Queries + +Live queries are meant to be used in real-time reactive applications, where just using the traditional query paradigm could cause several problems, like increased response time and high network and server usage. Live queries should be used in cases where you need to continuously update a page with fresh data coming from the database, which often happens in (but is not limited to) online games, messaging clients and shared to-do lists. + +Take a look at [Live Query Guide](https://docs.parseplatform.org/parse-server/guide/#live-queries), [Live Query Server Setup Guide](https://docs.parseplatform.org/parse-server/guide/#scalability) and [Live Query Protocol Specification](https://github.com/parse-community/parse-server/wiki/Parse-LiveQuery-Protocol-Specification). You can setup a standalone server or multiple instances for scalability (recommended). + +# GraphQL (beta) + +[GraphQL](https://graphql.org/), developed by Facebook, is an open-source data query and manipulation language for APIs. In addition to the traditional REST API, Parse Server automatically generates a GraphQL API based on your current application schema. Parse Server also allows you to define your custom GraphQL queries and mutations, whose resolvers can be bound to your cloud code functions. + +⚠️ The Parse GraphQL ```beta``` implementation is fully functional but discussions are taking place on how to improve it. So new versions of Parse Server can bring breaking changes to the current API. + +## Running + +### Using the CLI + +The easiest way to run the Parse GraphQL API is through the CLI: + +```bash +$ npm install -g parse-server mongodb-runner +$ mongodb-runner start +$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground +``` + +After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps. + +### Using Docker + +You can also run the Parse GraphQL API inside a Docker container: + +```bash +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server +$ docker build --tag parse-server . +$ docker run --name my-mongo -d mongo +$ docker run --name my-parse-server --link my-mongo:mongo -v cloud-code-vol:/parse-server/cloud -v config-vol:/parse-server/config -p 1337:1337 -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground +``` + +After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps. + +### Using Express.js + +You can also mount the GraphQL API in an Express.js application together with the REST API or solo. You first need to create a new project and install the required dependencies: + +```bash +$ mkdir my-app +$ cd my-app +$ npm install parse-server express --save +``` + +Then, create an `index.js` file with the following content: + +```js +const express = require('express'); +const { default: ParseServer, ParseGraphQLServer } = require('parse-server'); + +const app = express(); + +const parseServer = new ParseServer({ + databaseURI: 'mongodb://localhost:27017/test', + appId: 'APPLICATION_ID', + masterKey: 'MASTER_KEY', + serverURL: 'http://localhost:1337/parse', + publicServerURL: 'http://localhost:1337/parse' +}); + +const parseGraphQLServer = new ParseGraphQLServer( + parseServer, + { + graphQLPath: '/graphql', + playgroundPath: '/playground' + } +); + +app.use('/parse', parseServer.app); // (Optional) Mounts the REST API +parseGraphQLServer.applyGraphQL(app); // Mounts the GraphQL API +parseGraphQLServer.applyPlayground(app); // (Optional) Mounts the GraphQL Playground - do NOT use in Production + +app.listen(1337, function() { + console.log('REST API running on http://localhost:1337/parse'); + console.log('GraphQL API running on http://localhost:1337/graphql'); + console.log('GraphQL Playground running on http://localhost:1337/playground'); +}); +``` + +And finally start your app: + +```bash +$ npx mongodb-runner start +$ node index.js +``` + +After starting the app, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +***Note:*** Do ***NOT*** mount the GraphQL Playground in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps. + +## Checking the API health + +Run the following: + +```graphql +query Health { + health +} +``` + +You should receive the following response: + +```json +{ + "data": { + "health": true + } +} +``` + +## Creating your first class + +Since your application does not have any schema yet, you can use the `createClass` mutation to create your first class. Run the following: + +```graphql +mutation CreateClass { + createClass( + name: "GameScore" + schemaFields: { + addStrings: [{ name: "playerName" }] + addNumbers: [{ name: "score" }] + addBooleans: [{ name: "cheatMode" }] + } + ) { + name + schemaFields { + name + __typename + } + } +} +``` + +You should receive the following response: + +```json +{ + "data": { + "createClass": { + "name": "GameScore", + "schemaFields": [ + { + "name": "objectId", + "__typename": "SchemaStringField" + }, + { + "name": "updatedAt", + "__typename": "SchemaDateField" + }, + { + "name": "createdAt", + "__typename": "SchemaDateField" + }, + { + "name": "playerName", + "__typename": "SchemaStringField" + }, + { + "name": "score", + "__typename": "SchemaNumberField" + }, + { + "name": "cheatMode", + "__typename": "SchemaBooleanField" + }, + { + "name": "ACL", + "__typename": "SchemaACLField" + } + ] + } + } +} +``` + +## Using automatically generated operations + +Parse Server learned from the first class that you created and now you have the `GameScore` class in your schema. You can now start using the automatically generated operations! + +Run the following to create your first object: + +```graphql +mutation CreateGameScore { + createGameScore( + fields: { + playerName: "Sean Plott" + score: 1337 + cheatMode: false + } + ) { + id + updatedAt + createdAt + playerName + score + cheatMode + ACL + } +} +``` + +You should receive a response similar to this: + +```json +{ + "data": { + "createGameScore": { + "id": "XN75D94OBD", + "updatedAt": "2019-09-17T06:50:26.357Z", + "createdAt": "2019-09-17T06:50:26.357Z", + "playerName": "Sean Plott", + "score": 1337, + "cheatMode": false, + "ACL": null + } + } +} +``` + +You can also run a query to this new class: + +```graphql +query GameScores { + gameScores { + results { + id + updatedAt + createdAt + playerName + score + cheatMode + ACL + } + } +} +``` + +You should receive a response similar to this: + +```json +{ + "data": { + "gameScores": { + "results": [ + { + "id": "XN75D94OBD", + "updatedAt": "2019-09-17T06:50:26.357Z", + "createdAt": "2019-09-17T06:50:26.357Z", + "playerName": "Sean Plott", + "score": 1337, + "cheatMode": false, + "ACL": null + } + ] + } + } +} +``` + +## Customizing your GraphQL Schema + +Parse GraphQL Server allows you to create a custom GraphQL schema with own queries and mutations to be merged with the auto-generated ones. You can resolve these operations using your regular cloud code functions. + +To start creating your custom schema, you need to code a `schema.graphql` file and initialize Parse Server with `--graphQLSchema` and `--cloud` options: + +```bash +$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test --publicServerURL http://localhost:1337/parse --cloud ./cloud/main.js --graphQLSchema ./cloud/schema.graphql --mountGraphQL --mountPlayground +``` + +### Creating your first custom query + +Use the code below for your `schema.graphql` and `main.js` files. Then restart your Parse Server. + +```graphql +# schema.graphql +extend type Query { + hello: String! @resolve +} +``` + +```js +// main.js +Parse.Cloud.define('hello', async () => { + return 'Hello world!'; +}); +``` + +You can now run your custom query using GraphQL Playground: + +```graphql +query { + hello +} +``` + +You should receive the response below: + +```json +{ + "data": { + "hello": "Hello world!" + } +} +``` + +## Learning more + +The [Parse GraphQL Guide](http://docs.parseplatform.org/graphql/guide/) is a very good source for learning how to use the Parse GraphQL API. + +You also have a very powerful tool inside your GraphQL Playground. Please look at the right side of your GraphQL Playground. You will see the `DOCS` and `SCHEMA` menus. They are automatically generated by analyzing your application schema. Please refer to them and learn more about everything that you can do with your Parse GraphQL API. + +Additionally, the [GraphQL Learn Section](https://graphql.org/learn/) is a very good source to learn more about the power of the GraphQL language. + +# Upgrading to 3.0.0 + +Starting 3.0.0, parse-server uses the JS SDK version 2.0. +In short, parse SDK v2.0 removes the backbone style callbacks as well as the Parse.Promise object in favor of native promises. +All the Cloud Code interfaces also have been updated to reflect those changes, and all backbone style response objects are removed and replaced by Promise style resolution. + +We have written up a [migration guide](3.0.0.md), hoping this will help you transition to the next major release. # Support -For implementation related questions or technical support, please refer to the [Stack Overflow](http://stackoverflow.com/questions/tagged/parse.com) and [Server Fault](https://serverfault.com/tags/parse) communities. +Please take a look at our [support document](https://github.com/parse-community/.github/blob/master/SUPPORT.md). If you believe you've found an issue with Parse Server, make sure these boxes are checked before [reporting an issue](https://github.com/parse-community/parse-server/issues): @@ -362,64 +749,37 @@ If you believe you've found an issue with Parse Server, make sure these boxes ar # Want to ride the bleeding edge? -The `latest` branch in this repository is automatically maintained to be the last -commit to `master` to pass all tests, in the same form found on npm. It is -recommend to use builds deployed npm for many reasons, but if you want to use +It is recommend to use builds deployed npm for many reasons, but if you want to use the latest not-yet-released version of parse-server, you can do so by depending directly on this branch: ``` -npm install parseplatform/parse-server.git#latest +npm install parse-community/parse-server.git#master ``` -# Contributing +## Experimenting -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). +You can also use your own forks, and work in progress branches by specifying them: ------ +``` +npm install github:myUsername/parse-server#my-awesome-feature +``` -As of April 5, 2017, Parse, LLC has transferred this code to the parse-community organization, and will no longer be contributing to or distributing this code. +And don't forget, if you plan to deploy it remotely, you should run `npm install` with the `--save` option. +# Contributing -# Backers +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). + +# Contributors -Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/parse-server#backer)] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +This project exists thanks to all the people who contribute... we'd love to see your face on this list! + # Sponsors -Become a sponsor and get your logo on our README on Github with a link to your site. [[Become a sponsor](https://opencollective.com/parse-server#sponsor)] +Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [Become a sponsor!](https://opencollective.com/parse-server#sponsor) @@ -452,3 +812,12 @@ Become a sponsor and get your logo on our README on Github with a link to your s +# Backers + +Support us with a monthly donation and help us continue our activities. [Become a backer!](https://opencollective.com/parse-server#backer) + + + +----- + +As of April 5, 2017, Parse, LLC has transferred this code to the parse-community organization, and will no longer be contributing to or distributing this code. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..fc9f24a973 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,101 @@ +# Parse Community Vulnerability Disclosure Program +If you believe you have found a security vulnerability on one of parse-community maintained packages, +we encourage you to let us know right away. +We will investigate all legitimate reports and do our best to quickly fix the problem. +Before making a report, please review this page to understand our disclosure policy and how to communicate with us. + +# Responsible Disclosure Policy +If you comply with the policies below when reporting a security issue to parse community, +we will not initiate a lawsuit or law enforcement investigation against you in response to your report. +We ask that: + +- You give us reasonable time to investigate and mitigate an issue you report before making public any information about the report or sharing such information with others. This means we request _at least_ **7 days** to get back to you with an initial response and _at least_ **30 days** from initial contact (made by you) to apply a patch. +- You do not interact with an individual account (which includes modifying or accessing data from the account) if the account owner has not consented to such actions. +- You make a good faith effort to avoid privacy violations and disruptions to others, including (but not limited to) destruction of data and interruption or degradation of our services. +- You do not exploit a security issue you discover for any reason. (This includes demonstrating additional risk, such as attempted compromise of sensitive company data or probing for additional issues). You do not violate any other applicable laws or regulations. + +# Communicating with us + +All vulnerabilities should be privately reported to either [Node Security](https://nodesecurity.io/report) or directly to us at the following address [security at parseplatform dot org](mailto:security@parseplatform.org) + +You can use our PGP public key, which is also uploaded [here](hkp://pgp.mit.edu): + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFbZHTcBEADMJledXkBantsiKc5fbln3j+Bj3R2fP6xcUZ4N6RdKj/19G8e4 ++Lwso/SEDlKKuh+1ORHrcXbYBPNRTi+syf0dtL6uqNKVS+jzuS48qd7G04Foe+qs +rg5k80TfRLboCoESIS4C8E6sdjCMKEj8b+QQU8YyzL470+gYwgg7bfvHyECuS4AD +lPssBi03cQdVlYjxNWQZAfVMZ+5zcvpS4P5KOCZPT082rzlgQEmVpmNuTyBELNtl +TBcVK9Sq6/KlNNSXMbGfJlMMq0kgAzVxrSyx3y0gOnRx1DR+a5jJSecPtdVJYno8 +9mwRT6Z1B/boN6GmEhC3vikmsOmA+umaLoscQcwjQj7jK5rPTF8ypuDfVNa+kAUS +ONFrayDQljwMEVHZ5/lk9TfEwrnarN8q0fRs2MXaJsD/YlTHG5/9LJs3mMk5yQpq +VGq0sydprnubW36nbP0SkH2LMRrLhQWoLEvtjkz7EaqGLWKO6N0Nr+BT1YBy5gM+ +evc5mUeHUTPqflDht1crHn0rdfWmtDzEsNUWc9GR1hK2+x8U43YUPDmmgRYZyCGP +iKdmrF0kUDlh2mmok3dXlQCZesXaeFvSbIFMfL7midhbiWyCfDtAIQPfBTKNtfc3 +qbaAoEHmYS2Yjri0rRqK9zbFqDgOR7Ap/ExeoOuaAMx1bvjV0QBm0W8q+QARAQAB +tC1BcnRodXIgQ2luYWRlciAoR2l0aHViKSA8YXJ0aHVyQHBvcHN1Z2FyLmNvbT6J +Aj0EEwEKACcFAloYZqECGwMFCQeGH4AFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AA +CgkQgZHETYyfyECCKA/8CbpKrMJn+UhP4s5eUisx6wSfqDWuHGkvhecxTWLRGGRT +yycDm7PJxSb3AdJ//sUTGemG88kpLXmEGt3HpINqB0B4J+aqTB/Ei0+1g/FH0LXP +RlCehH0RpLHJmplkEbd2VZ8wFN9+tW1u4jhG+LCZD8pAVy7f36QixCZA3fdlt9GN +K2Jq2456dMpHmaLdUbrYERcDSKmDVKBRa8/CTe9hAkA83kAt0xgWjr/Byxw+L3wi +Ar4/twAwLAHCzl7HTVvbWOXYehM8dpybE7rFV/1OACg3i2uppLE1oGeS2s4HBv84 +WYNx0oBlBzEefpDAxz1NQI4HnKtBopt8jNUs5GEa1GR4eSNdMf9SmX7MRBNgDKuY +PsvZQLUBqG8GYZR214NzK9wf0VkQDkZ+PwG+L5pnpKtc7RwsR49z2qyti/nZfPP7 +y9gJanTNPkzgx2YAk+UBrKL7435XfFAW6mo2y5LLbD6ouT2hGDfnhsSuMrS4bAdM +7ua9B8vs2cnwYXUFM7ydAueaPvfP0x5i0ZQrphls3ZUpKRpWORSXa0fTNinSpzqW +YzTmPxJsHsyioPlRsl2/r97I9XJ9i5gjMDkNI3TQpGKFy/YNMk7rkk1dp3hq3aP/ +xt0P/2yL/MJEj9Jus9FTKGqVtOn73e8oSOsu0ngpllYasYaLkO19MJ2lemSW+CC0 +LEFydGh1ciBDaW5hZGVyIChHaXRodWIpIDxhY2luYWRlckBnbWFpbC5jb20+iQI9 +BBMBCgAnBQJaGGJcAhsDBQkHhh+ABQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJ +EIGRxE2Mn8hAGVcP/RkqkER8/AKWfPFQs40Epe3mocuLyEW1CHX5LkFTjya42GAM +0BKk+bStRrMQ4rBGOmdKGxphysQFZn4bscRUVMmJd/frJ0s8ConSfWzaweL7rbQO +UgGnL4mSNUvQkNCoO/RgKJapq9G/+jA9RRYEoSncE1/i3FQ+96JWfRRYy3MGYi9H +WmH3UFQ8cJ1JAFMIGaxuHuNQ20mStVDSuK3Zm8KVxk8rWHb2O8lye4bcBi7OLXYx +oZEoLrbLQinMbuccNaMq2j3ZNLOPYUDyyv5O81WzN70A7r0rkipOaJx4LiXE2/NT +3vz1CyT7i+2/GlLL113DP0DA8neMjx6MzpxOo7MgT+ZBHRRZh+tWoqfJKclh6Duw +rAJ9BOxSCm1y4BxTxuWrb5mU/RDCe3oC7PTA6wIMbJThqxtRpjqa17oWn2UXyJOH +aEXvt6jH6YqqFV9liArwkjZZl4KKyiqZ8UFKLteIVSK5xlwQ/ICW3uPYRpYhIFj0 +fMaqN5SFcMOxtD4L5SP4k7HRn8l/gVoWQyIMJMip87sPCw7mRe5jq91n9s33stHr +vByL0ownS5MmvKXLLAyAltw2FcIyafcn6mKNGMUBunM14/j5uXaMcgz3MQtYjkvk +Fh6uX1OqLt/rpOhsRTeDRvjGvAFtdLt1QtDEz4i9kGN4h4B/XqwEbVNMWyv4tDlB +cnRodXIgQ2luYWRlciA8NzAwNTcyK2FjaW5hZGVyQHVzZXJzLm5vcmVwbHkuZ2l0 +aHViLmNvbT6JAj0EEwEKACcFAloYaaMCGwMFCQeGH4AFCwkIBwMFFQoJCAsFFgID +AQACHgECF4AACgkQgZHETYyfyEANHBAAuOkRMEoCuRjN3Dz/bP7SpWSFnBjOWW42 +Lbie3bXbT1SYRltd7AM3ICu2M8OzjATzrDimmGi7K4qxFIGnz+sjp9NRr6x7Ohgi +bPwmU1OMIjuARPhsauUyyUNI+wKbRG9/tO0YxOUBadsKcVYY+6JxhsjrO5qb9NUI +WaNvwfCPlSBDcvsKCOVu6weyw9FGpaaKZcscge8tPPEQCf7FYKy6NYPVK6/D7qn6 +myaKe/dh/HwozZ0o2NhW3uIAdd4OIvmWE7rh97B7afKXTiIfiqWqtkFhH0RxdR2q +Damg0BiGjdARqSnneLKDPgIwr904yM1RD36BkPcP8WH3ommsK95mrUKrZtLAQA6J +J6uESkuHNtcy5XTx4eF2cD2uaJTcRjlbAHFBMEI/+vr4umo+8wt38JhY+XtSot6W +rS99JU6Ht1/SMYdz/rFisOWHb6hS69DOSCEK68lne6n0u1AnsWnDHwbQxcaSEreR +axXMzgMtRuM5R4ncLpx0nUwhxlRoIyo2GN6aghXcCrZt1fsLXBilag1moxZgh+YE +RaVOsBASuqO/5m609Mi8AGLbuLU+39Ekb/b2ozw/MRvGPNfXC1XIqPe4asEE9GNL +XdVqvrHhEexBpv7El9yQ9qyllzEEdv5+soMcUQmjJAVabx+0gtLb5x3QHD4V8ttT +kA8kUPG5MyO5Ag0EVtkdNwEQAKssJS3MZiu6WkBact/HvDjJrq+S1HcxeTLYbFXK +lEsolW5sw0IX5ORM9+Z9LfUTyVcyU6w/UbM91IecjNnFQkMvIQy8lVhrqO20FL46 +Vu6G5HezIf2hg/1vgt891hrKMrySQDDyGo68f6uF3U+SJLeNPRoB4O8qL2RHXfC3 +3ti6FAoOFfRGe/CNB35viK/L//6O3pCFz/nrckEaMzH/GOrcZ8xlrFyeKhsOjtoR +S2MDSNpIJfZP+pbtBgVW5lA5HDlyy5s52jXgd0+1Ktw1FV1uCjsgaX9xfbfXG8o1 +SxpKpj1dI8WQ/7ZuCTxu0phyJsQPmfIHb5kBvZjm4vqpnCfbbFWxsQE+T01PRsV+ +rWdh1EG4dlTMkvZtMfAnDZV+Cqf6FELb/KhrbRqlCjHeC99tn6YP9EpvLNIgUnD6 +qiV2QVHMKZ+wRfRUAYUBtvbFYqbbEqLySpW0ahPB/UmLUMjvArzrQkxvKFM20nb8 +HnAAKAZpgjhXTO9OBiNErCfiORooZLEs1MBeR1u8932GL/uKSDX0RhTYBBFDVoNy +zGj3lW3YfnCurVIjCoj+jAZGMSVi67GnUuhm0Vj2K4mdSbq40TwhXxKlp8G0uSU4 +SmCm+yjTVcgQj+Xj+fsFJh6YGIgkcLEpbZS6kCLKcnx+44U3nZYPZch0+3/m8Uaf +i3e5ABEBAAGJAiUEGAEKAA8FAlbZHTcCGwwFCQeGH4AACgkQgZHETYyfyEBEcQ/8 +DXyIYahE4JmY4REkdSnTQQ09etNmlqZbnMo1y7aYqDgqoixGpZAyE5U3oxGMeNBD +P+XEaZGDav9wfiOlnofMXBa65kbtWoz/+dLc+sTAjNdWvucuzP0yiE0+RNkOtvmY +5BlGgIQS9PTRaw86aRFOE5LilAoR/jv+mOMPt1dcLfHksmCpW+3OzPyxCA703fE5 +l7xOXYOAhPGMco30EftebbZkiaAmoZFese92pRenTJXi007ALhMpjPbk5D7717DZ +4/g2gqT+Zs8fZe4tUHjo8LSQrFh/i3TpyBoAIouJsuvVvXy0r+iucKvfBjB4vdQb +b33Fft2DYVBMpVVfnjRg1Y+p5IFNWByI5NYfFsf8AWLHhOWargYmiUjHMdDFXuea +3QUTzHARp4HsqoZocjhKEoW5+j0MTVM6q7cTGgkNvAUmlPEzpvjQP84zkeM7gskP +vaKjgp0gIaCMlzP2fRSKqQ2f84LhKj0mZDy7HQNhtKme1l014HgTbbP7GDJ2UMse +uHgdaLLljuHFbHYAgGI7Uck225weDESF8enizh1ZF1itRliN47ICsef1RQJCgrJb +dkoPBN52k7VhS3vUIQhA1P1sLSEtPMuJ8SDq0CuA008WpU/xHdm1b+xcBxrabuoz +6jfgzgnAZveF5DMisrOnbi4GHVIiHXvWrrIglA6o1sM= +=paxU +-----END PGP PUBLIC KEY BLOCK----- +``` diff --git a/bin/dev b/bin/dev deleted file mode 100755 index 5549b75d29..0000000000 --- a/bin/dev +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node - -var nodemon = require('nodemon'); -var babel = require("babel-core"); -var gaze = require('gaze'); -var fs = require('fs'); -var path = require('path'); - -// Watch the src and transpile when changed -gaze('src/**/*', function(err, watcher) { - if (err) throw err; - watcher.on('changed', function(sourceFile) { - console.log(sourceFile + " has changed"); - try { - targetFile = path.relative(__dirname, sourceFile).replace(/\/src\//, '/lib/'); - targetFile = path.resolve(__dirname, targetFile); - fs.writeFile(targetFile, babel.transformFileSync(sourceFile).code); - } catch (e) { - console.error(e.message, e.stack); - } - }); -}); - -try { - // Run and watch dist - nodemon({ - script: 'bin/parse-server', - ext: 'js json', - watch: 'lib' - }); -} catch (e) { - console.error(e.message, e.stack); -} - -process.once('SIGINT', function() { - process.exit(0); -}); \ No newline at end of file diff --git a/bootstrap.sh b/bootstrap.sh index 3f8b3db6ed..c36f0ad402 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' @@ -163,7 +163,7 @@ cat > ./package.json << EOF "start": "parse-server config.json" }, "dependencies": { - "parse-server": "^2.0.0" + "parse-server": "^3.9.0" } } EOF diff --git a/jsdoc-conf.json b/jsdoc-conf.json new file mode 100644 index 0000000000..ad059acda1 --- /dev/null +++ b/jsdoc-conf.json @@ -0,0 +1,23 @@ +{ + "plugins": ["node_modules/jsdoc-babel", "plugins/markdown"], + "babel": { + "plugins": ["@babel/plugin-transform-flow-strip-types"] + }, + "source": { + "include": ["./README.md", "./src/cloud-code", "./src/Options/docs.js", "./src/ParseServer.js", "./src/Adapters"], + "excludePattern": "(^|\\/|\\\\)_" + }, + "templates": { + "default": { + "outputSourceFiles": false, + "showInheritedInNav": false, + "useLongnameInNav": true + }, + "cleverLinks": true, + "monospaceLinks": false + }, + "opts": { + "template": "node_modules/@parse/minami", + "recurse": true + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..f98b840ddf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12713 @@ +{ + "name": "parse-server", + "version": "4.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@apollo/protobufjs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.0.3.tgz", + "integrity": "sha512-gqeT810Ect9WIqsrgfUvr+ljSB5m1PyBae9HGdrRyQ3HjHjTcjVvxpsMYXlUk4rUHnrfUqyoGvLSy2yLlRGEOw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "dependencies": { + "@types/node": { + "version": "10.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.17.tgz", + "integrity": "sha512-gpNnRnZP3VWzzj5k3qrpRC6Rk3H/uclhAVo1aIvwzK5p5cOrs9yEyQ8H/HBsBY0u5rrWxXEiVPQ0dEB6pkjE8Q==" + } + } + }, + "@apollographql/apollo-tools": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.4.3.tgz", + "integrity": "sha512-CtC1bmohB1owdGMT2ZZKacI94LcPAZDN2WvCe+4ZXT5d7xO5PHOAb70EP/LcFbvnS8QI+pkYRSCGFQnUcv9efg==", + "requires": { + "apollo-env": "^0.6.1" + } + }, + "@apollographql/graphql-playground-html": { + "version": "1.6.24", + "resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.24.tgz", + "integrity": "sha512-8GqG48m1XqyXh4mIZrtB5xOhUwSsh1WsrrsaZQOEYYql3YN9DEu9OOSg0ILzXHZo/h2Q74777YE4YzlArQzQEQ==" + }, + "@babel/cli": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.8.4.tgz", + "integrity": "sha512-XXLgAm6LBbaNxaGhMAznXXaxtCWfuv6PIDJ9Alsy9JYTOh+j2jJz+L/162kkfU1j/pTSxK1xGmlwI4pdIMkoag==", + "dev": true, + "requires": { + "chokidar": "^2.1.8", + "commander": "^4.0.1", + "convert-source-map": "^1.1.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.0.0", + "lodash": "^4.17.13", + "make-dir": "^2.1.0", + "slash": "^2.0.0", + "source-map": "^0.5.0" + }, + "dependencies": { + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/compat-data": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.9.0.tgz", + "integrity": "sha512-zeFQrr+284Ekvd9e7KAX954LkapWiOmQtsfHirhxqfdlX6MEC32iRE+pqUGlYIBchdevaCwvzxWGSy/YBNI85g==", + "dev": true, + "requires": { + "browserslist": "^4.9.1", + "invariant": "^2.2.4", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@babel/core": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.9.0.tgz", + "integrity": "sha512-kWc7L0fw1xwvI0zi8OKVBuxRVefwGOrKSQMvrQ3dW+bIIavBY3/NpXmpjMy7bQnLgwgzWQZ8TlM57YHpHNHz4w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helpers": "^7.9.0", + "@babel/parser": "^7.9.0", + "@babel/template": "^7.8.6", + "@babel/traverse": "^7.9.0", + "@babel/types": "^7.9.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.13", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz", + "integrity": "sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz", + "integrity": "sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-call-delegate": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.8.7.tgz", + "integrity": "sha512-doAA5LAKhsFCR0LAFIf+r2RSMmC+m8f/oQ+URnUET/rWeEzC0yTRmAGyWkD4sSu3xwbS7MYQ2u+xlt1V5R56KQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.8.3", + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.7" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-compilation-targets": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.8.7.tgz", + "integrity": "sha512-4mWm8DCK2LugIS+p1yArqvG1Pf162upsIsjE7cNBjez+NjliQpVhj20obE520nao0o14DaTnFJv+Fw5a0JpoUw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.8.6", + "browserslist": "^4.9.1", + "invariant": "^2.2.4", + "levenary": "^1.1.1", + "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.8.tgz", + "integrity": "sha512-LYVPdwkrQEiX9+1R29Ld/wTrmQu1SSKYnuOk3g0CkcZMA1p0gsNxJFj/3gBdaJ7Cg0Fnek5z0DsMULePP7Lrqg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-regex": "^7.8.3", + "regexpu-core": "^4.7.0" + } + }, + "@babel/helper-define-map": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz", + "integrity": "sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/types": "^7.8.3", + "lodash": "^4.17.13" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz", + "integrity": "sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz", + "integrity": "sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz", + "integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-module-imports": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz", + "integrity": "sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-module-transforms": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz", + "integrity": "sha512-0FvKyu0gpPfIQ8EkxlrAydOWROdHpBmiCiRwLkUiBGhCUPRRbVD2/tm3sFr/c/GWFrQ/ffutGUAnx7V0FzT2wA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-simple-access": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/template": "^7.8.6", + "@babel/types": "^7.9.0", + "lodash": "^4.17.13" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz", + "integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-plugin-utils": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz", + "integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.8.3.tgz", + "integrity": "sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==", + "dev": true, + "requires": { + "lodash": "^4.17.13" + } + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz", + "integrity": "sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-wrap-function": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-replace-supers": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz", + "integrity": "sha512-PeMArdA4Sv/Wf4zXwBKPqVj7n9UF/xg6slNRtZW84FM7JpE1CbG8B612FyM4cxrf4fMAMGO0kR7voy1ForHHFA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.8.6", + "@babel/types": "^7.8.6" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-simple-access": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz", + "integrity": "sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", + "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz", + "integrity": "sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helpers": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.0.tgz", + "integrity": "sha512-/9GvfYTCG1NWCNwDj9e+XlnSCmWW/r9T794Xi58vPF9WCcnZCAZ0kWLSn54oqP40SUvh1T2G6VwKmFO5AOlW3A==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.9.0", + "@babel/types": "^7.9.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.0.tgz", + "integrity": "sha512-onl4Oy46oGCzymOXtKMQpI7VXtCbTSHK1kqBydZ6AmzuNcacEVqGk9tZtAS+48IA9IstZcDCgIg8hQKnb7suRw==", + "dev": true, + "requires": { + "@babel/types": "^7.9.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.0.tgz", + "integrity": "sha512-jAZQj0+kn4WTHO5dUZkZKhbFrqZE7K5LAQ5JysMnmvGij+wOdr+8lWqPeW0BcF4wFwrEXXtdGO7wcV6YPJcf3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.4.tgz", + "integrity": "sha512-jIwvLO0zCL+O/LmEJQjWA75MQTWwx3c3u2JOTDK5D3/9egrWRRA0/0hk9XXywYnXZVVpzrBYeIQTmhwUaePI9g==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz", + "integrity": "sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-remap-async-to-generator": "^7.8.3", + "@babel/plugin-syntax-async-generators": "^7.8.0" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.8.3.tgz", + "integrity": "sha512-NyaBbyLFXFLT9FP+zk0kYlUlA8XtCUbehs67F0nnEg7KICgMc2mNkIeu9TYhKzyXMkrapZFwAhXLdnt4IYHy1w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-dynamic-import": "^7.8.0" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.8.3.tgz", + "integrity": "sha512-KGhQNZ3TVCQG/MjRbAUwuH+14y9q0tpxs1nWWs3pbSleRdDro9SAMMDyye8HhY1gqZ7/NqIc8SKhya0wRDgP1Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.0" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-TS9MlfzXpXKt6YYomudb/KU7nQI6/xnapG6in1uZxoxDghuSMZsPb6D2fyUwNYSAp4l1iR7QtFOjkqcRYcUsfw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.8.3.tgz", + "integrity": "sha512-jWioO1s6R/R+wEHizfaScNsAx+xKgwTLNXSh7tTC4Usj3ItsPEhYkEpU4h+lpnBwq7NBVOJXfO6cRFYcX69JUQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.9.0.tgz", + "integrity": "sha512-UgqBv6bjq4fDb8uku9f+wcm1J7YxJ5nT7WO/jBr0cl0PLKb7t1O6RNR1kZbjgx2LQtsDI9hwoQVmn0yhXeQyow==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-0gkX7J7E+AtAw9fcwlVQj8peP61qhdg/89D5swOkjYbkboA2CVckn3kiyum1DE0wskGb7KJJxBdyEBApDLLVdw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.9.0.tgz", + "integrity": "sha512-NDn5tu3tcv4W30jNhmc2hyD5c56G6cXx4TesJubhxrJeCvuuMpttxr0OnNCqbZGhFjLrg+NIhxxC+BK5F6yS3w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.0" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.8.8.tgz", + "integrity": "sha512-EVhjVsMpbhLw9ZfHWSx2iy13Q8Z/eg8e8ccVWt23sWQK5l1UdkoLJPN5w69UA4uITGBnEZD2JOe4QOHycYKv8A==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.8", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.8.3.tgz", + "integrity": "sha512-innAx3bUbA0KSYj2E2MNFSn9hiCeowOFLxlsuhXzw8hMQnzkDomUr9QCD7E9VF60NmnG1sNTuuv6Qf4f8INYsg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.8.3.tgz", + "integrity": "sha512-H7dCMAdN83PcCmqmkHB5dtp+Xa9a6LKSvA2hiFBC/5alSHxM5VgWZXFqDi0YFe8XNGT6iCa+z4V4zSt/PdZ7Dw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.8.3.tgz", + "integrity": "sha512-kwj1j9lL/6Wd0hROD3b/OZZ7MSrZLqqn9RAZ5+cYYsflQ9HZBIKCUkr3+uL1MEJ1NePiUbf98jjiMQSv0NMR9g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz", + "integrity": "sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz", + "integrity": "sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-remap-async-to-generator": "^7.8.3" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz", + "integrity": "sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz", + "integrity": "sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "lodash": "^4.17.13" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.9.0.tgz", + "integrity": "sha512-xt/0CuBRBsBkqfk95ILxf0ge3gnXjEhOHrNxIiS8fdzSWgecuf9Vq2ogLUfaozJgt3LDO49ThMVWiyezGkei7A==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-define-map": "^7.8.3", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-split-export-declaration": "^7.8.3", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz", + "integrity": "sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.8.tgz", + "integrity": "sha512-eRJu4Vs2rmttFCdhPUM3bV0Yo/xPSdPw6ML9KHs/bjB4bLA5HXlbvYXPOD5yASodGod+krjYx21xm1QmL8dCJQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.8.3.tgz", + "integrity": "sha512-kLs1j9Nn4MQoBYdRXH6AeaXMbEJFaFu/v1nQkvib6QzTj8MZI5OQzqmD83/2jEM1z0DLilra5aWO5YpyC0ALIw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz", + "integrity": "sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz", + "integrity": "sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.9.0.tgz", + "integrity": "sha512-7Qfg0lKQhEHs93FChxVLAvhBshOPQDtJUTVHr/ZwQNRccCm4O9D79r9tVSoV8iNwjP1YgfD+e/fgHcPkN1qEQg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-flow": "^7.8.3" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.9.0.tgz", + "integrity": "sha512-lTAnWOpMwOXpyDx06N+ywmF3jNbafZEqZ96CGYabxHrxNX8l5ny7dt4bK/rGwAh9utyP2b2Hv7PlZh1AAS54FQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz", + "integrity": "sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/helper-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", + "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz", + "integrity": "sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.8.3.tgz", + "integrity": "sha512-3Wk2EXhnw+rP+IDkK6BdtPKsUE5IeZ6QOGrPYvw52NwBStw9V1ZVzxgK6fSKSxqUvH9eQPR3tm3cOq79HlsKYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.9.0.tgz", + "integrity": "sha512-vZgDDF003B14O8zJy0XXLnPH4sg+9X5hFBBGN1V+B2rgrB+J2xIypSN6Rk9imB2hSTHQi5OHLrFWsZab1GMk+Q==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.9.0.tgz", + "integrity": "sha512-qzlCrLnKqio4SlgJ6FMMLBe4bySNis8DFn1VkGmOcxG9gqEyPIOzeQrA//u0HAKrWpJlpZbZMPB1n/OPa4+n8g==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-simple-access": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.9.0.tgz", + "integrity": "sha512-FsiAv/nao/ud2ZWy4wFacoLOm5uxl0ExSQ7ErvP7jpoihLR6Cq90ilOFyX9UXct3rbtKsAiZ9kFt5XGfPe/5SQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.8.3", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.9.0.tgz", + "integrity": "sha512-uTWkXkIVtg/JGRSIABdBoMsoIeoHQHPTL0Y2E7xf5Oj7sLqwVsNXOkNk0VJc7vF0IMBsPeikHxFjGe+qmwPtTQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.8.3.tgz", + "integrity": "sha512-f+tF/8UVPU86TrCb06JoPWIdDpTNSGGcAtaD9mLP0aYGA0OS0j7j7DHJR0GTFrUZPUU6loZhbsVZgTh0N+Qdnw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.8.3.tgz", + "integrity": "sha512-QuSGysibQpyxexRyui2vca+Cmbljo8bcRckgzYV4kRIsHpVeyeC3JDO63pY+xFZ6bWOBn7pfKZTqV4o/ix9sFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz", + "integrity": "sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.3" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.8.tgz", + "integrity": "sha512-hC4Ld/Ulpf1psQciWWwdnUspQoQco2bMzSrwU6TmzRlvoYQe4rQFy9vnCZDTlVeCQj0JPfL+1RX0V8hCJvkgBA==", + "dev": true, + "requires": { + "@babel/helper-call-delegate": "^7.8.7", + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + }, + "dependencies": { + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.8.3.tgz", + "integrity": "sha512-uGiiXAZMqEoQhRWMK17VospMZh5sXWg+dlh2soffpkAl96KAm+WZuJfa6lcELotSRmooLqg0MWdH6UUq85nmmg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.7.tgz", + "integrity": "sha512-TIg+gAl4Z0a3WmD3mbYSk+J9ZUH6n/Yc57rtKRnlA/7rcCvpekHXe0CMZHP1gYp7/KLe9GHTuIba0vXmls6drA==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.2" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.8.3.tgz", + "integrity": "sha512-mwMxcycN3omKFDjDQUl+8zyMsBfjRFr0Zn/64I41pmjv4NJuqcYlEtezwYtw9TFd9WR1vN5kiM+O0gMZzO6L0A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz", + "integrity": "sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz", + "integrity": "sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz", + "integrity": "sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-regex": "^7.8.3" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz", + "integrity": "sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.4.tgz", + "integrity": "sha512-2QKyfjGdvuNfHsb7qnBBlKclbD4CfshH2KvDabiijLMGXPHJXGxtDzwIF7bQP+T0ysw8fYTtxPafgfs/c1Lrqg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz", + "integrity": "sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/preset-env": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.9.0.tgz", + "integrity": "sha512-712DeRXT6dyKAM/FMbQTV/FvRCms2hPCx+3weRjZ8iQVQWZejWWk1wwG6ViWMyqb/ouBbGOl5b6aCk0+j1NmsQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.9.0", + "@babel/helper-compilation-targets": "^7.8.7", + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-proposal-async-generator-functions": "^7.8.3", + "@babel/plugin-proposal-dynamic-import": "^7.8.3", + "@babel/plugin-proposal-json-strings": "^7.8.3", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-proposal-numeric-separator": "^7.8.3", + "@babel/plugin-proposal-object-rest-spread": "^7.9.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.8.3", + "@babel/plugin-proposal-optional-chaining": "^7.9.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.8.3", + "@babel/plugin-syntax-async-generators": "^7.8.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.0", + "@babel/plugin-syntax-json-strings": "^7.8.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0", + "@babel/plugin-syntax-numeric-separator": "^7.8.0", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.0", + "@babel/plugin-syntax-top-level-await": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.8.3", + "@babel/plugin-transform-async-to-generator": "^7.8.3", + "@babel/plugin-transform-block-scoped-functions": "^7.8.3", + "@babel/plugin-transform-block-scoping": "^7.8.3", + "@babel/plugin-transform-classes": "^7.9.0", + "@babel/plugin-transform-computed-properties": "^7.8.3", + "@babel/plugin-transform-destructuring": "^7.8.3", + "@babel/plugin-transform-dotall-regex": "^7.8.3", + "@babel/plugin-transform-duplicate-keys": "^7.8.3", + "@babel/plugin-transform-exponentiation-operator": "^7.8.3", + "@babel/plugin-transform-for-of": "^7.9.0", + "@babel/plugin-transform-function-name": "^7.8.3", + "@babel/plugin-transform-literals": "^7.8.3", + "@babel/plugin-transform-member-expression-literals": "^7.8.3", + "@babel/plugin-transform-modules-amd": "^7.9.0", + "@babel/plugin-transform-modules-commonjs": "^7.9.0", + "@babel/plugin-transform-modules-systemjs": "^7.9.0", + "@babel/plugin-transform-modules-umd": "^7.9.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.8.3", + "@babel/plugin-transform-new-target": "^7.8.3", + "@babel/plugin-transform-object-super": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.8.7", + "@babel/plugin-transform-property-literals": "^7.8.3", + "@babel/plugin-transform-regenerator": "^7.8.7", + "@babel/plugin-transform-reserved-words": "^7.8.3", + "@babel/plugin-transform-shorthand-properties": "^7.8.3", + "@babel/plugin-transform-spread": "^7.8.3", + "@babel/plugin-transform-sticky-regex": "^7.8.3", + "@babel/plugin-transform-template-literals": "^7.8.3", + "@babel/plugin-transform-typeof-symbol": "^7.8.4", + "@babel/plugin-transform-unicode-regex": "^7.8.3", + "@babel/preset-modules": "^0.1.3", + "@babel/types": "^7.9.0", + "browserslist": "^4.9.1", + "core-js-compat": "^3.6.2", + "invariant": "^2.2.2", + "levenary": "^1.1.1", + "semver": "^5.5.0" + }, + "dependencies": { + "@babel/types": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.0.tgz", + "integrity": "sha512-BS9JKfXkzzJl8RluW4JGknzpiUV7ZrvTayM6yfqLTVBEnFtyowVIOu6rqxRd5cVO6yGoWf4T8u8dgK9oB+GCng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "@babel/preset-modules": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.3.tgz", + "integrity": "sha512-Ra3JXOHBq2xd56xSF7lMKXdjBn3T772Y1Wet3yWnkDly9zHvJki029tAFzvAAK5cf4YV3yoxuP61crYRol6SVg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/runtime": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.6.3.tgz", + "integrity": "sha512-kq6anf9JGjW8Nt5rYfEuGRaEAaH1mkv3Bbu6rYvLOpPh/RusSJXuKPEAoZ7L7gybZkchE8+NV5g9vKF4AGAtsA==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/runtime-corejs3": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.6.3.tgz", + "integrity": "sha512-933SXHQr7apa95F+3IqkBne8mqOnu1kDh6dnSddC07aW/R51WsOVD7MSczJ6DRpq/L8KLll7TFDxmt30pft44w==", + "requires": { + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz", + "integrity": "sha512-ZR0rq/f/E4f4XcgnDvtMWXCUJpi8eO0rssVhmztsZqLIEFA9UUP9zmpE0VxlM+kv/E1ul2I876Fwil2ayptDVg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, + "@parse/fs-files-adapter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@parse/fs-files-adapter/-/fs-files-adapter-1.0.1.tgz", + "integrity": "sha1-do94QIPo+Wc+9GPhmaG+X4N7QWU=" + }, + "@parse/minami": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@parse/minami/-/minami-1.0.0.tgz", + "integrity": "sha512-Rw+p0WdOOypFPVJsmhyiI+Q056ZxdP2iAtObnU1DZrsvKZTf5x0B/0SjIt0hUgWp+COjqi/p17VdBU9IAD/NJg==", + "dev": true + }, + "@parse/node-apn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@parse/node-apn/-/node-apn-3.1.0.tgz", + "integrity": "sha512-uEf6hL2WOFle5e9JUpbVwYUWYupqeiVS6StibkiYY4Bw3GmjYoZvHZu7DbGxQedJ85EjxuCYXFfopudiUElRpQ==", + "requires": { + "coveralls": "^3.0.6", + "debug": "^3.1.0", + "jsonwebtoken": "^8.1.0", + "node-forge": "^0.7.1", + "verror": "^1.10.0" + } + }, + "@parse/node-gcm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@parse/node-gcm/-/node-gcm-1.0.1.tgz", + "integrity": "sha512-HsQFPwu3amGBaHfjIQ/gIU3hAQoKEymrXC0Ezkl5CNe5ShfqXcCUO9H7/hPkcYoaNDYTVBOglmnuXL5rxBb/xA==", + "requires": { + "debug": "^3.1.0", + "lodash": "^4.17.10", + "request": "2.88.0" + } + }, + "@parse/push-adapter": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-3.2.0.tgz", + "integrity": "sha512-6V7Bnh9+pSRc2U6ONvDPrPN20nRO4YT9eAITiZyVQu2N9WRCE+QoR2wyK7f+iTmyhRbVWP5xeSEoEhOPkS4pWA==", + "requires": { + "@parse/node-apn": "^3.1.0", + "@parse/node-gcm": "^1.0.0", + "npmlog": "^4.0.2", + "parse": "2.8.0" + }, + "dependencies": { + "parse": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-2.8.0.tgz", + "integrity": "sha512-dd6IOPYST+qDqWG22xyZpBLQJ+vqozzE4/43cc0OcKakELoSEsJS43JPaxmELI5/sVxsYYYAqshuPePPnefu5A==", + "requires": { + "@babel/runtime": "7.6.3", + "@babel/runtime-corejs3": "7.6.3", + "uuid": "3.3.3", + "ws": "7.1.2", + "xmlhttprequest": "1.8.0" + }, + "dependencies": { + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + } + } + }, + "ws": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.1.2.tgz", + "integrity": "sha512-gftXq3XI81cJCgkUiAVixA0raD9IVmXqsylCrjRygw4+UOOGzPoxnQ6r/CnVL9i+mDncJo94tSkyrtuuQVBmrg==", + "requires": { + "async-limiter": "^1.0.0" + } + } + } + }, + "@parse/s3-files-adapter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@parse/s3-files-adapter/-/s3-files-adapter-1.4.0.tgz", + "integrity": "sha512-qivvhL09Fqozo6B86PgUZjnY3VZQtxbH+6TtHEIg20Ol9THG/JaHGzSxlWNOBsCf7lvpRp0dELgbhMnMK3LWJA==", + "requires": { + "aws-sdk": "2.59.0", + "parse": "2.10.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.4.tgz", + "integrity": "sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/runtime-corejs3": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.7.4.tgz", + "integrity": "sha512-BBIEhzk8McXDcB3IbOi8zQPzzINUp4zcLesVlBSOcyGhzPUU8Xezk5GAG7Sy5GVhGmAO0zGd2qRSeY2g4Obqxw==", + "requires": { + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.2" + } + }, + "parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-2.10.0.tgz", + "integrity": "sha512-TBJCvQPachrcGGLbN8llN8tOr01VsKB6pxi3OWq3/C0bIHHdb2Bd+cgH4v5ZlRCrZt3MHVasGH4rvx7Klkp7Wg==", + "requires": { + "@babel/runtime": "7.7.4", + "@babel/runtime-corejs3": "7.7.4", + "uuid": "3.3.3", + "ws": "7.2.0", + "xmlhttprequest": "1.8.0" + }, + "dependencies": { + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + } + } + }, + "ws": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.0.tgz", + "integrity": "sha512-+SqNqFbwTm/0DC18KYzIsMTnEWpLwJsiasW/O17la4iDRRIO9uaHbvKiAS3AHgTiuuWerK/brj4O6MYZkei9xg==", + "requires": { + "async-limiter": "^1.0.0" + } + } + } + }, + "@parse/simple-mailgun-adapter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@parse/simple-mailgun-adapter/-/simple-mailgun-adapter-1.1.0.tgz", + "integrity": "sha512-9Eaj25HQ7RpcA6gsTnimXtlLcyLpP9PKSFE9DF79ahgndbdyCjpNd9jQxpBaBlsCsDE+D5LlXfckMqqJPo+pjQ==", + "requires": { + "mailgun-js": "0.18.0" + } + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + }, + "@samverschueren/stream-to-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz", + "integrity": "sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg==", + "dev": true, + "requires": { + "any-observable": "^0.3.0" + } + }, + "@types/accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/connect": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", + "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "requires": { + "@types/node": "*" + } + }, + "@types/cookies": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.4.tgz", + "integrity": "sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw==", + "requires": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "@types/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg==", + "requires": { + "@types/express": "*" + } + }, + "@types/express": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.2.tgz", + "integrity": "sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-jwt": { + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz", + "integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==", + "requires": { + "@types/express": "*", + "@types/express-unless": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz", + "integrity": "sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/express-unless": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.1.tgz", + "integrity": "sha512-5fuvg7C69lemNgl0+v+CUxDYWVPSfXHhJPst4yTLcqi4zKJpORCxnDrnnilk3k0DTq/WrAUdvXFs01+vUqUZHw==", + "requires": { + "@types/express": "*" + } + }, + "@types/fs-capacitor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/fs-capacitor/-/fs-capacitor-2.0.0.tgz", + "integrity": "sha512-FKVPOCFbhCvZxpVAMhdBdTfVfXUpsh15wFHgqOKxh9N9vzWZVuWCSijZ5T4U34XYNnuj2oduh6xcs1i+LPI+BQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/graphql-upload": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@types/graphql-upload/-/graphql-upload-8.0.3.tgz", + "integrity": "sha512-hmLg9pCU/GmxBscg8GCr1vmSoEmbItNNxdD5YH2TJkXm//8atjwuprB+xJBK714JG1dkxbbhp5RHX+Pz1KsCMA==", + "requires": { + "@types/express": "*", + "@types/fs-capacitor": "*", + "@types/koa": "*", + "graphql": "^14.5.3" + } + }, + "@types/http-assert": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", + "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==" + }, + "@types/keygrip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", + "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==" + }, + "@types/koa": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.11.2.tgz", + "integrity": "sha512-2UPelagNNW6bnc1I5kIzluCaheXRA9S+NyOdXEFFj9Az7jc15ek5V03kb8OTbb3tdZ5i2BIJObe86PhHvpMolg==", + "requires": { + "@types/accepts": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "@types/koa-compose": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz", + "integrity": "sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==", + "requires": { + "@types/koa": "*" + } + }, + "@types/long": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", + "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" + }, + "@types/node": { + "version": "12.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.14.tgz", + "integrity": "sha512-u/SJDyXwuihpwjXy7hOOghagLEV1KdAST6syfnOk6QZAMzZuWZqXy5aYYZbh8Jdpd4escVFP0MvftHNDb9pruA==" + }, + "@types/node-fetch": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.4.tgz", + "integrity": "sha512-Oz6id++2qAOFuOlE1j0ouk1dzl3mmI1+qINPNBhi9nt/gVOz0G+13Ao6qjhdF0Ys+eOkhu6JnFmt38bR3H0POQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "@types/ws": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", + "integrity": "sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==", + "requires": { + "@types/node": "*" + } + }, + "@types/zen-observable": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz", + "integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==", + "dev": true + }, + "@wry/context": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.4.4.tgz", + "integrity": "sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag==", + "dev": true, + "requires": { + "@types/node": ">=6", + "tslib": "^1.9.3" + } + }, + "@wry/equality": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.9.tgz", + "integrity": "sha512-mB6ceGjpMGz1ZTza8HYnrPGos2mC6So4NhS1PtZ8s4Qt0K7fBiIGhpSxUbQmhwcSWE3no+bYxmI2OL6KuXYmoQ==", + "requires": { + "tslib": "^1.9.3" + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "optional": true + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", + "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "dev": true + }, + "acorn-jsx": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.1.0.tgz", + "integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==", + "dev": true + }, + "agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ampersand-events": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ampersand-events/-/ampersand-events-2.0.2.tgz", + "integrity": "sha1-9AK8LhgwX6vZldvc07cFe73X00c=", + "dev": true, + "requires": { + "ampersand-version": "^1.0.2", + "lodash": "^4.6.1" + } + }, + "ampersand-state": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/ampersand-state/-/ampersand-state-5.0.3.tgz", + "integrity": "sha512-sr904K5zvw6mkGjFHhTcfBIdpoJ6mn/HrFg7OleRmBpw3apLb3Z0gVrgRTb7kK1wOLI34vs4S+IXqNHUeqWCzw==", + "dev": true, + "requires": { + "ampersand-events": "^2.0.1", + "ampersand-version": "^1.0.0", + "array-next": "~0.0.1", + "key-tree-store": "^1.3.0", + "lodash": "^4.12.0" + } + }, + "ampersand-version": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ampersand-version/-/ampersand-version-1.0.2.tgz", + "integrity": "sha1-/489TOrE0yzNg/a9Zpc5f3tZ4sA=", + "dev": true, + "requires": { + "find-root": "^0.1.1", + "through2": "^0.6.3" + } + }, + "ansi": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", + "integrity": "sha1-DELU+xcWDVqa8eSEus4cZpIsGyE=", + "dev": true + }, + "ansi-escapes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.0.tgz", + "integrity": "sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "any-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", + "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "optional": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "optional": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "apollo-cache": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.4.tgz", + "integrity": "sha512-7X5aGbqaOWYG+SSkCzJNHTz2ZKDcyRwtmvW4mGVLRqdQs+HxfXS4dUS2CcwrAj449se6tZ6NLUMnjko4KMt3KA==", + "dev": true, + "requires": { + "apollo-utilities": "^1.3.3", + "tslib": "^1.10.0" + } + }, + "apollo-cache-control": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.9.0.tgz", + "integrity": "sha512-iLT6IT4Ul5cMfBcJAvhpk3a7AD6fXqvFxNmJEPVapVJHbSKYIjra4PTis13sOyN5Y3WQS6a+NRFxAW8+hL3q3Q==", + "requires": { + "apollo-server-env": "^2.4.3", + "graphql-extensions": "^0.11.0" + } + }, + "apollo-cache-inmemory": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.5.tgz", + "integrity": "sha512-koB76JUDJaycfejHmrXBbWIN9pRKM0Z9CJGQcBzIOtmte1JhEBSuzsOUu7NQgiXKYI4iGoMREcnaWffsosZynA==", + "dev": true, + "requires": { + "apollo-cache": "^1.3.4", + "apollo-utilities": "^1.3.3", + "optimism": "^0.10.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0" + } + }, + "apollo-client": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.6.6.tgz", + "integrity": "sha512-jPMojAVjDKNhSmM+6Bn4cVo4dlqxM6/XazAWPQPL3xRr8K1eYNKvy5iP77K9Z6tnIqGRB/R0sjeg5F2yLqcHZA==", + "dev": true, + "requires": { + "@types/zen-observable": "^0.8.0", + "apollo-cache": "^1.3.4", + "apollo-link": "^1.0.0", + "apollo-utilities": "^1.3.3", + "symbol-observable": "^1.0.2", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0", + "zen-observable": "^0.8.0" + } + }, + "apollo-datasource": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.0.tgz", + "integrity": "sha512-Yja12BgNQhzuFGG/5Nw2MQe0hkuQy2+9er09HxeEyAf2rUDIPnhPrn1MDoZTB8MU7UGfjwITC+1ofzKkkrZobA==", + "requires": { + "apollo-server-caching": "^0.5.1", + "apollo-server-env": "^2.4.3" + } + }, + "apollo-engine-reporting": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-1.7.0.tgz", + "integrity": "sha512-jsjSnoHrRmk4XXK4aFU17YSJILmWsilKRwIeN74QJsSxjn5SCVF4EI/ebf/MNrTHpft8EhShx+wdkAcOD9ivqA==", + "requires": { + "apollo-engine-reporting-protobuf": "^0.4.4", + "apollo-graphql": "^0.4.0", + "apollo-server-caching": "^0.5.1", + "apollo-server-env": "^2.4.3", + "apollo-server-errors": "^2.4.0", + "apollo-server-types": "^0.3.0", + "async-retry": "^1.2.1", + "graphql-extensions": "^0.11.0" + } + }, + "apollo-engine-reporting-protobuf": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.4.4.tgz", + "integrity": "sha512-SGrIkUR7Q/VjU8YG98xcvo340C4DaNUhg/TXOtGsMlfiJDzHwVau/Bv6zifAzBafp2lj0XND6Daj5kyT/eSI/w==", + "requires": { + "@apollo/protobufjs": "^1.0.3" + } + }, + "apollo-env": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/apollo-env/-/apollo-env-0.6.1.tgz", + "integrity": "sha512-B9BgpQGR1ndeDtb4Gtor0J4CITQ+OPACZrVW6lgStnljKEe9ZB76DZ1dAd3OCeizAswW6Lo9uvfK8jhVS5nBhQ==", + "requires": { + "@types/node-fetch": "2.5.4", + "core-js": "^3.0.1", + "node-fetch": "^2.2.0", + "sha.js": "^2.4.11" + } + }, + "apollo-graphql": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.4.0.tgz", + "integrity": "sha512-abCHcKln1EGbzSItW087EjBI5wnluikyUqEn4VsdeWHCtdENWpHCn/MnM0+jJa1prNasxN7tCukp4nMpJYYVqg==", + "requires": { + "apollo-env": "^0.6.1", + "lodash.sortby": "^4.7.0" + } + }, + "apollo-link": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.13.tgz", + "integrity": "sha512-+iBMcYeevMm1JpYgwDEIDt/y0BB7VWyvlm/7x+TIPNLHCTCMgcEgDuW5kH86iQZWo0I7mNwQiTOz+/3ShPFmBw==", + "requires": { + "apollo-utilities": "^1.3.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.9.3", + "zen-observable-ts": "^0.8.20" + } + }, + "apollo-link-http": { + "version": "1.5.16", + "resolved": "https://registry.npmjs.org/apollo-link-http/-/apollo-link-http-1.5.16.tgz", + "integrity": "sha512-IA3xA/OcrOzINRZEECI6IdhRp/Twom5X5L9jMehfzEo2AXdeRwAMlH5LuvTZHgKD8V1MBnXdM6YXawXkTDSmJw==", + "dev": true, + "requires": { + "apollo-link": "^1.2.13", + "apollo-link-http-common": "^0.2.15", + "tslib": "^1.9.3" + } + }, + "apollo-link-http-common": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.15.tgz", + "integrity": "sha512-+Heey4S2IPsPyTf8Ag3PugUupASJMW894iVps6hXbvwtg1aHSNMXUYO5VG7iRHkPzqpuzT4HMBanCTXPjtGzxg==", + "dev": true, + "requires": { + "apollo-link": "^1.2.13", + "ts-invariant": "^0.4.0", + "tslib": "^1.9.3" + } + }, + "apollo-link-ws": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/apollo-link-ws/-/apollo-link-ws-1.0.19.tgz", + "integrity": "sha512-mRXmeUkc55ixOdYRtfq5rq3o9sboKghKABKroDVhJnkdS56zthBEWMAD+phajujOUbqByxjok0te8ABqByBdeQ==", + "dev": true, + "requires": { + "apollo-link": "^1.2.13", + "tslib": "^1.9.3" + } + }, + "apollo-server-caching": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.1.tgz", + "integrity": "sha512-L7LHZ3k9Ao5OSf2WStvQhxdsNVplRQi7kCAPfqf9Z3GBEnQ2uaL0EgO0hSmtVHfXTbk5CTRziMT1Pe87bXrFIw==", + "requires": { + "lru-cache": "^5.0.0" + } + }, + "apollo-server-core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.11.0.tgz", + "integrity": "sha512-jHLOqwTRlyWzqWNRlwr2M/xfrt+lw2pHtKYyxUGRjWFo8EM5TX1gDcTKtbtvx9p5m+ZBDAhcWp/rpq0vSz4tqg==", + "requires": { + "@apollographql/apollo-tools": "^0.4.3", + "@apollographql/graphql-playground-html": "1.6.24", + "@types/graphql-upload": "^8.0.0", + "@types/ws": "^6.0.0", + "apollo-cache-control": "^0.9.0", + "apollo-datasource": "^0.7.0", + "apollo-engine-reporting": "^1.7.0", + "apollo-server-caching": "^0.5.1", + "apollo-server-env": "^2.4.3", + "apollo-server-errors": "^2.4.0", + "apollo-server-plugin-base": "^0.7.0", + "apollo-server-types": "^0.3.0", + "apollo-tracing": "^0.9.0", + "fast-json-stable-stringify": "^2.0.0", + "graphql-extensions": "^0.11.0", + "graphql-tag": "^2.9.2", + "graphql-tools": "^4.0.0", + "graphql-upload": "^8.0.2", + "sha.js": "^2.4.11", + "subscriptions-transport-ws": "^0.9.11", + "ws": "^6.0.0" + }, + "dependencies": { + "fs-capacitor": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-2.0.4.tgz", + "integrity": "sha512-8S4f4WsCryNw2mJJchi46YgB6CR5Ze+4L1h8ewl9tEpL4SJ3ZO+c/bS4BWhB8bK+O3TMqhuZarTitd0S0eh2pA==" + }, + "graphql-upload": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-8.1.0.tgz", + "integrity": "sha512-U2OiDI5VxYmzRKw0Z2dmfk0zkqMRaecH9Smh1U277gVgVe9Qn+18xqf4skwr4YJszGIh7iQDZ57+5ygOK9sM/Q==", + "requires": { + "busboy": "^0.3.1", + "fs-capacitor": "^2.0.4", + "http-errors": "^1.7.3", + "object-path": "^0.11.4" + } + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "apollo-server-env": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.3.tgz", + "integrity": "sha512-23R5Xo9OMYX0iyTu2/qT0EUb+AULCBriA9w8HDfMoChB8M+lFClqUkYtaTTHDfp6eoARLW8kDBhPOBavsvKAjA==", + "requires": { + "node-fetch": "^2.1.2", + "util.promisify": "^1.0.0" + } + }, + "apollo-server-errors": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.0.tgz", + "integrity": "sha512-ZouZfr2sGavvI18rgdRcyY2ausRAlVtWNOax9zca8ZG2io86dM59jXBmUVSNlVZSmBsIh45YxYC0eRvr2vmRdg==" + }, + "apollo-server-express": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.11.0.tgz", + "integrity": "sha512-9bbiD+zFAx+xyurc9lxYmNa9y79k/gsA1vEyPFVcv7jxzCFC5wc0tcbV7NPX2qi1Nn7K76fxo2fPNYbPFX/y0g==", + "requires": { + "@apollographql/graphql-playground-html": "1.6.24", + "@types/accepts": "^1.3.5", + "@types/body-parser": "1.19.0", + "@types/cors": "^2.8.4", + "@types/express": "4.17.2", + "accepts": "^1.3.5", + "apollo-server-core": "^2.11.0", + "apollo-server-types": "^0.3.0", + "body-parser": "^1.18.3", + "cors": "^2.8.4", + "express": "^4.17.1", + "graphql-subscriptions": "^1.0.0", + "graphql-tools": "^4.0.0", + "parseurl": "^1.3.2", + "subscriptions-transport-ws": "^0.9.16", + "type-is": "^1.6.16" + } + }, + "apollo-server-plugin-base": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.7.0.tgz", + "integrity": "sha512-//xgYrBYLQSr92W0z3mYsFGoVz3wxKNsv3KcOUBhbOCGTbjZgP7vHOE1vhHhRcpZKKXmjXTVONdrnNJ+XVGi6A==", + "requires": { + "apollo-server-types": "^0.3.0" + } + }, + "apollo-server-types": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.3.0.tgz", + "integrity": "sha512-FMo7kbTkhph9dfIQ3xDbRLObqmdQH9mwSjxhGsX+JxGMRPPXgd3+GZvCeVKOi/udxh//w1otSeAqItjvbj0tfQ==", + "requires": { + "apollo-engine-reporting-protobuf": "^0.4.4", + "apollo-server-caching": "^0.5.1", + "apollo-server-env": "^2.4.3" + } + }, + "apollo-tracing": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.9.0.tgz", + "integrity": "sha512-oqspTrf4BLGbKkIk1vF+I31C2v7PPJmF36TFpT/+zJxNvJw54ji4ZMhtytgVqbVldQEintJmdHQIidYBGKmu+g==", + "requires": { + "apollo-server-env": "^2.4.3", + "graphql-extensions": "^0.11.0" + } + }, + "apollo-upload-client": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-12.1.0.tgz", + "integrity": "sha512-pAWYDMU9aFZnPvj0g7+FPRCHcslBPwUpnFxSUWiPDJAGYPXzE5C5DKcvRSQMMyQ09akqU6wZsyVk8zhL7GFC8Q==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.4", + "apollo-link": "^1.2.12", + "apollo-link-http-common": "^0.2.14", + "extract-files": "^5.0.1" + } + }, + "apollo-utilities": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.3.tgz", + "integrity": "sha512-F14aX2R/fKNYMvhuP2t9GD9fggID7zp5I96MF5QeKYWDWTrkRdHRp4+SVfXUVN+cXOaB/IebfvRtzPf25CM0zw==", + "requires": { + "@wry/equality": "^0.1.2", + "fast-json-stable-stringify": "^2.0.0", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0" + } + }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true, + "optional": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true, + "optional": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true, + "optional": true + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-next": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-next/-/array-next-0.0.1.tgz", + "integrity": "sha1-5eRmCkwn/agVH/d2QnXQCQAGK+E=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true, + "optional": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-options": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.6.1.tgz", + "integrity": "sha512-jH2pNULN0t3uFLb7Fh0SAuMo/Ei5yWiRirvLez2g+sd16d0xKl+DGdGkD6sqkrZTnCZK5lWRjUa4X3sxHQkg9g==" + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true, + "optional": true + }, + "ast-types": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.2.tgz", + "integrity": "sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==" + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true, + "optional": true + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, + "async-retry": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz", + "integrity": "sha512-aiieFW/7h3hY0Bq5d+ktDBejxuwR78vRu9hDUdR8rNhSaQ29VzPL4AoIRG7D/c7tdenwOcKvgPM6tIxB3cB6HA==", + "requires": { + "retry": "0.12.0" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "optional": true + }, + "aws-sdk": { + "version": "2.59.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.59.0.tgz", + "integrity": "sha1-8kG2SrqIyI4jW4Wz8cHnFUgzGyc=", + "requires": { + "buffer": "5.0.6", + "crypto-browserify": "1.0.9", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.0.1", + "xml2js": "0.4.17", + "xmlbuilder": "4.2.1" + }, + "dependencies": { + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "uuid": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", + "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=" + } + } + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.0.tgz", + "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==" + }, + "babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, + "backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", + "requires": { + "precond": "0.2" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "optional": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "optional": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, + "bcrypt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-4.0.1.tgz", + "integrity": "sha512-hSIZHkUxIDS5zA2o00Kf2O5RfVbQ888n54xQoF/eIaquU4uaLxK8vhhBdktd0B3n2MjkcAWzv4mnhogykBKOUQ==", + "optional": true, + "requires": { + "node-addon-api": "^2.0.0", + "node-pre-gyp": "0.14.0" + } + }, + "bcrypt-nodejs": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/bcrypt-nodejs/-/bcrypt-nodejs-0.0.3.tgz", + "integrity": "sha1-xgkX8m3CNWYVZsaBBhwwPCsohCs=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true, + "optional": true + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + } + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "optional": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "browserslist": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.10.0.tgz", + "integrity": "sha512-TpfK0TDgv71dzuTsEAlQiHeWQ/tiPqgNZVdv046fvNtBZrjbv2O3TsWCDU0AWGJJKCF/KsjNdLzR9hXOsh/CfA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001035", + "electron-to-chromium": "^1.3.378", + "node-releases": "^1.1.52", + "pkg-up": "^3.1.0" + } + }, + "bson": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.3.tgz", + "integrity": "sha512-TdiJxMVnodVS7r0BdL42y/pqC9cL2iKynVwA0Ho3qbsQYr428veL3l7BQyuqiw+Q5SqqoT0m4srSY/BlZ9AxXg==" + }, + "buffer": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.0.6.tgz", + "integrity": "sha1-LqZp9+7Atu2gWwj4tf9mGyhXNYg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", + "dev": true + }, + "buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" + }, + "bunyan": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.12.tgz", + "integrity": "sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c=", + "requires": { + "dtrace-provider": "~0.8", + "moment": "^2.10.6", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "busboy": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz", + "integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==", + "requires": { + "dicer": "0.3.0" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "optional": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001035", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001035.tgz", + "integrity": "sha512-C1ZxgkuA4/bUEdMbU5WrGY4+UhMFFiXrgNAfxiMIqWgFTWfv/xsZCS2xEHT2LMq7xAZfuAnu6mcqyDl0ZR6wLQ==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "catharsis": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.8.11.tgz", + "integrity": "sha512-a+xUyMV7hD1BrDQA/3iPV7oc+6W26BgVJO05PGEoatMyIuPScQKsde6i3YorWX1qs+AZjnJ18NqdKoCtKiNh1g==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, + "caw": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", + "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", + "dev": true, + "requires": { + "get-proxy": "^2.0.0", + "isurl": "^1.0.0-alpha5", + "tunnel-agent": "^0.6.0", + "url-to-options": "^1.0.1" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "cheerio": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz", + "integrity": "sha1-qbqoYKP5tZWmuBsahocxIe06Jp4=", + "dev": true, + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash.assignin": "^4.0.9", + "lodash.bind": "^4.1.4", + "lodash.defaults": "^4.0.1", + "lodash.filter": "^4.4.0", + "lodash.flatten": "^4.2.0", + "lodash.foreach": "^4.3.0", + "lodash.map": "^4.4.0", + "lodash.merge": "^4.4.0", + "lodash.pick": "^4.2.1", + "lodash.reduce": "^4.4.0", + "lodash.reject": "^4.4.0", + "lodash.some": "^4.4.0" + } + }, + "chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "optional": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "optional": true + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "optional": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-0.3.2.tgz", + "integrity": "sha1-dfpfcowwjMSsWUsF4GzF2A2szYY=", + "dev": true, + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.2", + "memoizee": "0.3.x", + "timers-ext": "0.1.x" + } + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-truncate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", + "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "dev": true, + "requires": { + "slice-ansi": "0.0.4", + "string-width": "^1.0.1" + }, + "dependencies": { + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + } + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.0.tgz", + "integrity": "sha512-7kFQgnEaMdRtwf6uSfUnVr9gSGC7faurn+J/Mv90/W+iTtN0405/nLdopfMWwchyxhbGYl6TC4Sccn9TUkGAgg==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "clui": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/clui/-/clui-0.3.6.tgz", + "integrity": "sha512-Z4UbgZILlIAjkEkZiDOa2aoYjohKx7fa6DxIh6cE9A6WNWZ61iXfQc6CmdC9SKdS5nO0P0UyQ+WfoXfB65e3HQ==", + "dev": true, + "requires": { + "cli-color": "0.3.2" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "optional": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colornames": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", + "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=" + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "colorspace": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz", + "integrity": "sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==", + "requires": { + "color": "3.0.x", + "text-hex": "1.0.x" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.0.0.tgz", + "integrity": "sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==" + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "compare-versions": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.5.1.tgz", + "integrity": "sha512-9fGPIB7C6AyM18CJJBHt5EnCZDG3oiTJYy0NjfIAGjKpzv0tkxWko7TNQHF5ymqm7IH03tqmeuBxtvD+Izh6mg==", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", + "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "dev": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "connected-domain": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/connected-domain/-/connected-domain-1.0.0.tgz", + "integrity": "sha1-v+dyOMdL5FOnnwy2BY3utPI1jpM=", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true, + "optional": true + }, + "core-js": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz", + "integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==" + }, + "core-js-compat": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.4.tgz", + "integrity": "sha512-zAa3IZPvsJ0slViBQ2z+vgyyTuhd3MFn1rBQjZSKVEgB0UMYhUkCj9jJUVPgGTGqWvsBVmfnruXgTcNyTlEiSA==", + "dev": true, + "requires": { + "browserslist": "^4.8.3", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "core-js-pure": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.4.5.tgz", + "integrity": "sha512-v3BoUOhmBvs4Z17jG/oM7qyv+tEEMvD1FYDDfxa6uD5W2rA/DpKvhvmyrBzxuMQTa/91UQKisaiqe0+0GuL2oA==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "coveralls": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/coveralls/-/coveralls-3.0.9.tgz", + "integrity": "sha512-nNBg3B1+4iDox5A5zqHKzUTiwl2ey4k2o0NEcVZYvl+GOSJdKBj4AJGKLv6h3SvWch7tABHePAQOSZWM9E2hMg==", + "requires": { + "js-yaml": "^3.13.1", + "lcov-parse": "^1.0.0", + "log-driver": "^1.2.7", + "minimist": "^1.2.0", + "request": "^2.88.0" + } + }, + "cross-env": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.2.tgz", + "integrity": "sha512-KZP/bMEOJEDCkDQAyRhu3RL2ZO/SUVrxQVI0G3YEQ+OLbRA3c6zgixe8Mq8a/z7+HKlNEjo8oiLUs8iRijY2Rw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, + "cross-spawn": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz", + "integrity": "sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "crypto-browserify": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz", + "integrity": "sha1-zFRJaF37hesRyYKKzHy4erW7/MA=" + }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, + "css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "dev": true, + "requires": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "css-what": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", + "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", + "dev": true + }, + "d": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz", + "integrity": "sha1-2hhMU10Y2O57oqoim5FACfrhEwk=", + "dev": true, + "requires": { + "es5-ext": "~0.10.2" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-uri-to-buffer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz", + "integrity": "sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==" + }, + "date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", + "dev": true + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true, + "optional": true + }, + "decompress": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.0.tgz", + "integrity": "sha1-eu3YVCflqS2s/lVnSnxQXpbQH50=", + "dev": true, + "requires": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "requires": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + } + }, + "decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "dependencies": { + "file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true + } + } + }, + "decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + } + }, + "decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=", + "dev": true, + "requires": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "dependencies": { + "file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", + "dev": true + }, + "get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, + "deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "dev": true + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "optional": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" + }, + "deepcopy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.0.0.tgz", + "integrity": "sha512-d5ZK7pJw7F3k6M5vqDjGiiUS9xliIyWkdzBjnPhnSeRGjkYOGZMCFkdKVwV/WiHOe0NwzB8q+iDo7afvSf0arA==", + "requires": { + "type-detect": "^4.0.8" + } + }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "optional": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "degenerator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz", + "integrity": "sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=", + "requires": { + "ast-types": "0.x.x", + "escodegen": "1.x.x", + "esprima": "3.x.x" + }, + "dependencies": { + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "denque": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", + "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "deprecated-decorator": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz", + "integrity": "sha1-AJZjF7ehL+kvPMgx91g68ym4bDc=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "optional": true + }, + "diagnostics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", + "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", + "requires": { + "colorspace": "1.1.x", + "enabled": "1.0.x", + "kuler": "1.0.x" + } + }, + "dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "requires": { + "streamsearch": "0.1.2" + } + }, + "docopt": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/docopt/-/docopt-0.6.2.tgz", + "integrity": "sha1-so6eIiDaXsSffqW7JKR3h0Be6xE=", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", + "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "dev": true, + "requires": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true + }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "downcache": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/downcache/-/downcache-0.0.9.tgz", + "integrity": "sha1-eQuwQkaJE2EVzpPyqhWUbG2AbQ4=", + "dev": true, + "requires": { + "extend": "^3.0.0", + "graceful-fs": "^4.1.3", + "limiter": "^1.1.0", + "mkdirp": "^0.5.1", + "npmlog": "^2.0.3", + "request": "^2.69.0", + "rimraf": "^2.5.2" + }, + "dependencies": { + "gauge": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", + "integrity": "sha1-6c7FSD09TuDvRLYKfZnkk14TbZM=", + "dev": true, + "requires": { + "ansi": "^0.3.0", + "has-unicode": "^2.0.0", + "lodash.pad": "^4.1.0", + "lodash.padend": "^4.1.0", + "lodash.padstart": "^4.1.0" + } + }, + "npmlog": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-2.0.4.tgz", + "integrity": "sha1-mLUlMPJRTKkNCexbIsiEZyI3VpI=", + "dev": true, + "requires": { + "ansi": "~0.3.1", + "are-we-there-yet": "~1.1.2", + "gauge": "~1.2.5" + } + } + } + }, + "download": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/download/-/download-6.2.5.tgz", + "integrity": "sha512-DpO9K1sXAST8Cpzb7kmEhogJxymyVUd5qz/vCOSyvwtp2Klj2XcDt5YUuasgxka44SxF0q5RriKIwJmQHG2AuA==", + "dev": true, + "requires": { + "caw": "^2.0.0", + "content-disposition": "^0.5.2", + "decompress": "^4.0.0", + "ext-name": "^5.0.0", + "file-type": "5.2.0", + "filenamify": "^2.0.0", + "get-stream": "^3.0.0", + "got": "^7.0.0", + "make-dir": "^1.0.0", + "p-event": "^1.0.0", + "pify": "^3.0.0" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "optional": true, + "requires": { + "nan": "^2.14.0" + } + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "electron-to-chromium": { + "version": "1.3.379", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.379.tgz", + "integrity": "sha512-NK9DBBYEBb5f9D7zXI0hiE941gq3wkBeQmXs1ingigA/jnTg5mhwY2Z5egwA+ZI8OLGKCx0h1Cl8/xeuIBuLlg==", + "dev": true + }, + "elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "enabled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", + "requires": { + "env-variable": "0.0.x" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "env-variable": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.5.tgz", + "integrity": "sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + } + } + }, + "es-abstract": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz", + "integrity": "sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es5-ext": { + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", + "dev": true, + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + } + }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + }, + "dependencies": { + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + } + } + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "requires": { + "es6-promise": "^4.0.3" + } + }, + "es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dev": true, + "requires": { + "d": "^1.0.1", + "ext": "^1.1.2" + }, + "dependencies": { + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + } + } + }, + "es6-weak-map": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-0.1.4.tgz", + "integrity": "sha1-cGzvnpmqI2undmwjnIueKG6n0ig=", + "dev": true, + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.6", + "es6-iterator": "~0.1.3", + "es6-symbol": "~2.0.1" + }, + "dependencies": { + "es6-iterator": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", + "integrity": "sha1-1vWLjE/EE8JJtLqhl2j45NfIlE4=", + "dev": true, + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.5", + "es6-symbol": "~2.0.1" + } + }, + "es6-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-2.0.1.tgz", + "integrity": "sha1-dhtcZ8/U8dGK+yNPaR1nhoLLO/M=", + "dev": true, + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.5" + } + } + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.12.0.tgz", + "integrity": "sha512-TuA+EhsanGcme5T3R0L80u4t8CpbXQjegRmf7+FPTJrtCTErXFeelblRgHQa1FofEzqYYJmJ/OqjTwREp9qgmg==", + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=" + } + } + }, + "eslint": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", + "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "ajv": "^6.10.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.5", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^1.4.3", + "eslint-visitor-keys": "^1.1.0", + "espree": "^6.1.2", + "esquery": "^1.0.1", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "inquirer": "^7.0.0", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.14", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.3", + "progress": "^2.0.0", + "regexpp": "^2.0.1", + "semver": "^6.1.2", + "strip-ansi": "^5.2.0", + "strip-json-comments": "^3.0.1", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "glob-parent": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", + "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.3.0.tgz", + "integrity": "sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "eslint-plugin-flowtype": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-4.5.0.tgz", + "integrity": "sha512-zylRibc5W//x7vURL8vW3B1RVZyjSujcAMNDPAUA5SWHCskNmrX1wFODog1kTkC96acluCwMlWhJYrOyyXFr/A==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "eslint-scope": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", + "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "eslint-visitor-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz", + "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==", + "dev": true + }, + "espree": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-6.1.2.tgz", + "integrity": "sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==", + "dev": true, + "requires": { + "acorn": "^7.1.0", + "acorn-jsx": "^5.1.0", + "eslint-visitor-keys": "^1.1.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + }, + "dependencies": { + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + } + } + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "execa": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-3.4.0.tgz", + "integrity": "sha512-r9vdGQk4bmCuK1yKQu1KTwcT2zwfWdbdaXfCtAh+5nU/4fSX+JAb7vZGvI5naJrQlvONrEB20jeruESI69530g==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "optional": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true + } + } + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "ext": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", + "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "dev": true, + "requires": { + "type": "^2.0.0" + }, + "dependencies": { + "type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type/-/type-2.0.0.tgz", + "integrity": "sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow==", + "dev": true + } + } + }, + "ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "dev": true, + "requires": { + "mime-db": "^1.28.0" + } + }, + "ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "dev": true, + "requires": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "optional": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "optional": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "optional": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "optional": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extract-files": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-5.0.1.tgz", + "integrity": "sha512-qRW6y9eKF0VbCyOoOEtFhzJ3uykAw8GKwQVXyAIqwocyEWW4m+v+evec34RwtUkkxxHh7NKBLJ6AnXM8W4dH5w==", + "dev": true + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + }, + "fast-safe-stringify": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" + }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, + "fecha": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" + }, + "figures": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.1.0.tgz", + "integrity": "sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "file-stream-rotator": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.5.7.tgz", + "integrity": "sha512-VYb3HZ/GiAGUCrfeakO8Mp54YGswNUHvL7P09WQcXAJNSj3iQ5QraYSp3cIn1MUyw6uzfgN/EFOarCNa4JvUHQ==", + "requires": { + "moment": "^2.11.2" + } + }, + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", + "dev": true + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=", + "dev": true + }, + "filenamify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", + "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", + "dev": true, + "requires": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.0", + "trim-repeated": "^1.0.0" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "find-cache-dir": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.2.0.tgz", + "integrity": "sha512-1JKclkYYsf1q9WIJKLZa9S9muC+08RIjzAlLrK4QcYLJMS6mk9yombQ9qf+zJ7H9LS800k0s44L4sDq9VYzqyg==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.0", + "pkg-dir": "^4.1.0" + }, + "dependencies": { + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "find-root": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-0.1.2.tgz", + "integrity": "sha1-mNImfP8ZFsyvJ0OzoO6oHXnX3NE=", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "find-versions": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", + "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", + "dev": true, + "requires": { + "semver-regex": "^2.0.0" + } + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + }, + "dependencies": { + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "flatted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz", + "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==", + "dev": true + }, + "flow-bin": { + "version": "0.119.1", + "resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.119.1.tgz", + "integrity": "sha512-mX6qjJVi7aLqR9sDf8QIHt8yYEWQbkMLw7qFoC7sM/AbJwvqFm3pATPN96thsaL9o1rrshvxJpSgoj1PJSC3KA==", + "dev": true + }, + "follow-redirects": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.10.0.tgz", + "integrity": "sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==", + "requires": { + "debug": "^3.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true, + "optional": true + }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", + "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "optional": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fromentries": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.2.0.tgz", + "integrity": "sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ==", + "dev": true + }, + "fs-capacitor": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.1.0.tgz", + "integrity": "sha512-YsKGCLAB40P3OKeciIa7cKzt7WkY8QT9ETa2wVIG3fQDHW2h3xtRo0770lUIbPrjCr5Sa+zFhixNJ+2xNxaraQ==" + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "optional": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.11.tgz", + "integrity": "sha512-+ux3lx6peh0BpvY0JebGyZoiR4D+oYzdPZMKJwkZ+sFkNJzpL7tXc/wehS49gUAxg3tmMHPHZkA8JU2rhhgDHw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1", + "node-pre-gyp": "*" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "3.2.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.9.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.14.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.4.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.7.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.1", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.13", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.1.1", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "ftp": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", + "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=", + "requires": { + "readable-stream": "1.1.x", + "xregexp": "2.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "requires": { + "globule": "^1.0.0" + } + }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-mongodb-version": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/get-mongodb-version/-/get-mongodb-version-2.0.1.tgz", + "integrity": "sha512-yTN0UY7VJSSt01QH/aCiqiBjfxcDrEdKeM3uXY6QR3sRARoftx36QT0YNsCQm7FDTgrmDje7bK2C9ClM7SGKDA==", + "dev": true, + "requires": { + "lodash.startswith": "^4.2.1", + "minimist": "^1.1.1", + "mongodb": "*", + "which": "^1.1.1" + }, + "dependencies": { + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true + }, + "get-proxy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", + "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", + "dev": true, + "requires": { + "npm-conf": "^1.1.0" + } + }, + "get-stream": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", + "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "get-uri": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz", + "integrity": "sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==", + "requires": { + "data-uri-to-buffer": "1", + "debug": "2", + "extend": "~3.0.2", + "file-uri-to-path": "1", + "ftp": "~0.3.10", + "readable-stream": "2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true, + "optional": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "optional": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "optional": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globule": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", + "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "dev": true, + "requires": { + "glob": "~7.1.1", + "lodash": "~4.17.10", + "minimatch": "~3.0.2" + } + }, + "got": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/got/-/got-7.1.0.tgz", + "integrity": "sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==", + "dev": true, + "requires": { + "decompress-response": "^3.2.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "is-plain-obj": "^1.1.0", + "is-retry-allowed": "^1.0.0", + "is-stream": "^1.0.0", + "isurl": "^1.0.0-alpha5", + "lowercase-keys": "^1.0.0", + "p-cancelable": "^0.3.0", + "p-timeout": "^1.1.1", + "safe-buffer": "^5.0.1", + "timed-out": "^4.0.0", + "url-parse-lax": "^1.0.0", + "url-to-options": "^1.0.1" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + } + } + }, + "graceful-fs": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true + }, + "graceful-readlink": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true + }, + "graphql": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.6.0.tgz", + "integrity": "sha512-VKzfvHEKybTKjQVpTFrA5yUq2S9ihcZvfJAtsDBBCuV6wauPu1xl/f9ehgVf0FcEJJs4vz6ysb/ZMkGigQZseg==", + "requires": { + "iterall": "^1.2.2" + } + }, + "graphql-extensions": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.11.0.tgz", + "integrity": "sha512-zd4qfUiJoYBx2MwJusM36SEJ+YmJ1ki8YF8nlm9mgaPDUzsnmFq4lxULxUfhLAXFwZw7MbEN2vV4V6WiNgSJLg==", + "requires": { + "@apollographql/apollo-tools": "^0.4.3", + "apollo-server-env": "^2.4.3", + "apollo-server-types": "^0.3.0" + } + }, + "graphql-list-fields": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphql-list-fields/-/graphql-list-fields-2.0.2.tgz", + "integrity": "sha512-9TSAwcVA3KWw7JWYep5NCk2aw3wl1ayLtbMpmG7l26vh1FZ+gZexNPP+XJfUFyJa71UU0zcKSgtgpsrsA3Xv9Q==" + }, + "graphql-relay": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.6.0.tgz", + "integrity": "sha512-OVDi6C9/qOT542Q3KxZdXja3NrDvqzbihn1B44PH8P/c5s0Q90RyQwT6guhGqXqbYEH6zbeLJWjQqiYvcg2vVw==", + "requires": { + "prettier": "^1.16.0" + }, + "dependencies": { + "prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==" + } + } + }, + "graphql-subscriptions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-1.1.0.tgz", + "integrity": "sha512-6WzlBFC0lWmXJbIVE8OgFgXIP4RJi3OQgTPa0DVMsDXdpRDjTsM1K9wfl5HSYX7R87QAGlvcv2Y4BIZa/ItonA==", + "requires": { + "iterall": "^1.2.1" + } + }, + "graphql-tag": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.1.tgz", + "integrity": "sha512-jApXqWBzNXQ8jYa/HLkZJaVw9jgwNqZkywa2zfFn16Iv1Zb7ELNHkJaXHR7Quvd5SIGsy6Ny7SUKATgnu05uEg==" + }, + "graphql-tools": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/graphql-tools/-/graphql-tools-4.0.7.tgz", + "integrity": "sha512-rApl8sT8t/W1uQRcwzxMYyUBiCl/XicluApiDkNze5TX/GR0BSTQMjM2UcRGdTmkbsb1Eqq6afkyyeG/zMxZYQ==", + "requires": { + "apollo-link": "^1.2.3", + "apollo-utilities": "^1.0.1", + "deprecated-decorator": "^0.1.6", + "iterall": "^1.1.3", + "uuid": "^3.1.0" + } + }, + "graphql-upload": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-10.0.0.tgz", + "integrity": "sha512-8n11qujsqHWT48visvQbqLqAj8o6NCLJ35tGkI/RynhDs7E07TxlswVe4vPZaLiXJeemZA7xrxkMohwP//DOqA==", + "requires": { + "busboy": "^0.3.1", + "fs-capacitor": "^6.1.0", + "http-errors": "^1.7.3", + "isobject": "^4.0.0", + "object-path": "^0.11.4" + }, + "dependencies": { + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==" + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbol-support-x": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", + "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "has-to-string-tag-x": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", + "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", + "dev": true, + "requires": { + "has-symbol-support-x": "^1.4.1" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "optional": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "optional": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hasha": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.1.0.tgz", + "integrity": "sha512-OFPDWmzPN1l7atOV1TgBVmNtBxaIysToK6Ve9DK+vT6pYuklw/nPNT+HJbZi0KDcI6vWB+9tgvZ5YD7fA3CXcA==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + } + } + }, + "html-escaper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.0.tgz", + "integrity": "sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig==", + "dev": true + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "dev": true, + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-proxy-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", + "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "requires": { + "agent-base": "4", + "debug": "3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + } + }, + "human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true + }, + "husky": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-4.2.3.tgz", + "integrity": "sha512-VxTsSTRwYveKXN4SaH1/FefRJYCtx+wx04sSVcOpD7N2zjoHxa+cEJ07Qg5NmV3HAK+IRKOyNVpi2YBIVccIfQ==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "ci-info": "^2.0.0", + "compare-versions": "^3.5.1", + "cosmiconfig": "^6.0.0", + "find-versions": "^3.2.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "slash": "^3.0.0", + "which-pm-runs": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ignore-walk": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "import-fresh": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", + "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + } + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "inflection": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", + "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "inquirer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.1.tgz", + "integrity": "sha512-V1FFQ3TIO15det8PijPLFR9M9baSlnRs9nL7zWu1MNVA2T9YVl9ZbrHJhYs7e9X8jeMZ3lr2JH/rdHFgNCBdYw==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^2.4.2", + "cli-cursor": "^3.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.15", + "mute-stream": "0.0.8", + "run-async": "^2.2.0", + "rxjs": "^6.5.3", + "string-width": "^4.1.0", + "strip-ansi": "^5.1.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } + } + } + } + }, + "intersect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/intersect/-/intersect-1.0.1.tgz", + "integrity": "sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" + }, + "ipaddr.js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", + "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==" + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "optional": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "optional": true + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "optional": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true, + "optional": true + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true, + "optional": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-mongodb-running": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-mongodb-running/-/is-mongodb-running-1.0.1.tgz", + "integrity": "sha512-gyUmdhGKLHuDv+JGma70b1P1WLBMy8bvt9QLSEojSSi3/5FbUKuOKCNzHmto9ftMjyLtzlUtfCoSGdkPvx6H5w==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "debug": ">= 2.6.9", + "figures": "^2.0.0", + "lodash": "^4.17.10", + "lsof": "^0.1.0", + "minimist": "^1.2.0", + "node-netstat": "^1.4.2", + "ps-node": "^0.1.6" + }, + "dependencies": { + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + } + } + }, + "is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + }, + "is-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", + "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=", + "dev": true + }, + "is-observable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", + "integrity": "sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==", + "dev": true, + "requires": { + "symbol-observable": "^1.1.0" + } + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "optional": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true + }, + "is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true, + "optional": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.0.tgz", + "integrity": "sha512-Nm4wVHdo7ZXSG30KjZ2Wl5SU/Bw7bDx1PdaiIFzEStdjs0H12mOTncn1GVYuqQSaZxpg87VGBRsVRPGD2cD1AQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@babel/parser": "^7.7.5", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "@babel/parser": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.7.tgz", + "integrity": "sha512-WtTZMZAZLbeymhkd/sEaPD8IQyGAhmuTuvTzLiCFM7iXiVdY0gc0IaI+cW0fh1BnSMbJSzXX6/fHllgHKwHhXw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "dependencies": { + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "rimraf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "istanbul-reports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.0.tgz", + "integrity": "sha512-2osTcC8zcOSUkImzN2EWQta3Vdi4WjjKw99P2yWx5mLnigAM0Rd5uYFn1cf2i/Ois45GkNjaoTqc5CxgMSX80A==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "isurl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", + "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", + "dev": true, + "requires": { + "has-to-string-tag-x": "^1.2.0", + "is-object": "^1.0.1" + } + }, + "iterall": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.2.2.tgz", + "integrity": "sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==" + }, + "jasmine": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.5.0.tgz", + "integrity": "sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ==", + "dev": true, + "requires": { + "glob": "^7.1.4", + "jasmine-core": "~3.5.0" + } + }, + "jasmine-core": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.5.0.tgz", + "integrity": "sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA==", + "dev": true + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "js2xmlparser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.0.tgz", + "integrity": "sha512-WuNgdZOXVmBk5kUPMcTcVUpbGRzLfNkv7+7APq7WiDihpXVKrgxo6wwRpRl9OQeEBgKCVk9mR7RbzrnNWC8oBw==", + "dev": true, + "requires": { + "xmlcreate": "^2.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "jsdoc": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.3.tgz", + "integrity": "sha512-Yf1ZKA3r9nvtMWHO1kEuMZTlHOF8uoQ0vyo5eH7SQy5YeIiHM+B0DgKnn+X6y6KDYZcF7G2SPkKF+JORCXWE/A==", + "dev": true, + "requires": { + "@babel/parser": "^7.4.4", + "bluebird": "^3.5.4", + "catharsis": "^0.8.11", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.0", + "klaw": "^3.0.0", + "markdown-it": "^8.4.2", + "markdown-it-anchor": "^5.0.2", + "marked": "^0.7.0", + "mkdirp": "^0.5.1", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.0.1", + "taffydb": "2.6.2", + "underscore": "~1.9.1" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + } + } + }, + "jsdoc-babel": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsdoc-babel/-/jsdoc-babel-0.5.0.tgz", + "integrity": "sha512-PYfTbc3LNTeR8TpZs2M94NLDWqARq0r9gx3SvuziJfmJS7/AeMKvtj0xjzOX0R/4MOVA7/FqQQK7d6U0iEoztQ==", + "dev": true, + "requires": { + "jsdoc-regex": "^1.0.1", + "lodash": "^4.17.10" + } + }, + "jsdoc-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jsdoc-regex/-/jsdoc-regex-1.0.1.tgz", + "integrity": "sha1-hCRCjVtWOtjFx/vsB5uaiwnI3Po=", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "json5": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.2.tgz", + "integrity": "sha512-MoUOQ4WdiN3yxhm7NEVJSJrieAo5hNSLQ5sj05OTRHPL9HOBy8u4Bu88jsC1jvqAdN+E1bJmsUcZH+1HQxliqQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jwks-rsa": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.7.0.tgz", + "integrity": "sha512-tq7DVJt9J6wTvl9+AQfwZIiPSuY2Vf0F+MovfRTFuBqLB1xgDVhegD33ChEAQ6yBv9zFvUIyj4aiwrSA5VehUw==", + "requires": { + "@types/express-jwt": "0.0.42", + "debug": "^4.1.0", + "jsonwebtoken": "^8.5.1", + "limiter": "^1.1.4", + "lru-memoizer": "^2.0.1", + "ms": "^2.1.2", + "request": "^2.88.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "key-tree-store": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/key-tree-store/-/key-tree-store-1.3.0.tgz", + "integrity": "sha1-XqKa/CUppCWThDfWlVtxTOapeR8=", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "optional": true + }, + "klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "kuler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", + "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", + "requires": { + "colornames": "^1.1.1" + } + }, + "lcov-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", + "integrity": "sha1-6w1GtUER68VhrLTECO+TY73I9+A=" + }, + "ldap-filter": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz", + "integrity": "sha1-8rhCvguG2jNSeYUFsx68rlkNd9A=", + "requires": { + "assert-plus": "0.1.5" + }, + "dependencies": { + "assert-plus": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz", + "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=" + } + } + }, + "ldapjs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.2.tgz", + "integrity": "sha1-VE/3Ayt7g8aPBwEyjZKXqmlDQPk=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "bunyan": "^1.8.3", + "dashdash": "^1.14.0", + "dtrace-provider": "~0.8", + "ldap-filter": "0.2.2", + "once": "^1.4.0", + "vasync": "^1.6.4", + "verror": "^1.8.1" + }, + "dependencies": { + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + } + } + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "levenary": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz", + "integrity": "sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==", + "dev": true, + "requires": { + "leven": "^3.1.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "dev": true, + "requires": { + "uc.micro": "^1.0.1" + } + }, + "lint-staged": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.0.8.tgz", + "integrity": "sha512-Oa9eS4DJqvQMVdywXfEor6F4vP+21fPHF8LUXgBbVWUSWBddjqsvO6Bv1LwMChmgQZZqwUvgJSHlu8HFHAPZmA==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "cosmiconfig": "^6.0.0", + "debug": "^4.1.1", + "dedent": "^0.7.0", + "execa": "^3.4.0", + "listr": "^0.14.3", + "log-symbols": "^3.0.0", + "micromatch": "^4.0.2", + "normalize-path": "^3.0.0", + "please-upgrade-node": "^3.2.0", + "string-argv": "0.3.1", + "stringify-object": "^3.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "listr": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", + "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", + "dev": true, + "requires": { + "@samverschueren/stream-to-observable": "^0.3.0", + "is-observable": "^1.1.0", + "is-promise": "^2.1.0", + "is-stream": "^1.1.0", + "listr-silent-renderer": "^1.1.1", + "listr-update-renderer": "^0.5.0", + "listr-verbose-renderer": "^0.5.0", + "p-map": "^2.0.0", + "rxjs": "^6.3.3" + }, + "dependencies": { + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + } + } + }, + "listr-silent-renderer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", + "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=", + "dev": true + }, + "listr-update-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz", + "integrity": "sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "cli-truncate": "^0.2.1", + "elegant-spinner": "^1.0.1", + "figures": "^1.7.0", + "indent-string": "^3.0.0", + "log-symbols": "^1.0.2", + "log-update": "^2.3.0", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "dev": true + }, + "log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "dev": true, + "requires": { + "chalk": "^1.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "listr-verbose-renderer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz", + "integrity": "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "cli-cursor": "^2.1.0", + "date-fns": "^1.27.2", + "figures": "^2.0.0" + }, + "dependencies": { + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + } + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "lodash.assignin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz", + "integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI=", + "dev": true + }, + "lodash.bind": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz", + "integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU=", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=", + "dev": true + }, + "lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=", + "dev": true + }, + "lodash.filter": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz", + "integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4=", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, + "lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM=", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.map": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", + "integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM=", + "dev": true + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, + "lodash.pad": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", + "integrity": "sha1-QzCUmoM6fI2iLMIPaibE1Z3runA=", + "dev": true + }, + "lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=", + "dev": true + }, + "lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=", + "dev": true + }, + "lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=", + "dev": true + }, + "lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=", + "dev": true + }, + "lodash.reject": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz", + "integrity": "sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU=", + "dev": true + }, + "lodash.some": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", + "integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=", + "dev": true + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" + }, + "lodash.startswith": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.startswith/-/lodash.startswith-4.2.1.tgz", + "integrity": "sha1-xZjErc4YiiflMUVzHNxsDnF3YAw=", + "dev": true + }, + "log-driver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", + "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==" + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + } + }, + "log-update": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz", + "integrity": "sha1-iDKP19HOeTiykoN0bwsbwSayRwg=", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "cli-cursor": "^2.0.0", + "wrap-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + } + } + }, + "logform": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.1.2.tgz", + "integrity": "sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==", + "requires": { + "colors": "^1.2.1", + "fast-safe-stringify": "^2.0.4", + "fecha": "^2.3.3", + "ms": "^2.1.1", + "triple-beam": "^1.3.0" + } + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "requires": { + "yallist": "^3.0.2" + }, + "dependencies": { + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + } + } + }, + "lru-memoizer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.0.tgz", + "integrity": "sha512-oKjxgJhL+m1wfEkez7/a6iyRZUdohej+2u04qCaAQ7BBfx/qD4RH3jOQhPsd8Y3pcm7IhcNtE3kCEIDCMPiJFQ==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "~4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "requires": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + } + } + }, + "lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=", + "dev": true, + "requires": { + "es5-ext": "~0.10.2" + } + }, + "lsof": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lsof/-/lsof-0.1.0.tgz", + "integrity": "sha1-rALU2HYGIB8TZLr7FcSRz9WuMJI=", + "dev": true + }, + "mailgun-js": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/mailgun-js/-/mailgun-js-0.18.0.tgz", + "integrity": "sha512-o0P6jjZlx5CQj12tvVgDTbgjTqVN0+5h6/6P1+3c6xmozVKBwniQ6Qt3MkCSF0+ueVTbobAfWyGpWRZMJu8t1g==", + "requires": { + "async": "~2.6.0", + "debug": "~3.1.0", + "form-data": "~2.3.0", + "inflection": "~1.12.0", + "is-stream": "^1.1.0", + "path-proxy": "~1.0.0", + "promisify-call": "^2.0.2", + "proxy-agent": "~3.0.0", + "tsscmp": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true, + "optional": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "optional": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "markdown-it": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", + "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "markdown-it-anchor": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.2.5.tgz", + "integrity": "sha512-xLIjLQmtym3QpoY9llBgApknl7pxAcN3WDRc2d3rwpl+/YvDZHPmKscGs+L6E05xf2KrCXPBvosWt7MZukwSpQ==", + "dev": true + }, + "marked": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", + "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", + "dev": true + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "memoizee": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.3.10.tgz", + "integrity": "sha1-TsoNiu057J0Bf0xcLy9kMvQuXI8=", + "dev": true, + "requires": { + "d": "~0.1.1", + "es5-ext": "~0.10.11", + "es6-weak-map": "~0.1.4", + "event-emitter": "~0.3.4", + "lru-queue": "0.1", + "next-tick": "~0.2.2", + "timers-ext": "0.1" + }, + "dependencies": { + "next-tick": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz", + "integrity": "sha1-ddpKkn7liH45BliABltzNkE7MQ0=", + "dev": true + } + } + }, + "memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "optional": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "mime": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==" + }, + "mime-db": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", + "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==" + }, + "mime-types": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz", + "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==", + "requires": { + "mime-db": "1.42.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + }, + "dependencies": { + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "optional": true + } + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "optional": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "optional": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "optional": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, + "mongodb": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.5.5.tgz", + "integrity": "sha512-GCjDxR3UOltDq00Zcpzql6dQo1sVry60OXJY3TDmFc2SWFY6c8Gn1Ardidc5jDirvJrx2GC3knGOImKphbSL3A==", + "requires": { + "bl": "^2.2.0", + "bson": "^1.1.1", + "denque": "^1.4.1", + "require_optional": "^1.0.1", + "safe-buffer": "^5.1.2", + "saslprep": "^1.0.0" + }, + "dependencies": { + "bl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz", + "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + } + } + }, + "mongodb-core": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.2.7.tgz", + "integrity": "sha512-WypKdLxFNPOH/Jy6i9z47IjG2wIldA54iDZBmHMINcgKOUcWJh8og+Wix76oGd7EyYkHJKssQ2FAOw5Su/n4XQ==", + "dev": true, + "requires": { + "bson": "^1.1.1", + "require_optional": "^1.0.1", + "safe-buffer": "^5.1.2", + "saslprep": "^1.0.0" + } + }, + "mongodb-dbpath": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/mongodb-dbpath/-/mongodb-dbpath-0.0.1.tgz", + "integrity": "sha1-4BMsZ3sbncgwBFEW0Yrbf2kk8XU=", + "dev": true, + "requires": { + "async": "^1.4.0", + "debug": "^2.1.1", + "minimist": "^1.1.1", + "mkdirp": "^0.5.1", + "untildify": "^1.0.0" + }, + "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "untildify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-1.0.0.tgz", + "integrity": "sha1-TYAx0YBvT718QrAjeq8hNoYmJjU=", + "dev": true, + "requires": { + "user-home": "^1.0.0" + } + } + } + }, + "mongodb-download-url": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/mongodb-download-url/-/mongodb-download-url-0.5.2.tgz", + "integrity": "sha512-hEwWEsJUh/XbY8yy7EfwF4a6Hres/v89EyMV5/l3dWh8zT2isIo9gt94RkLgvdgOliBw4jtGbViIOcvM6F1G/w==", + "dev": true, + "requires": { + "async": "^2.1.2", + "debug": "^2.2.0", + "lodash.defaults": "^4.0.0", + "minimist": "^1.2.0", + "mongodb-version-list": "^1.0.0", + "request": "^2.65.0", + "semver": "^5.0.3" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "mongodb-runner": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/mongodb-runner/-/mongodb-runner-4.8.0.tgz", + "integrity": "sha512-wFTjkqVCkH9MK05t8kSDVP3oSmFq43AYwDBqVWndpggsO+Jr7sBqgf2VlNKlP5xzWF9rX2gqjESyc1Q3QxsXOw==", + "dev": true, + "requires": { + "async": "^3.1.0", + "clui": "^0.3.6", + "debug": "^4.1.1", + "fs-extra": "^8.1.0", + "is-mongodb-running": "^1.0.1", + "lodash.defaults": "^4.2.0", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "mongodb": "^3.4.0", + "mongodb-dbpath": "^0.0.1", + "mongodb-tools": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "mongodb-version-manager": "^1.4.3", + "untildify": "^4.0.0", + "which": "^2.0.1" + }, + "dependencies": { + "async": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.1.0.tgz", + "integrity": "sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "mongodb-tools": { + "version": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "from": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "dev": true, + "requires": { + "debug": "^2.2.0", + "lodash": "^4.17.12", + "mkdirp": "0.5.0", + "mongodb-core": "*", + "rimraf": "2.2.6" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz", + "integrity": "sha1-HXMHam35hs2TROFecfzAWkyavxI=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "rimraf": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.6.tgz", + "integrity": "sha1-xZWXVpsU2VatKcrMQr3d9fDqT0w=", + "dev": true + } + } + }, + "mongodb-version-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mongodb-version-list/-/mongodb-version-list-1.0.0.tgz", + "integrity": "sha1-8lAxz83W8UWx3o/OKk6+wCiLtKQ=", + "dev": true, + "requires": { + "cheerio": "^0.22.0", + "debug": "^2.2.0", + "downcache": "^0.0.9", + "fs-extra": "^1.0.0", + "minimist": "^1.1.1", + "semver": "^5.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "fs-extra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0" + } + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "mongodb-version-manager": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/mongodb-version-manager/-/mongodb-version-manager-1.4.3.tgz", + "integrity": "sha512-+zQdWHLdumqX4mNyqLP08LIa8Wsn52AyU8lEBRx4onlCN13AVNCvgHtCjBAquCYC/wm1rT1i7nAglSJq1LD81Q==", + "dev": true, + "requires": { + "ampersand-state": "^5.0.3", + "async": "^2.1.2", + "chalk": "^2.1.0", + "debug": ">= 2.6.9 < 3.0.0 || >= ^3.1.0", + "docopt": "^0.6.2", + "download": "^6.2.5", + "figures": "^2.0.0", + "fs-extra": "^4.0.2", + "get-mongodb-version": "^2.0.1", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.1.1", + "mongodb-download-url": "^0.5.2", + "mongodb-version-list": "^1.0.0", + "semver": "^5.3.0", + "tildify": "^1.2.0", + "untildify": "^3.0.2" + }, + "dependencies": { + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "untildify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz", + "integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==", + "dev": true + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "optional": true, + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "optional": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "optional": true, + "requires": { + "glob": "^6.0.1" + } + } + } + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "optional": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "optional": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "optional": true + }, + "needle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.3.tgz", + "integrity": "sha512-EkY0GeSq87rWp1hoq/sH/wnTWgFVhYlnIkbJ0YJFfRgEFlz2RraCjBpFQ+vrEgEdp0ThfyHADmkChEhcb7PKyw==", + "optional": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "netmask": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", + "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=" + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-addon-api": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz", + "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA==", + "optional": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + }, + "node-forge": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz", + "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==" + }, + "node-netstat": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/node-netstat/-/node-netstat-1.8.0.tgz", + "integrity": "sha512-P1a5Sh9FfjTXxI6hC9q/Nqre8kT63FQxBCr1qz5ffk76EkQBH62+XEhIhlzfz6Bz+FRwOFqidW2FDGXnOXvyJQ==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "node-pre-gyp": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz", + "integrity": "sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==", + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "optional": true + } + } + }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, + "node-releases": { + "version": "1.1.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.52.tgz", + "integrity": "sha512-snSiT1UypkgGt2wxPqS6ImEUICbNCMb31yaxWrOLXjhlt2z2/IBpaOxzONExqSm4y5oLnAqjjRWu+wsDzK5yNQ==", + "dev": true, + "requires": { + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "nopt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-bundled": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz", + "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==", + "optional": true, + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-conf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", + "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", + "dev": true, + "requires": { + "config-chain": "^1.1.11", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", + "optional": true + }, + "npm-packlist": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", + "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "nyc": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.0.0.tgz", + "integrity": "sha512-qcLBlNCKMDVuKb7d1fpxjPR8sHeMVX0CHarXAVzrVWoFrigCkYR8xcrjfXSPi5HXM7EU78L6ywO7w1c5rZNCNg==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.0", + "js-yaml": "^3.13.1", + "make-dir": "^3.0.0", + "node-preload": "^0.2.0", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "uuid": "^3.3.3", + "yargs": "^15.0.2" + }, + "dependencies": { + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "rimraf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "optional": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-hash": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.0.3.tgz", + "integrity": "sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg==" + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "object-path": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.4.tgz", + "integrity": "sha1-NwrnUvvzfePqcKhhwju6iRVpGUk=" + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "optional": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", + "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "optional": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=" + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "opencollective-postinstall": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz", + "integrity": "sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==", + "dev": true + }, + "optimism": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.10.3.tgz", + "integrity": "sha512-9A5pqGoQk49H6Vhjb9kPgAeeECfUDF6aIICbMDL23kDLStBn1MWk3YvcZ4xWF9CsSf6XEgvRLkXy4xof/56vVw==", + "dev": true, + "requires": { + "@wry/context": "^0.4.0" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-cancelable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.3.0.tgz", + "integrity": "sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==", + "dev": true + }, + "p-event": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-1.3.0.tgz", + "integrity": "sha1-jmtPT2XHK8W2/ii3XtqHT5akoIU=", + "dev": true, + "requires": { + "p-timeout": "^1.1.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-timeout": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz", + "integrity": "sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pac-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz", + "integrity": "sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==", + "requires": { + "agent-base": "^4.2.0", + "debug": "^4.1.1", + "get-uri": "^2.0.0", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^3.0.0", + "pac-resolver": "^3.0.0", + "raw-body": "^2.2.0", + "socks-proxy-agent": "^4.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "https-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", + "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "requires": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + } + } + }, + "pac-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz", + "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==", + "requires": { + "co": "^4.6.0", + "degenerator": "^1.0.4", + "ip": "^1.1.5", + "netmask": "^1.0.6", + "thunkify": "^2.1.2" + } + }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, + "packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-2.11.0.tgz", + "integrity": "sha512-dbGdA5M1ylky4T/b5pOXeYhsHwzATz/JbweCiBtdJLsnb8SylSSgA7V0U96RtXBI1Hfzp5uFZpqmnUKr5t69NA==", + "requires": { + "@babel/runtime": "7.7.7", + "@babel/runtime-corejs3": "7.7.7", + "crypto-js": "3.1.9-1", + "uuid": "3.3.3", + "ws": "7.2.1", + "xmlhttprequest": "1.8.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.7.tgz", + "integrity": "sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "@babel/runtime-corejs3": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.7.7.tgz", + "integrity": "sha512-kr3W3Fw8mB/CTru2M5zIRQZZgC/9zOxNSoJ/tVCzjPt3H1/p5uuGbz6WwmaQy/TLQcW31rUhUUWKY28sXFRelA==", + "requires": { + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.2" + } + }, + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + }, + "ws": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz", + "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==" + } + } + }, + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true, + "optional": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true, + "optional": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-proxy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz", + "integrity": "sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=", + "requires": { + "inflection": "~1.3.0" + }, + "dependencies": { + "inflection": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz", + "integrity": "sha1-y9Fg2p91sUw8xjV41POWeEvzAU4=" + } + } + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pg": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-7.18.2.tgz", + "integrity": "sha512-Mvt0dGYMwvEADNKy5PMQGlzPudKcKKzJds/VbOeZJpb6f/pI3mmoXX0JksPgI3l3JPP/2Apq7F36O63J7mgveA==", + "requires": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "0.1.3", + "pg-packet-stream": "^1.1.0", + "pg-pool": "^2.0.10", + "pg-types": "^2.1.0", + "pgpass": "1.x", + "semver": "4.3.2" + }, + "dependencies": { + "semver": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", + "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" + } + } + }, + "pg-connection-string": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", + "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" + }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-minify": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.5.2.tgz", + "integrity": "sha512-uZn/gXkGmO5JBdopxNLSpFMS1lXr+KJqynI8Di1Qyr8ZVXt67ruh+XNfzLMVdLzYv+MQRdNYQdVwWPSs0qM7xQ==" + }, + "pg-packet-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pg-packet-stream/-/pg-packet-stream-1.1.0.tgz", + "integrity": "sha512-kRBH0tDIW/8lfnnOyTwKD23ygJ/kexQVXZs7gEyBljw4FYqimZFxnMMx50ndZ8In77QgfGuItS5LLclC2TtjYg==" + }, + "pg-pool": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-2.0.10.tgz", + "integrity": "sha512-qdwzY92bHf3nwzIUcj+zJ0Qo5lpG/YxchahxIN8+ZVmXqkahKXsnl2aiJPHLYN9o5mB/leG+Xh6XKxtP7e0sjg==" + }, + "pg-promise": { + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.4.4.tgz", + "integrity": "sha512-N2NsOgKxrnNPwP0Q609ZmxmAZEo2TQ26SzSvlbZWQb8vteqUhOPpU/pHi9DGatJrPcXNoyr4xjRw42CNfEBg/w==", + "requires": { + "assert-options": "0.6.1", + "pg": "7.18.2", + "pg-minify": "1.5.2", + "spex": "3.0.1" + } + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", + "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", + "requires": { + "split": "^1.0.0" + } + }, + "picomatch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", + "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, + "pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true, + "optional": true + }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + }, + "postgres-date": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.4.tgz", + "integrity": "sha512-bESRvKVuTrjoBluEcpv2346+6kgB7UlnqWZsnbnCccTNq/pqfj1j6oBaN5+b/NrDXepYUT/HKadqv3iS9lJuVA==" + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "requires": { + "xtend": "^4.0.0" + } + }, + "precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=" + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=" + }, + "prepend-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", + "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=", + "dev": true + }, + "prettier": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.0.tgz", + "integrity": "sha512-GlAIjk6DjkNT6u/Bw5QCWrbzh9YlLKwwmJT//1YiCR3WDpZDnyss64aXHQZgF8VKeGlWnX6+tGsKSVxsZT/gtA==", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promisify-call": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz", + "integrity": "sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=", + "requires": { + "with-callback": "^1.0.2" + } + }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "dev": true + }, + "proxy-addr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", + "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.0" + } + }, + "proxy-agent": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-3.0.3.tgz", + "integrity": "sha512-PXVVVuH9tiQuxQltFJVSnXWuDtNr+8aNBP6XVDDCDiUuDN8eRCm+ii4/mFWmXWEA0w8jjJSlePa4LXlM4jIzNA==", + "requires": { + "agent-base": "^4.2.0", + "debug": "^3.1.0", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^2.2.1", + "lru-cache": "^4.1.2", + "pac-proxy-agent": "^3.0.0", + "proxy-from-env": "^1.0.0", + "socks-proxy-agent": "^4.0.1" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + } + } + }, + "proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=" + }, + "ps-node": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ps-node/-/ps-node-0.1.6.tgz", + "integrity": "sha1-mvZ6mdex0BMuUaUDCZ04qNKs4sM=", + "dev": true, + "requires": { + "table-parser": "^0.1.3" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, + "psl": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.5.0.tgz", + "integrity": "sha512-4vqUjKi2huMu1OJiLhi3jN6jeeKvMZdI1tYgi/njW5zV52jNLgSAZSdN16m9bJFe61/cT8ulmw4qFitV9QRsEA==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "optional": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "redis": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz", + "integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==", + "requires": { + "denque": "^1.4.1", + "redis-commands": "^1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + } + }, + "redis-commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz", + "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz", + "integrity": "sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + }, + "regenerator-transform": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.4.tgz", + "integrity": "sha512-EaJaKPBI9GvKpvUz2mz4fhx7WPgvwRLY9v3hlNHWmAuJHI13T4nwKnNvm5RWJzEdnI5g5UwtOww+S8IdoUC2bw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4", + "private": "^0.1.8" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.0.tgz", + "integrity": "sha512-cTIudHnzuWLS56ik4DnRnqqNf8MkdUzV4iFFI1h7Jo9xvrpQROYaAnaSd2mHLQAzzZAPfATynX5ord6YlNYNMA==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", + "dev": true + } + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, + "regexpu-core": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz", + "integrity": "sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.2.0", + "regjsgen": "^0.5.1", + "regjsparser": "^0.6.4", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.2.0" + } + }, + "regjsgen": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.1.tgz", + "integrity": "sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==", + "dev": true + }, + "regjsparser": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.4.tgz", + "integrity": "sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true, + "optional": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true, + "optional": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true, + "optional": true + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "requizzle": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.3.tgz", + "integrity": "sha512-YanoyJjykPxGHii0fZP0uUPEXpvqfBDxWV7s6GKAiiOsiqhX6vHNyW3Qzdmqp/iq/ExbhaGbVrjB4ruEVSM4GQ==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, + "resolve": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.13.1.tgz", + "integrity": "sha512-CxqObCX8K8YtAhOBRg+lrcdn+LK+WYOS8tSjqSFbjtrI5PnS63QPhZl4+yKfrU9tdsbMu9Anr/amegT87M9Z6w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true, + "optional": true + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "optional": true + }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "rxjs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz", + "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + }, + "safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "optional": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "optional": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "optional": true, + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "seek-bzip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.5.tgz", + "integrity": "sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w=", + "dev": true, + "requires": { + "commander": "~2.8.1" + }, + "dependencies": { + "commander": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", + "dev": true, + "requires": { + "graceful-readlink": ">= 1.0.0" + } + } + } + }, + "semver": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz", + "integrity": "sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==" + }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "semver-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", + "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", + "dev": true + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + } + } + }, + "smart-buffer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.1.0.tgz", + "integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw==" + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "optional": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "optional": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "optional": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "optional": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "optional": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "socks": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz", + "integrity": "sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==", + "requires": { + "ip": "1.1.5", + "smart-buffer": "^4.1.0" + } + }, + "socks-proxy-agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz", + "integrity": "sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==", + "requires": { + "agent-base": "~4.2.1", + "socks": "~2.3.2" + }, + "dependencies": { + "agent-base": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", + "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "requires": { + "es6-promisify": "^5.0.0" + } + } + } + }, + "sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + }, + "sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", + "dev": true, + "requires": { + "sort-keys": "^1.0.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, + "optional": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true, + "optional": true + }, + "sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "optional": true, + "requires": { + "memory-pager": "^1.0.2" + } + }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "rimraf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "spex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spex/-/spex-3.0.1.tgz", + "integrity": "sha512-priWZUrXBmVPHTOmtUeS7gZzCOUwRK87OHJw5K8bTC6MLOq93mQocx+vWccNyKPT2EY+goZvKGguGn2lx8TBDA==" + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "requires": { + "through": "2" + } + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "optional": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "optional": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "optional": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string.prototype.trimleft": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", + "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string.prototype.trimright": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", + "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "requires": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "requires": { + "is-natural-number": "^4.0.1" + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "optional": true + }, + "strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, + "subscriptions-transport-ws": { + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.16.tgz", + "integrity": "sha512-pQdoU7nC+EpStXnCfh/+ho0zE0Z+ma+i7xvj7bkXKb1dvYHSZxgRPaU6spRP+Bjzow67c/rRDoix5RT0uU9omw==", + "requires": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^5.2.0" + }, + "dependencies": { + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, + "table": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", + "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "table-parser": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/table-parser/-/table-parser-0.1.3.tgz", + "integrity": "sha1-BEHPzhallIFoTCfRtaZ/8VpDx7A=", + "dev": true, + "requires": { + "connected-domain": "^1.0.0" + } + }, + "taffydb": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz", + "integrity": "sha1-fLy2S1oUG2ou/CxdLGe04VCyomg=", + "dev": true + }, + "tar": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", + "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + }, + "dependencies": { + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "optional": true + } + } + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=", + "dev": true, + "requires": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "thunkify": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz", + "integrity": "sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0=" + }, + "tildify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz", + "integrity": "sha1-3OwD9V3Km3qj5bBPIYF+tW5jWIo=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0" + } + }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", + "dev": true + }, + "timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "dev": true, + "requires": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "optional": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "optional": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "optional": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "optional": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + } + } + }, + "trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, + "triple-beam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", + "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + }, + "ts-invariant": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz", + "integrity": "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==", + "requires": { + "tslib": "^1.9.3" + } + }, + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha1-0CDIRvrdUMhVq7JeuuzGj8EPeWM=" + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true + }, + "unbzip2-stream": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz", + "integrity": "sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg==", + "dev": true, + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + }, + "dependencies": { + "buffer": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", + "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + } + } + }, + "underscore": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", + "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==", + "dev": true + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz", + "integrity": "sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz", + "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", + "dev": true + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "optional": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "optional": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "optional": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "optional": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true, + "optional": true + } + } + }, + "untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true + }, + "upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "optional": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true, + "optional": true + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, + "url-parse-lax": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", + "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=", + "dev": true, + "requires": { + "prepend-http": "^1.0.1" + } + }, + "url-to-options": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", + "integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=", + "dev": true + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true, + "optional": true + }, + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + } + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "v8-compile-cache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", + "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "vasync": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", + "integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=", + "requires": { + "verror": "1.6.0" + }, + "dependencies": { + "extsprintf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", + "integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk=" + }, + "verror": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", + "integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=", + "requires": { + "extsprintf": "1.2.0" + } + } + } + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "which-pm-runs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", + "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "winston": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.2.1.tgz", + "integrity": "sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==", + "requires": { + "async": "^2.6.1", + "diagnostics": "^1.1.1", + "is-stream": "^1.1.0", + "logform": "^2.1.1", + "one-time": "0.0.4", + "readable-stream": "^3.1.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.3.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "winston-daily-rotate-file": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-4.4.2.tgz", + "integrity": "sha512-pVOUJKxN+Kn6LnOJZ4tTwdV5+N+fCkiRAb3bVnzcPtOj1ScxGNC3DyUhHuAHssBtMl5s45/aUcSUtApH+69V5A==", + "requires": { + "file-stream-rotator": "^0.5.7", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.2.0" + } + }, + "winston-transport": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.3.0.tgz", + "integrity": "sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==", + "requires": { + "readable-stream": "^2.3.6", + "triple-beam": "^1.2.0" + } + }, + "with-callback": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz", + "integrity": "sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE=" + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" + }, + "wrap-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz", + "integrity": "sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "write-file-atomic": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.1.tgz", + "integrity": "sha512-JPStrIyyVJ6oCSz/691fAjFtefZ6q+fP6tm+OS4Qw6o+TGQxNp1ziY2PgS+X/m0V8OWhZiO/m4xSj+Pr4RrZvw==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.3.tgz", + "integrity": "sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==" + }, + "xml2js": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz", + "integrity": "sha1-F76T6q4/O3eTWceVtBlwWogX6Gg=", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "^4.1.0" + } + }, + "xmlbuilder": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz", + "integrity": "sha1-qlijBBoGb5DqoWwvU4n/GfP0YaU=", + "requires": { + "lodash": "^4.0.0" + } + }, + "xmlcreate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.1.tgz", + "integrity": "sha512-MjGsXhKG8YjTKrDCXseFo3ClbMGvUD4en29H2Cev1dv4P/chlpw6KdYmlCWDkhosBVKRDjM836+3e3pm1cBNJA==", + "dev": true + }, + "xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw=" + }, + "xregexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", + "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, + "yaml": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.7.2.tgz", + "integrity": "sha512-qXROVp90sb83XtAoqE8bP9RwAkTTZbugRUTm5YeFCBfNRPEp2YzTeqWiz7m5OORHzEvrA/qcGS8hp/E+MMROYw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.6.3" + } + }, + "yargs": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.0.2.tgz", + "integrity": "sha512-GH/X/hYt+x5hOat4LMnCqMd8r5Cv78heOMIJn1hr7QPPBqfeC6p89Y78+WB9yGDvfpCvgasfmWLzNzEioOUD9Q==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^16.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "yargs-parser": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-16.1.0.tgz", + "integrity": "sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" + }, + "zen-observable-ts": { + "version": "0.8.20", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.20.tgz", + "integrity": "sha512-2rkjiPALhOtRaDX6pWyNqK1fnP5KkJJybYebopNSn6wDG1lxBoFs2+nwwXKoA6glHIrtwrfBBy6da0stkKtTAA==", + "requires": { + "tslib": "^1.9.3", + "zen-observable": "^0.8.0" + } + } + } +} diff --git a/package.json b/package.json index fa65e8cc8b..25991a4351 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "parse-server", - "version": "2.6.5", + "version": "4.1.0", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { "type": "git", - "url": "https://github.com/ParsePlatform/parse-server" + "url": "https://github.com/parse-community/parse-server" }, "files": [ "bin/", @@ -14,84 +14,127 @@ "views/", "LICENSE", "PATENTS", + "postinstall.js", "README.md" ], "license": "BSD-3-Clause", "dependencies": { + "@apollographql/graphql-playground-html": "1.6.24", + "@parse/fs-files-adapter": "1.0.1", + "@parse/push-adapter": "3.2.0", + "@parse/s3-files-adapter": "1.4.0", + "@parse/simple-mailgun-adapter": "1.1.0", + "apollo-server-express": "2.11.0", "bcryptjs": "2.4.3", - "body-parser": "1.18.2", - "commander": "2.11.0", - "deepcopy": "0.6.3", - "express": "4.16.0", + "body-parser": "1.19.0", + "commander": "5.0.0", + "cors": "2.8.5", + "deepcopy": "2.0.0", + "express": "4.17.1", + "follow-redirects": "1.10.0", + "graphql": "14.6.0", + "graphql-list-fields": "2.0.2", + "graphql-relay": "^0.6.0", + "graphql-tools": "^4.0.7", + "graphql-upload": "10.0.0", "intersect": "1.0.1", - "lodash": "4.17.4", - "lru-cache": "4.1.1", - "mime": "1.4.1", - "mongodb": "2.2.33", - "multer": "1.3.0", - "parse": "1.10.1", - "parse-server-fs-adapter": "1.0.1", - "parse-server-push-adapter": "2.0.2", - "parse-server-s3-adapter": "1.2.0", - "parse-server-simple-mailgun-adapter": "1.0.1", - "pg-promise": "7.0.3", - "redis": "2.8.0", - "request": "2.83.0", - "semver": "5.4.1", + "jsonwebtoken": "8.5.1", + "jwks-rsa": "1.7.0", + "ldapjs": "1.0.2", + "lodash": "4.17.15", + "lru-cache": "5.1.1", + "mime": "2.4.4", + "mongodb": "3.5.5", + "parse": "2.11.0", + "pg-promise": "10.4.4", + "pluralize": "^8.0.0", + "redis": "3.0.2", + "semver": "7.1.3", + "subscriptions-transport-ws": "0.9.16", "tv4": "1.3.0", - "uuid": "^3.1.0", - "winston": "2.4.0", - "winston-daily-rotate-file": "1.7.2", - "ws": "3.2.0" + "uuid": "3.4.0", + "winston": "3.2.1", + "winston-daily-rotate-file": "4.4.2", + "ws": "7.2.3" }, "devDependencies": { - "babel-cli": "6.26.0", - "babel-core": "6.26.0", - "babel-eslint": "^8.0.0", - "babel-plugin-syntax-flow": "6.18.0", - "babel-plugin-transform-flow-strip-types": "6.22.0", - "babel-preset-env": "1.6.1", - "babel-preset-es2015": "6.24.1", - "babel-preset-stage-3": "6.24.1", - "babel-register": "6.26.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/plugin-proposal-object-rest-spread": "7.9.0", + "@babel/plugin-transform-flow-strip-types": "7.9.0", + "@babel/preset-env": "7.9.0", + "@parse/minami": "1.0.0", + "apollo-cache-inmemory": "1.6.5", + "apollo-client": "2.6.6", + "apollo-link": "1.2.13", + "apollo-link-http": "1.5.16", + "apollo-link-ws": "1.0.19", + "apollo-upload-client": "12.1.0", + "apollo-utilities": "1.3.3", + "babel-eslint": "10.1.0", "bcrypt-nodejs": "0.0.3", - "cross-env": "5.1.0", - "deep-diff": "0.3.8", - "eslint": "^4.9.0", - "eslint-plugin-flowtype": "^2.39.1", - "gaze": "1.1.2", - "jasmine": "2.8.0", - "jasmine-spec-reporter": "^4.1.0", - "mongodb-runner": "3.6.1", - "nodemon": "1.12.1", - "nyc": "^11.0.2", - "request-promise": "4.2.2" + "cross-env": "7.0.2", + "deep-diff": "1.0.2", + "eslint": "6.8.0", + "eslint-plugin-flowtype": "4.5.0", + "flow-bin": "0.119.1", + "form-data": "3.0.0", + "gaze": "1.1.3", + "graphql-tag": "^2.10.1", + "husky": "4.2.3", + "jasmine": "3.5.0", + "jsdoc": "3.6.3", + "jsdoc-babel": "0.5.0", + "lint-staged": "10.0.8", + "mongodb-runner": "4.8.0", + "node-fetch": "2.6.0", + "nyc": "15.0.0", + "prettier": "1.19.0" }, "scripts": { + "definitions": "node ./resources/buildConfigDefinitions.js", + "docs": "jsdoc -c ./jsdoc-conf.json", "dev": "npm run build && node bin/dev", - "lint": "eslint --cache ./", + "lint": "flow && eslint --cache ./", "build": "babel src/ -d lib/ --copy-files", - "pretest": "npm run lint", - "test": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.2.6} MONGODB_STORAGE_ENGINE=mmapv1 TESTING=1 $COVERAGE_OPTION jasmine", - "test:win": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.2.6} MONGODB_STORAGE_ENGINE=mmapv1 TESTING=1 jasmine", - "coverage": "cross-env COVERAGE_OPTION='./node_modules/.bin/nyc' npm test", - "coverage:win": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.2.6} MONGODB_STORAGE_ENGINE=mmapv1 TESTING=1 node ./node_modules/.bin/nyc ./node_modules/jasmine/bin/jasmine.js", + "watch": "babel --watch src/ -d lib/ --copy-files", + "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} mongodb-runner start", + "testonly": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} TESTING=1 jasmine", + "test": "npm run testonly", + "posttest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} mongodb-runner stop", + "coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} TESTING=1 nyc jasmine", "start": "node ./bin/parse-server", - "prepublish": "npm run build" + "prepare": "npm run build", + "postinstall": "node -p 'require(\"./postinstall.js\")()'" }, "engines": { - "node": ">=4.6" + "node": ">= 8" }, "bin": { "parse-server": "./bin/parse-server" }, "optionalDependencies": { - "bcrypt": "1.0.3", - "uws": "^8.14.1" + "bcrypt": "4.0.1" }, "collective": { "type": "opencollective", "url": "https://opencollective.com/parse-server", "logo": "https://opencollective.com/parse-server/logo.txt?reverse=true&variant=binary" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parse-server" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "{src,spec}/**/*.js": [ + "prettier --write", + "eslint --cache", + "git add" + ] } } diff --git a/postinstall.js b/postinstall.js new file mode 100644 index 0000000000..fef1fb31ff --- /dev/null +++ b/postinstall.js @@ -0,0 +1,50 @@ +const pkg = require('./package.json'); + +const version = parseFloat(process.version.substr(1)); +const minimum = parseFloat(pkg.engines.node.match(/\d+/g).join('.')); + +module.exports = function () { + const openCollective = ` + 1111111111 + 1111111111111111 + 1111111111111111111111 + 11111111111111111111111111 + 111111111111111 11111111 + 1111111111111 111111 + 1111111111111 111111111 111111 + 111111111111 11111111111 111111 + 1111111111111 11111111111 111111 + 1111111111111 1111111111 111111 + 1111111111111111111111111 1111111 + 11111111 11111111 + 111111 1111111111111111111 + 11111 11111 111111111111111111 + 11111 11111111111111111 + 111111 111111111111111111 + 11111111111111111111111111 + 1111111111111111111111 + 111111111111111111 + 11111111111 + + + Thanks for installing parse 🙏 + Please consider donating to our open collective + to help us maintain this package. + + 👉 https://opencollective.com/parse-server + + `; + process.stdout.write(openCollective); + if (version >= minimum) { + process.exit(0); + } + + const errorMessage = ` + ⚠️ parse-server requires at least node@${minimum}! + You have node@${version} + + `; + + process.stdout.write(errorMessage); + process.exit(1); +}; diff --git a/release_docs.sh b/release_docs.sh new file mode 100755 index 0000000000..bb12bac50f --- /dev/null +++ b/release_docs.sh @@ -0,0 +1,29 @@ +#!/bin/sh -e +set -x +if [ "${TRAVIS_REPO_SLUG}" = "" ]; +then + echo "Cannot release docs without TRAVIS_REPO_SLUG set" + exit 0; +fi +REPO="https://github.com/${TRAVIS_REPO_SLUG}" + +rm -rf docs +git clone -b gh-pages --single-branch $REPO ./docs +cd docs +git pull origin gh-pages +cd .. + +DEST="master" + +if [ "${TRAVIS_TAG}" != "" ]; +then + DEST="${TRAVIS_TAG}" + # change the default page to the latest + echo "" > "docs/api/index.html" +fi + +npm run definitions +npm run docs + +mkdir -p "docs/api/${DEST}" +cp -R out/* "docs/api/${DEST}" diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index ffe5b2e65d..8215792a82 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -1,11 +1,11 @@ /** * Parse Server Configuration Builder - * + * * This module builds the definitions file (src/Options/Definitions.js) * from the src/Options/index.js options interfaces. * The Definitions.js module is responsible for the default values as well * as the mappings for the CLI. - * + * * To rebuild the definitions file, run * `$ node resources/buildConfigDefinitions.js` */ @@ -56,23 +56,35 @@ function getENVPrefix(iface) { function processProperty(property, iface) { const firstComment = getCommentValue(last(property.leadingComments || [])); - const lastComment = getCommentValue((property.trailingComments || [])[0]); const name = property.key.name; const prefix = getENVPrefix(iface); if (!firstComment) { return; } - const components = firstComment.split(':ENV:').map((elt) => { - return elt.trim(); + const lines = firstComment.split('\n').map((line) => line.trim()); + let help = ''; + let envLine; + let defaultLine; + lines.forEach((line) => { + if (line.indexOf(':ENV:') === 0) { + envLine = line; + } else if (line.indexOf(':DEFAULT:') === 0) { + defaultLine = line; + } else { + help += line; + } }); + let env; + if (envLine) { + env = envLine.split(' ')[1]; + } else { + env = (prefix + toENV(name)); + } let defaultValue; - if (lastComment && lastComment.indexOf('=') >= 0) { - const slice = lastComment.slice(lastComment.indexOf('=') + 1, lastComment.length).trim(); - defaultValue = slice; + if (defaultLine) { + defaultValue = defaultLine.split(' ')[1]; } - const help = components[0]; - const env = components[1] || (prefix + toENV(name)); let type = property.value.type; let isRequired = true; if (type == 'NullableTypeAnnotation') { @@ -94,6 +106,7 @@ function processProperty(property, iface) { function doInterface(iface) { return iface.body.properties + .sort((a, b) => a.key.name.localeCompare(b.key.name)) .map((prop) => processProperty(prop, iface)) .filter((e) => e !== undefined); } @@ -123,18 +136,18 @@ function mapperFor(elt, t) { } function parseDefaultValue(elt, value, t) { - let litteralValue; + let literalValue; if (t.isStringTypeAnnotation(elt)) { if (value == '""' || value == "''") { - litteralValue = t.stringLiteral(''); + literalValue = t.stringLiteral(''); } else { - litteralValue = t.stringLiteral(value); + literalValue = t.stringLiteral(value); } } else if (t.isNumberTypeAnnotation(elt)) { - litteralValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); + literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); } else if (t.isArrayTypeAnnotation(elt)) { const array = parsers.objectParser(value); - litteralValue = t.arrayExpression(array.map((value) => { + literalValue = t.arrayExpression(array.map((value) => { if (typeof value == 'string') { return t.stringLiteral(value); } else { @@ -142,27 +155,36 @@ function parseDefaultValue(elt, value, t) { } })); } else if (t.isAnyTypeAnnotation(elt)) { - litteralValue = t.arrayExpression([]); + literalValue = t.arrayExpression([]); } else if (t.isBooleanTypeAnnotation(elt)) { - litteralValue = t.booleanLiteral(parsers.booleanParser(value)); + literalValue = t.booleanLiteral(parsers.booleanParser(value)); } else if (t.isGenericTypeAnnotation(elt)) { const type = elt.typeAnnotation.id.name; if (type == 'NumberOrBoolean') { - litteralValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); + literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); } if (type == 'CustomPagesOptions') { const object = parsers.objectParser(value); const props = Object.keys(object).map((key) => { return t.objectProperty(key, object[value]); }); - litteralValue = t.objectExpression(props); + literalValue = t.objectExpression(props); + } + if (type == 'ProtectedFields') { + const prop = t.objectProperty( + t.stringLiteral('_User'), t.objectPattern([ + t.objectProperty(t.stringLiteral('*'), t.arrayExpression([t.stringLiteral('email')])) + ]) + ); + literalValue = t.objectExpression([prop]); } } - return litteralValue; + return literalValue; } function inject(t, list) { - return list.map((elt) => { + let comments = ''; + const results = list.map((elt) => { if (!elt.name) { return; } @@ -186,23 +208,45 @@ function inject(t, list) { throw new Error(`Unable to parse value for ${elt.name} `); } } + let type = elt.type.replace('TypeAnnotation', ''); + if (type === 'Generic') { + type = elt.typeAnnotation.id.name; + } + if (type === 'Array') { + type = `${elt.typeAnnotation.elementType.type.replace('TypeAnnotation', '')}[]`; + } + if (type === 'NumberOrBoolean') { + type = 'Number|Boolean'; + } + if (type === 'NumberOrString') { + type = 'Number|String'; + } + if (type === 'Adapter') { + const adapterType = elt.typeAnnotation.typeParameters.params[0].id.name; + type = `Adapter<${adapterType}>`; + } + comments += ` * @property {${type}} ${elt.name} ${elt.help}\n`; const obj = t.objectExpression(props); return t.objectProperty(t.stringLiteral(elt.name), obj); }).filter((elt) => { return elt != undefined; }); + return { results, comments }; } const makeRequire = function(variableName, module, t) { const decl = t.variableDeclarator(t.identifier(variableName), t.callExpression(t.identifier('require'), [t.stringLiteral(module)])); return t.variableDeclaration('var', [decl]) } - +let docs = ``; const plugin = function (babel) { const t = babel.types; const moduleExports = t.memberExpression(t.identifier('module'), t.identifier('exports')); return { visitor: { + ImportDeclaration: function(path) { + path.remove(); + }, Program: function(path) { // Inject the parser's loader path.unshiftContainer('body', makeRequire('parsers', './parsers', t)); @@ -210,11 +254,12 @@ const plugin = function (babel) { ExportDeclaration: function(path) { // Export declaration on an interface if (path.node && path.node.declaration && path.node.declaration.type == 'InterfaceDeclaration') { - const l = inject(t, doInterface(path.node.declaration)); + const { results, comments } = inject(t, doInterface(path.node.declaration)); const id = path.node.declaration.id.name; const exports = t.memberExpression(moduleExports, t.identifier(id)); + docs += `/**\n * @interface ${id}\n${comments} */\n\n`; path.replaceWith( - t.assignmentExpression('=', exports, t.objectExpression(l)) + t.assignmentExpression('=', exports, t.objectExpression(results)) ) } } @@ -228,6 +273,7 @@ This code has been generated by resources/buildConfigDefinitions.js Do not edit manually, but update Options/index.js ` -const babel = require("babel-core"); -const res = babel.transformFileSync('./src/Options/index.js', { plugins: [ plugin ], auxiliaryCommentBefore }); +const babel = require("@babel/core"); +const res = babel.transformFileSync('./src/Options/index.js', { plugins: [ plugin, '@babel/transform-flow-strip-types' ], babelrc: false, auxiliaryCommentBefore, sourceMaps: false }); require('fs').writeFileSync('./src/Options/Definitions.js', res.code + '\n'); +require('fs').writeFileSync('./src/Options/docs.js', docs); diff --git a/resources/npm-git.sh b/resources/npm-git.sh deleted file mode 100755 index 7fa1dd0642..0000000000 --- a/resources/npm-git.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh -e - -# This script maintains a git branch which mirrors master but in a form that -# what will eventually be deployed to npm, allowing npm dependencies to use: -# -# "parse-server": "parseplatform/parse-server#latest" -# - -# From: https://github.com/graphql/graphql-js/blob/master/resources/npm-git.sh - -BUILD_DIR=latest - -npm run build - -mkdir -p $BUILD_DIR - -cp package.json $BUILD_DIR/ -cp README.md $BUILD_DIR/ -cp LICENSE $BUILD_DIR/ -cp PATENTS $BUILD_DIR/ -cp CHANGELOG.md $BUILD_DIR/ -cp -R lib $BUILD_DIR -cp -R bin $BUILD_DIR -cp -R public_html $BUILD_DIR -cp -R views $BUILD_DIR - -cd $BUILD_DIR -git init -git config user.name "Travis CI" -git config user.email "github@fb.com" -git add . -git commit -m "Deploy master to LATEST branch" -git push --force --quiet "https://${GH_TOKEN}@github.com/parse-community/parse-server.git" master:latest diff --git a/scripts/before_install_postgres.sh b/scripts/before_install_postgres.sh new file mode 100755 index 0000000000..f2269e1301 --- /dev/null +++ b/scripts/before_install_postgres.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -e + +source ~/.nvm/nvm.sh + +echo "[SCRIPT] Before Install Script :: Setup Postgres ${POSTGRES_MAJOR_VERSION}" + +nvm install $NODE_VERSION +nvm use $NODE_VERSION +npm install -g greenkeeper-lockfile@1 + +sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/${POSTGRES_MAJOR_VERSION}/main/postgresql.conf + +if [[ $POSTGRES_MAJOR_VERSION -lt 11 ]]; then + # Setup postgres 9 or 10 + sudo service postgresql stop + + # Remove correct version of postgres + if [[ $POSTGRES_MAJOR_VERSION -lt 10 ]]; then + sudo apt-get remove -q 'postgresql-10.*' + else + sudo apt-get remove -q 'postgresql-9.*' + fi + + sudo service postgresql start ${POSTGRES_MAJOR_VERSION} + +else + + # Setup postgres 11 or higher + sudo cp /etc/postgresql/{10,${POSTGRES_MAJOR_VERSION}}/main/pg_hba.conf + sudo service postgresql stop + # Remove previous versions of postgres + sudo apt-get remove -q 'postgresql-9.*' 'postgresql-10.*' + sudo service postgresql start ${POSTGRES_MAJOR_VERSION} +fi diff --git a/scripts/before_script_postgres.sh b/scripts/before_script_postgres.sh new file mode 100755 index 0000000000..c63d30de23 --- /dev/null +++ b/scripts/before_script_postgres.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +echo "[SCRIPT] Before Script :: Setup Parse DB for Postgres ${POSTGRES_MAJOR_VERSION}" + +node -e 'require("./lib/index.js")' + +psql -v ON_ERROR_STOP=1 --username "postgres" --dbname "${POSTGRES_DB}" <<-EOSQL + CREATE DATABASE parse_server_postgres_adapter_test_database; + \c parse_server_postgres_adapter_test_database; + CREATE EXTENSION postgis; + CREATE EXTENSION postgis_topology; +EOSQL + diff --git a/spec/.babelrc b/spec/.babelrc new file mode 100644 index 0000000000..a611705cd0 --- /dev/null +++ b/spec/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + "transform-object-rest-spread" + ], + "presets": [ + ["env", { + "targets": { + "node": "8" + } + }] + ], + "sourceMaps": "inline", + "retainLines": true +} diff --git a/spec/.eslintrc.json b/spec/.eslintrc.json index 007ccf2390..7031e96d6f 100644 --- a/spec/.eslintrc.json +++ b/spec/.eslintrc.json @@ -13,6 +13,7 @@ "Item": true, "Container": true, "equal": true, + "expectAsync": true, "notEqual": true, "it_only_db": true, "it_exclude_dbs": true, @@ -20,14 +21,15 @@ "describe_only": true, "on_db": true, "defaultConfiguration": true, - "expectSuccess": true, "range": true, - "expectError": true, "jequal": true, "create": true, - "arrayContains": true + "arrayContains": true, + "expectAsync": true, + "databaseAdapter": true }, "rules": { - "no-console": [0] + "no-console": [0], + "no-var": "error" } } diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js index 92f56fea75..5fac8a703d 100644 --- a/spec/AccountLockoutPolicy.spec.js +++ b/spec/AccountLockoutPolicy.spec.js @@ -1,8 +1,8 @@ -"use strict"; +'use strict'; -const Config = require("../src/Config"); +const Config = require('../lib/Config'); -var loginWithWrongCredentialsShouldFail = function(username, password) { +const loginWithWrongCredentialsShouldFail = function(username, password) { return new Promise((resolve, reject) => { Parse.User.logIn(username, password) .then(() => reject('login should have failed')) @@ -16,13 +16,18 @@ var loginWithWrongCredentialsShouldFail = function(username, password) { }); }; -var isAccountLockoutError = function(username, password, duration, waitTime) { +const isAccountLockoutError = function(username, password, duration, waitTime) { return new Promise((resolve, reject) => { setTimeout(() => { Parse.User.logIn(username, password) .then(() => reject('login should have failed')) .catch(err => { - if (err.message === 'Your account is locked due to multiple failed login attempts. Please try again after ' + duration + ' minute(s)') { + if ( + err.message === + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + duration + + ' minute(s)' + ) { resolve(); } else { reject(err); @@ -32,31 +37,41 @@ var isAccountLockoutError = function(username, password, duration, waitTime) { }); }; -describe("Account Lockout Policy: ", () => { - +describe('Account Lockout Policy: ', () => { it('account should not be locked even after failed login attempts if account lockout policy is not set', done => { reconfigureServer({ appName: 'unlimited', publicServerURL: 'http://localhost:1337/1', }) .then(() => { - var user = new Parse.User(); + const user = new Parse.User(); user.setUsername('username1'); user.setPassword('password'); return user.signUp(null); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 1'); + return loginWithWrongCredentialsShouldFail( + 'username1', + 'incorrect password 1' + ); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 2'); + return loginWithWrongCredentialsShouldFail( + 'username1', + 'incorrect password 2' + ); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 3'); + return loginWithWrongCredentialsShouldFail( + 'username1', + 'incorrect password 3' + ); }) .then(() => done()) .catch(err => { - fail('allow unlimited failed login attempts failed: ' + JSON.stringify(err)); + fail( + 'allow unlimited failed login attempts failed: ' + JSON.stringify(err) + ); done(); }); }); @@ -66,9 +81,9 @@ describe("Account Lockout Policy: ", () => { appName: 'duration', accountLockout: { duration: 'invalid value', - threshold: 5 + threshold: 5, }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -76,10 +91,17 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') { + if ( + err && + err === + 'Account lockout duration should be greater than 0 and less than 100000' + ) { done(); } else { - fail('set duration to an invalid number test failed: ' + JSON.stringify(err)); + fail( + 'set duration to an invalid number test failed: ' + + JSON.stringify(err) + ); done(); } }); @@ -90,9 +112,9 @@ describe("Account Lockout Policy: ", () => { appName: 'threshold', accountLockout: { duration: 5, - threshold: 'invalid number' + threshold: 'invalid number', }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -100,10 +122,17 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') { + if ( + err && + err === + 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { done(); } else { - fail('set threshold to an invalid number test failed: ' + JSON.stringify(err)); + fail( + 'set threshold to an invalid number test failed: ' + + JSON.stringify(err) + ); done(); } }); @@ -114,9 +143,9 @@ describe("Account Lockout Policy: ", () => { appName: 'threshold', accountLockout: { duration: 5, - threshold: 0 + threshold: 0, }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -124,10 +153,16 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') { + if ( + err && + err === + 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { done(); } else { - fail('threshold value < 1 is invalid test failed: ' + JSON.stringify(err)); + fail( + 'threshold value < 1 is invalid test failed: ' + JSON.stringify(err) + ); done(); } }); @@ -138,9 +173,9 @@ describe("Account Lockout Policy: ", () => { appName: 'threshold', accountLockout: { duration: 5, - threshold: 1000 + threshold: 1000, }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -148,10 +183,17 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') { + if ( + err && + err === + 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { done(); } else { - fail('threshold value > 999 is invalid test failed: ' + JSON.stringify(err)); + fail( + 'threshold value > 999 is invalid test failed: ' + + JSON.stringify(err) + ); done(); } }); @@ -162,9 +204,9 @@ describe("Account Lockout Policy: ", () => { appName: 'duration', accountLockout: { duration: 0, - threshold: 5 + threshold: 5, }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -172,10 +214,16 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') { + if ( + err && + err === + 'Account lockout duration should be greater than 0 and less than 100000' + ) { done(); } else { - fail('duration value < 1 is invalid test failed: ' + JSON.stringify(err)); + fail( + 'duration value < 1 is invalid test failed: ' + JSON.stringify(err) + ); done(); } }); @@ -186,9 +234,9 @@ describe("Account Lockout Policy: ", () => { appName: 'duration', accountLockout: { duration: 100000, - threshold: 5 + threshold: 5, }, - publicServerURL: "https://my.public.server.com/1" + publicServerURL: 'https://my.public.server.com/1', }) .then(() => { Config.get('test'); @@ -196,10 +244,17 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') { + if ( + err && + err === + 'Account lockout duration should be greater than 0 and less than 100000' + ) { done(); } else { - fail('duration value > 99999 is invalid test failed: ' + JSON.stringify(err)); + fail( + 'duration value > 99999 is invalid test failed: ' + + JSON.stringify(err) + ); done(); } }); @@ -210,21 +265,27 @@ describe("Account Lockout Policy: ", () => { appName: 'lockout threshold', accountLockout: { duration: 1, - threshold: 2 + threshold: 2, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - var user = new Parse.User(); - user.setUsername("username2"); - user.setPassword("failedLoginAttemptsThreshold"); + const user = new Parse.User(); + user.setUsername('username2'); + user.setPassword('failedLoginAttemptsThreshold'); return user.signUp(); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username2', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username2', + 'wrong password' + ); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username2', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username2', + 'wrong password' + ); }) .then(() => { return isAccountLockoutError('username2', 'wrong password', 1, 1); @@ -233,7 +294,10 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - fail('lock account after failed login attempts test failed: ' + JSON.stringify(err)); + fail( + 'lock account after failed login attempts test failed: ' + + JSON.stringify(err) + ); done(); }); }); @@ -243,34 +307,43 @@ describe("Account Lockout Policy: ", () => { appName: 'lockout threshold', accountLockout: { duration: 0.05, // 0.05*60 = 3 secs - threshold: 2 + threshold: 2, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - var user = new Parse.User(); - user.setUsername("username3"); - user.setPassword("failedLoginAttemptsThreshold"); + const user = new Parse.User(); + user.setUsername('username3'); + user.setPassword('failedLoginAttemptsThreshold'); return user.signUp(); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username3', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username3', + 'wrong password' + ); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username3', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username3', + 'wrong password' + ); }) .then(() => { return isAccountLockoutError('username3', 'wrong password', 0.05, 1); }) .then(() => { - // account should still be locked even after 2 seconds. + // account should still be locked even after 2 seconds. return isAccountLockoutError('username3', 'wrong password', 0.05, 2000); }) .then(() => { done(); }) .catch(err => { - fail('account should be locked for duration mins test failed: ' + JSON.stringify(err)); + fail( + 'account should be locked for duration mins test failed: ' + + JSON.stringify(err) + ); done(); }); }); @@ -280,24 +353,30 @@ describe("Account Lockout Policy: ", () => { appName: 'lockout threshold', accountLockout: { duration: 0.05, // 0.05*60 = 3 secs - threshold: 2 + threshold: 2, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - var user = new Parse.User(); - user.setUsername("username4"); - user.setPassword("correct password"); + const user = new Parse.User(); + user.setUsername('username4'); + user.setPassword('correct password'); return user.signUp(); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username4', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username4', + 'wrong password' + ); }) .then(() => { - return loginWithWrongCredentialsShouldFail('username4', 'wrong password'); + return loginWithWrongCredentialsShouldFail( + 'username4', + 'wrong password' + ); }) .then(() => { - // allow locked user to login after 3 seconds with a valid userid and password + // allow locked user to login after 3 seconds with a valid userid and password return new Promise((resolve, reject) => { setTimeout(() => { Parse.User.logIn('username4', 'correct password') @@ -310,9 +389,11 @@ describe("Account Lockout Policy: ", () => { done(); }) .catch(err => { - fail('allow login for locked account after accountPolicy.duration minutes test failed: ' + JSON.stringify(err)); + fail( + 'allow login for locked account after accountPolicy.duration minutes test failed: ' + + JSON.stringify(err) + ); done(); }); }); - -}) +}); diff --git a/spec/AdaptableController.spec.js b/spec/AdaptableController.spec.js index 4d2ec0f13e..d0bfb16405 100644 --- a/spec/AdaptableController.spec.js +++ b/spec/AdaptableController.spec.js @@ -1,83 +1,86 @@ +const AdaptableController = require('../lib/Controllers/AdaptableController') + .AdaptableController; +const FilesAdapter = require('../lib/Adapters/Files/FilesAdapter').default; +const FilesController = require('../lib/Controllers/FilesController') + .FilesController; -var AdaptableController = require("../src/Controllers/AdaptableController").AdaptableController; -var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; -var FilesController = require("../src/Controllers/FilesController").FilesController; - -var MockController = function(options) { +const MockController = function(options) { AdaptableController.call(this, options); -} +}; MockController.prototype = Object.create(AdaptableController.prototype); MockController.prototype.constructor = AdaptableController; -describe("AdaptableController", ()=>{ - it("should use the provided adapter", (done) => { - var adapter = new FilesAdapter(); - var controller = new FilesController(adapter); +describe('AdaptableController', () => { + it('should use the provided adapter', done => { + const adapter = new FilesAdapter(); + const controller = new FilesController(adapter); expect(controller.adapter).toBe(adapter); // make sure _adapter is private expect(controller._adapter).toBe(undefined); // Override _adapter is not doing anything - controller._adapter = "Hello"; + controller._adapter = 'Hello'; expect(controller.adapter).toBe(adapter); done(); }); - it("should throw when creating a new mock controller", (done) => { - var adapter = new FilesAdapter(); + it('should throw when creating a new mock controller', done => { + const adapter = new FilesAdapter(); expect(() => { new MockController(adapter); }).toThrow(); done(); }); - it("should fail setting the wrong adapter to the controller", (done) => { + it('should fail setting the wrong adapter to the controller', done => { function WrongAdapter() {} - var adapter = new FilesAdapter(); - var controller = new FilesController(adapter); - var otherAdapter = new WrongAdapter(); + const adapter = new FilesAdapter(); + const controller = new FilesController(adapter); + const otherAdapter = new WrongAdapter(); expect(() => { controller.adapter = otherAdapter; }).toThrow(); done(); }); - it("should fail to instantiate a controller with wrong adapter", (done) => { + it('should fail to instantiate a controller with wrong adapter', done => { function WrongAdapter() {} - var adapter = new WrongAdapter(); + const adapter = new WrongAdapter(); expect(() => { new FilesController(adapter); }).toThrow(); done(); }); - it("should fail to instantiate a controller without an adapter", (done) => { + it('should fail to instantiate a controller without an adapter', done => { expect(() => { new FilesController(); }).toThrow(); done(); }); - it("should accept an object adapter", (done) => { - var adapter = { - createFile: function() { }, - deleteFile: function() { }, - getFileData: function() { }, - getFileLocation: function() { }, - } + it('should accept an object adapter', done => { + const adapter = { + createFile: function() {}, + deleteFile: function() {}, + getFileData: function() {}, + getFileLocation: function() {}, + validateFilename: function() {}, + }; expect(() => { new FilesController(adapter); }).not.toThrow(); done(); }); - it("should accept an object adapter", (done) => { + it('should accept an prototype based object adapter', done => { function AGoodAdapter() {} - AGoodAdapter.prototype.createFile = function() { }; - AGoodAdapter.prototype.deleteFile = function() { }; - AGoodAdapter.prototype.getFileData = function() { }; - AGoodAdapter.prototype.getFileLocation = function() { }; + AGoodAdapter.prototype.createFile = function() {}; + AGoodAdapter.prototype.deleteFile = function() {}; + AGoodAdapter.prototype.getFileData = function() {}; + AGoodAdapter.prototype.getFileLocation = function() {}; + AGoodAdapter.prototype.validateFilename = function() {}; - var adapter = new AGoodAdapter(); + const adapter = new AGoodAdapter(); expect(() => { new FilesController(adapter); }).not.toThrow(); diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index c318fac838..9f1f526405 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -1,41 +1,41 @@ +const loadAdapter = require('../lib/Adapters/AdapterLoader').loadAdapter; +const FilesAdapter = require('@parse/fs-files-adapter').default; +const S3Adapter = require('@parse/s3-files-adapter').default; +const ParsePushAdapter = require('@parse/push-adapter').default; +const Config = require('../lib/Config'); -var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; -var FilesAdapter = require("parse-server-fs-adapter").default; -var S3Adapter = require("parse-server-s3-adapter").default; -var ParsePushAdapter = require("parse-server-push-adapter").default; -const Config = require('../src/Config'); +describe('AdapterLoader', () => { + it('should instantiate an adapter from string in object', done => { + const adapterPath = require('path').resolve('./spec/MockAdapter'); -describe("AdapterLoader", ()=>{ - - it("should instantiate an adapter from string in object", (done) => { - var adapterPath = require('path').resolve("./spec/MockAdapter"); - - var adapter = loadAdapter({ + const adapter = loadAdapter({ adapter: adapterPath, options: { - key: "value", - foo: "bar" - } + key: 'value', + foo: 'bar', + }, }); expect(adapter instanceof Object).toBe(true); - expect(adapter.options.key).toBe("value"); - expect(adapter.options.foo).toBe("bar"); + expect(adapter.options.key).toBe('value'); + expect(adapter.options.foo).toBe('bar'); done(); }); - it("should instantiate an adapter from string", (done) => { - var adapterPath = require('path').resolve("./spec/MockAdapter"); - var adapter = loadAdapter(adapterPath); + it('should instantiate an adapter from string', done => { + const adapterPath = require('path').resolve('./spec/MockAdapter'); + const adapter = loadAdapter(adapterPath); expect(adapter instanceof Object).toBe(true); done(); }); - it("should instantiate an adapter from string that is module", (done) => { - var adapterPath = require('path').resolve("./src/Adapters/Files/FilesAdapter"); - var adapter = loadAdapter({ - adapter: adapterPath + it('should instantiate an adapter from string that is module', done => { + const adapterPath = require('path').resolve( + './lib/Adapters/Files/FilesAdapter' + ); + const adapter = loadAdapter({ + adapter: adapterPath, }); expect(typeof adapter).toBe('object'); @@ -46,9 +46,9 @@ describe("AdapterLoader", ()=>{ done(); }); - it("should instantiate an adapter from npm module", (done) => { - var adapter = loadAdapter({ - module: 'parse-server-fs-adapter' + it('should instantiate an adapter from npm module', done => { + const adapter = loadAdapter({ + module: '@parse/fs-files-adapter', }); expect(typeof adapter).toBe('object'); @@ -59,77 +59,77 @@ describe("AdapterLoader", ()=>{ done(); }); - it("should instantiate an adapter from function/Class", (done) => { - var adapter = loadAdapter({ - adapter: FilesAdapter + it('should instantiate an adapter from function/Class', done => { + const adapter = loadAdapter({ + adapter: FilesAdapter, }); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should instantiate the default adapter from Class", (done) => { - var adapter = loadAdapter(null, FilesAdapter); + it('should instantiate the default adapter from Class', done => { + const adapter = loadAdapter(null, FilesAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should use the default adapter", (done) => { - var defaultAdapter = new FilesAdapter(); - var adapter = loadAdapter(null, defaultAdapter); + it('should use the default adapter', done => { + const defaultAdapter = new FilesAdapter(); + const adapter = loadAdapter(null, defaultAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should use the provided adapter", (done) => { - var originalAdapter = new FilesAdapter(); - var adapter = loadAdapter(originalAdapter); + it('should use the provided adapter', done => { + const originalAdapter = new FilesAdapter(); + const adapter = loadAdapter(originalAdapter); expect(adapter).toBe(originalAdapter); done(); }); - it("should fail loading an improperly configured adapter", (done) => { - var Adapter = function(options) { + it('should fail loading an improperly configured adapter', done => { + const Adapter = function(options) { if (!options.foo) { - throw "foo is required for that adapter"; + throw 'foo is required for that adapter'; } - } - var adapterOptions = { - param: "key", - doSomething: function() {} + }; + const adapterOptions = { + param: 'key', + doSomething: function() {}, }; expect(() => { - var adapter = loadAdapter(adapterOptions, Adapter); + const adapter = loadAdapter(adapterOptions, Adapter); expect(adapter).toEqual(adapterOptions); - }).not.toThrow("foo is required for that adapter"); + }).not.toThrow('foo is required for that adapter'); done(); }); - it("should load push adapter from options", (done) => { - var options = { + it('should load push adapter from options', done => { + const options = { android: { senderId: 'yolo', - apiKey: 'yolo' - } - } + apiKey: 'yolo', + }, + }; expect(() => { - var adapter = loadAdapter(undefined, ParsePushAdapter, options); + const adapter = loadAdapter(undefined, ParsePushAdapter, options); expect(adapter.constructor).toBe(ParsePushAdapter); expect(adapter).not.toBe(undefined); }).not.toThrow(); done(); }); - it("should load custom push adapter from string (#3544)", (done) => { - var adapterPath = require('path').resolve("./spec/MockPushAdapter"); - var options = { + it('should load custom push adapter from string (#3544)', done => { + const adapterPath = require('path').resolve('./spec/MockPushAdapter'); + const options = { ios: { - bundleId: 'bundle.id' - } - } + bundleId: 'bundle.id', + }, + }; const pushAdapterOptions = { adapter: adapterPath, - options + options, }; expect(() => { reconfigureServer({ @@ -144,12 +144,13 @@ describe("AdapterLoader", ()=>{ }).not.toThrow(); }); - it("should load S3Adapter from direct passing", (done) => { - var s3Adapter = new S3Adapter("key", "secret", "bucket") + it('should load S3Adapter from direct passing', done => { + spyOn(console, 'warn').and.callFake(() => {}); + const s3Adapter = new S3Adapter('key', 'secret', 'bucket'); expect(() => { - var adapter = loadAdapter(s3Adapter, FilesAdapter); + const adapter = loadAdapter(s3Adapter, FilesAdapter); expect(adapter).toBe(s3Adapter); }).not.toThrow(); done(); - }) + }); }); diff --git a/spec/AggregateRouter.spec.js b/spec/AggregateRouter.spec.js new file mode 100644 index 0000000000..e0f9bac18c --- /dev/null +++ b/spec/AggregateRouter.spec.js @@ -0,0 +1,82 @@ +const AggregateRouter = require('../lib/Routers/AggregateRouter') + .AggregateRouter; + +describe('AggregateRouter', () => { + it('get pipeline from Array', () => { + const body = [ + { + group: { objectId: {} }, + }, + ]; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Object', () => { + const body = { + group: { objectId: {} }, + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Pipeline Operator (Array)', () => { + const body = { + pipeline: [ + { + group: { objectId: {} }, + }, + ], + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Pipeline Operator (Object)', () => { + const body = { + pipeline: { + group: { objectId: {} }, + }, + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline fails multiple keys in Array stage ', () => { + const body = [ + { + group: { objectId: {} }, + match: { name: 'Test' }, + }, + ]; + try { + AggregateRouter.getPipeline(body); + } catch (e) { + expect(e.message).toBe( + 'Pipeline stages should only have one key found group, match' + ); + } + }); + + it('get pipeline fails multiple keys in Pipeline Operator Array stage ', () => { + const body = { + pipeline: [ + { + group: { objectId: {} }, + match: { name: 'Test' }, + }, + ], + }; + try { + AggregateRouter.getPipeline(body); + } catch (e) { + expect(e.message).toBe( + 'Pipeline stages should only have one key found group, match' + ); + } + }); +}); diff --git a/spec/Analytics.spec.js b/spec/Analytics.spec.js index a4e7d87acc..b608aebe32 100644 --- a/spec/Analytics.spec.js +++ b/spec/Analytics.spec.js @@ -1,61 +1,69 @@ const analyticsAdapter = { appOpened: function() {}, - trackEvent: function() {} -} + trackEvent: function() {}, +}; describe('AnalyticsController', () => { - it('should track a simple event', (done) => { - + it('should track a simple event', done => { spyOn(analyticsAdapter, 'trackEvent').and.callThrough(); reconfigureServer({ - analyticsAdapter - }).then(() => { - return Parse.Analytics.track('MyEvent', { - key: 'value', - count: '0' - }) - }).then(() => { - expect(analyticsAdapter.trackEvent).toHaveBeenCalled(); - var lastCall = analyticsAdapter.trackEvent.calls.first(); - const args = lastCall.args; - expect(args[0]).toEqual('MyEvent'); - expect(args[1]).toEqual({ - dimensions: { + analyticsAdapter, + }) + .then(() => { + return Parse.Analytics.track('MyEvent', { key: 'value', - count: '0' + count: '0', + }); + }) + .then( + () => { + expect(analyticsAdapter.trackEvent).toHaveBeenCalled(); + const lastCall = analyticsAdapter.trackEvent.calls.first(); + const args = lastCall.args; + expect(args[0]).toEqual('MyEvent'); + expect(args[1]).toEqual({ + dimensions: { + key: 'value', + count: '0', + }, + }); + done(); + }, + err => { + fail(JSON.stringify(err)); + done(); } - }); - done(); - }, (err) => { - fail(JSON.stringify(err)); - done(); - }) + ); }); - it('should track a app opened event', (done) => { - + it('should track a app opened event', done => { spyOn(analyticsAdapter, 'appOpened').and.callThrough(); reconfigureServer({ - analyticsAdapter - }).then(() => { - return Parse.Analytics.track('AppOpened', { - key: 'value', - count: '0' - }) - }).then(() => { - expect(analyticsAdapter.appOpened).toHaveBeenCalled(); - var lastCall = analyticsAdapter.appOpened.calls.first(); - const args = lastCall.args; - expect(args[0]).toEqual({ - dimensions: { + analyticsAdapter, + }) + .then(() => { + return Parse.Analytics.track('AppOpened', { key: 'value', - count: '0' + count: '0', + }); + }) + .then( + () => { + expect(analyticsAdapter.appOpened).toHaveBeenCalled(); + const lastCall = analyticsAdapter.appOpened.calls.first(); + const args = lastCall.args; + expect(args[0]).toEqual({ + dimensions: { + key: 'value', + count: '0', + }, + }); + done(); + }, + err => { + fail(JSON.stringify(err)); + done(); } - }); - done(); - }, (err) => { - fail(JSON.stringify(err)); - done(); - }) - }) -}) + ); + }); +}); diff --git a/spec/AudienceRouter.spec.js b/spec/AudienceRouter.spec.js index 5e36adf08b..ac75d52c42 100644 --- a/spec/AudienceRouter.spec.js +++ b/spec/AudienceRouter.spec.js @@ -1,157 +1,185 @@ -var auth = require('../src/Auth'); -var Config = require('../src/Config'); -var rest = require('../src/rest'); -var AudiencesRouter = require('../src/Routers/AudiencesRouter').AudiencesRouter; +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const request = require('../lib/request'); +const AudiencesRouter = require('../lib/Routers/AudiencesRouter') + .AudiencesRouter; describe('AudiencesRouter', () => { - it('uses find condition from request.body', (done) => { - var config = Config.get('test'); - var androidAudienceRequest = { - 'name': 'Android Users', - 'query': '{ "test": "android" }' + it('uses find condition from request.body', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', }; - var iosAudienceRequest = { - 'name': 'Iphone Users', - 'query': '{ "test": "ios" }' + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', }; - var request = { + const request = { config: config, auth: auth.master(config), body: { where: { - query: '{ "test": "android" }' - } + query: '{ "test": "android" }', + }, }, query: {}, - info: {} + info: {}, }; - var router = new AudiencesRouter(); - rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + const router = new AudiencesRouter(); + rest + .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create( + config, + auth.nobody(config), + '_Audience', + iosAudienceRequest + ); }) .then(() => { return router.handleFind(request); }) - .then((res) => { - var results = res.response.results; + .then(res => { + const results = res.response.results; expect(results.length).toEqual(1); done(); }) - .catch((err) => { + .catch(err => { fail(JSON.stringify(err)); done(); }); }); - it('uses find condition from request.query', (done) => { - var config = Config.get('test'); - var androidAudienceRequest = { - 'name': 'Android Users', - 'query': '{ "test": "android" }' + it('uses find condition from request.query', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', }; - var iosAudienceRequest = { - 'name': 'Iphone Users', - 'query': '{ "test": "ios" }' + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { where: { - 'query': '{ "test": "android" }' - } + query: '{ "test": "android" }', + }, }, - info: {} + info: {}, }; - var router = new AudiencesRouter(); - rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + const router = new AudiencesRouter(); + rest + .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create( + config, + auth.nobody(config), + '_Audience', + iosAudienceRequest + ); }) .then(() => { return router.handleFind(request); }) - .then((res) => { - var results = res.response.results; + .then(res => { + const results = res.response.results; expect(results.length).toEqual(1); done(); }) - .catch((err) => { + .catch(err => { fail(err); done(); }); }); - it('query installations with limit = 0', (done) => { - var config = Config.get('test'); - var androidAudienceRequest = { - 'name': 'Android Users', - 'query': '{ "test": "android" }' + it('query installations with limit = 0', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', }; - var iosAudienceRequest = { - 'name': 'Iphone Users', - 'query': '{ "test": "ios" }' + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - limit: 0 + limit: 0, }, - info: {} + info: {}, }; Config.get('test'); - var router = new AudiencesRouter(); - rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + const router = new AudiencesRouter(); + rest + .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create( + config, + auth.nobody(config), + '_Audience', + iosAudienceRequest + ); }) .then(() => { return router.handleFind(request); }) - .then((res) => { - var response = res.response; + .then(res => { + const response = res.response; expect(response.results.length).toEqual(0); done(); }) - .catch((err) => { + .catch(err => { fail(JSON.stringify(err)); done(); }); }); - it('query installations with count = 1', done => { - var config = Config.get('test'); - var androidAudienceRequest = { - 'name': 'Android Users', - 'query': '{ "test": "android" }' + it_exclude_dbs(['postgres'])('query installations with count = 1', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', }; - var iosAudienceRequest = { - 'name': 'Iphone Users', - 'query': '{ "test": "ios" }' + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - count: 1 + count: 1, }, - info: {} + info: {}, }; - var router = new AudiencesRouter(); - rest.create(config, auth.nobody(config), '_Audience', androidAudienceRequest) - .then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest)) + const 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; + .then(res => { + const response = res.response; expect(response.results.length).toEqual(2); expect(response.count).toEqual(2); done(); @@ -159,66 +187,110 @@ describe('AudiencesRouter', () => { .catch(error => { fail(JSON.stringify(error)); done(); - }) + }); }); - it('query installations with limit = 0 and count = 1', (done) => { - var config = Config.get('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: {} - }; + it_exclude_dbs(['postgres'])( + 'query installations with limit = 0 and count = 1', + done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const 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(); - }); - }); + const 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 => { + const 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) => { + 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) => { + 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) => { + 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) => { + 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(); }); @@ -226,112 +298,169 @@ describe('AudiencesRouter', () => { }); }); }); - }); - }); - }); - - 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 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 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 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 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 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 delete with master key', (done) => { - Parse._request('DELETE', `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_exclude_dbs(['postgres'])('should support legacy parse.com audience fields', (done) => { - const database = (Config.get(Parse.applicationId)).database.adapter.database; - const now = new Date(); - Parse._request('POST', 'push_audiences', { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' })}, { useMasterKey: true }) - .then((audience) => { + 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(); + } + ); + }); + + it_exclude_dbs(['postgres'])( + 'should support legacy parse.com audience fields', + done => { + const database = Config.get(Parse.applicationId).database.adapter + .database; + const now = new Date(); + Parse._request( + 'POST', + 'push_audiences', + { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' }) }, + { useMasterKey: true } + ).then(audience => { database.collection('test__Audience').updateOne( { _id: audience.objectId }, { $set: { times_used: 1, - _last_used: now - } + _last_used: now, + }, }, {}, - (error) => { - expect(error).toEqual(null) - database.collection('test__Audience').find({ _id: audience.objectId}).toArray( - (error, rows) => { - expect(error).toEqual(null) + error => { + expect(error).toEqual(null); + database + .collection('test__Audience') + .find({ _id: audience.objectId }) + .toArray((error, rows) => { + expect(error).toEqual(null); expect(rows[0]['times_used']).toEqual(1); expect(rows[0]['_last_used']).toEqual(now); - Parse._request('GET', 'push_audiences/' + audience.objectId, {}, {useMasterKey: true}) - .then((audience) => { + Parse._request( + 'GET', + 'push_audiences/' + audience.objectId, + {}, + { useMasterKey: true } + ) + .then(audience => { expect(audience.name).toEqual('My Audience'); expect(audience.query.deviceType).toEqual('ios'); expect(audience.timesUsed).toEqual(1); expect(audience.lastUsed).toEqual(now.toISOString()); done(); }) - .catch((error) => { done.fail(error); }) + .catch(error => { + done.fail(error); + }); }); - }); + } + ); }); + } + ); + + it('should be able to search on audiences', done => { + Parse._request( + 'POST', + 'push_audiences', + { name: 'neverUsed', query: JSON.stringify({ deviceType: 'ios' }) }, + { useMasterKey: true } + ).then(() => { + const query = { + timesUsed: { $exists: false }, + lastUsed: { $exists: false }, + }; + Parse._request( + 'GET', + 'push_audiences?order=-createdAt&limit=1', + { where: query }, + { useMasterKey: true } + ) + .then(results => { + expect(results.results.length).toEqual(1); + const audience = results.results[0]; + expect(audience.name).toEqual('neverUsed'); + done(); + }) + .catch(error => { + done.fail(error); + }); + }); }); - it('should be able to search on audiences', (done) => { - Parse._request('POST', 'push_audiences', { name: 'neverUsed', query: JSON.stringify({ deviceType: 'ios' })}, { useMasterKey: true }) - .then(() => { - const query = {"timesUsed": {"$exists": false}, "lastUsed": {"$exists": false}}; - Parse._request('GET', 'push_audiences?order=-createdAt&limit=1', {where: query}, {useMasterKey: true}) - .then((results) => { - expect(results.results.length).toEqual(1); - const audience = results.results[0]; - expect(audience.name).toEqual("neverUsed"); - done(); - }) - .catch((error) => { done.fail(error); }) - }) + it('should handle _Audience invalid fields via rest', async () => { + await reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Audience', + body: { lorem: 'ipsum', _method: 'POST' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + expect(true).toBeFalsy(); + } catch (e) { + expect(e.data.code).toBe(107); + expect(e.data.error).toBe('Could not add field lorem'); + } }); }); diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index e68add003b..0884218f2d 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -1,11 +1,11 @@ describe('Auth', () => { - var Auth = require('../src/Auth.js').Auth; - + const { Auth, getAuthForSessionToken } = require('../lib/Auth.js'); + const Config = require('../lib/Config'); describe('getUserRoles', () => { - var auth; - var config; - var currentRoles = null; - var currentUserId = 'userId'; + let auth; + let config; + let currentRoles = null; + const currentUserId = 'userId'; beforeEach(() => { currentRoles = ['role:userId']; @@ -14,80 +14,230 @@ describe('Auth', () => { cacheController: { role: { get: () => Promise.resolve(currentRoles), - set: jasmine.createSpy('set') - } - } - } + set: jasmine.createSpy('set'), + }, + }, + }; spyOn(config.cacheController.role, 'get').and.callThrough(); auth = new Auth({ config: config, isMaster: false, user: { - id: currentUserId + id: currentUserId, }, - installationId: 'installationId' + installationId: 'installationId', }); }); - it('should get user roles from the cache', (done) => { - auth.getUserRoles() - .then((roles) => { - var firstSet = config.cacheController.role.set.calls.first(); - expect(firstSet).toEqual(undefined); + it('should get user roles from the cache', done => { + auth.getUserRoles().then(roles => { + const firstSet = config.cacheController.role.set.calls.first(); + expect(firstSet).toEqual(undefined); - var firstGet = config.cacheController.role.get.calls.first(); - expect(firstGet.args[0]).toEqual(currentUserId); - expect(roles).toEqual(currentRoles); - done(); - }); + const firstGet = config.cacheController.role.get.calls.first(); + expect(firstGet.args[0]).toEqual(currentUserId); + expect(roles).toEqual(currentRoles); + done(); + }); }); - it('should only query the roles once', (done) => { - var loadRolesSpy = spyOn(auth, '_loadRoles').and.callThrough(); - auth.getUserRoles() - .then((roles) => { + it('should only query the roles once', done => { + const loadRolesSpy = spyOn(auth, '_loadRoles').and.callThrough(); + auth + .getUserRoles() + .then(roles => { expect(roles).toEqual(currentRoles); - return auth.getUserRoles() + return auth.getUserRoles(); }) .then(() => auth.getUserRoles()) .then(() => auth.getUserRoles()) - .then((roles) => { + .then(roles => { // Should only call the cache adapter once. expect(config.cacheController.role.get.calls.count()).toEqual(1); expect(loadRolesSpy.calls.count()).toEqual(1); - var firstGet = config.cacheController.role.get.calls.first(); + const firstGet = config.cacheController.role.get.calls.first(); expect(firstGet.args[0]).toEqual(currentUserId); expect(roles).toEqual(currentRoles); done(); }); }); - it('should not have any roles with no user', (done) => { - auth.user = null - auth.getUserRoles() - .then((roles) => expect(roles).toEqual([])) + it('should not have any roles with no user', done => { + auth.user = null; + auth + .getUserRoles() + .then(roles => expect(roles).toEqual([])) .then(() => done()); }); - it('should not have any user roles with master', (done) => { - auth.isMaster = true - auth.getUserRoles() - .then((roles) => expect(roles).toEqual([])) + it('should not have any user roles with master', done => { + auth.isMaster = true; + auth + .getUserRoles() + .then(roles => expect(roles).toEqual([])) .then(() => done()); }); - it('should properly handle bcrypt upgrade', (done) => { - var bcryptOriginal = require('bcrypt-nodejs'); - var bcryptNew = require('bcryptjs'); + it('should properly handle bcrypt upgrade', done => { + const bcryptOriginal = require('bcrypt-nodejs'); + const bcryptNew = require('bcryptjs'); bcryptOriginal.hash('my1Long:password', null, null, function(err, res) { bcryptNew.compare('my1Long:password', res, function(err, res) { expect(res).toBeTruthy(); done(); - }) + }); + }); + }); + }); + + it('should load auth without a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + it('should load auth with a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + it('should load auth without a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + it('should load auth with a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + describe('getRolesForUser', () => { + const rolesNumber = 300; + + it('should load all roles without config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i++) { + const acl = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + role.getUsers().add([user]); + roles.push(role.save()); + } + const savedRoles = await Promise.all(roles); + expect(savedRoles.length).toBe(rolesNumber); + const cloudRoles = await userAuth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); + }); + + it('should load all roles with config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i++) { + const acl = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + role.getUsers().add([user]); + roles.push(role.save()); + } + const savedRoles = await Promise.all(roles); + expect(savedRoles.length).toBe(rolesNumber); + const cloudRoles = await userAuth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); }); + it('should load all roles for different users with config', async () => { + const rolesNumber = 100; + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const user2 = new Parse.User(); + await user2.signUp({ + username: 'world', + password: '1234', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + const user2Auth = await getAuthForSessionToken({ + sessionToken: user2.getSessionToken(), + config: Config.get('test'), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i += 1) { + const acl = new Parse.ACL(); + const acl2 = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + const role2 = new Parse.Role('role2loadtest' + i, acl2); + role.getUsers().add([user]); + role2.getUsers().add([user2]); + roles.push(role.save()); + roles.push(role2.save()); + } + const savedRoles = await Promise.all(roles); + expect(savedRoles.length).toBe(rolesNumber * 2); + const cloudRoles = await userAuth.getRolesForUser(); + const cloudRoles2 = await user2Auth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); + expect(cloudRoles2.length).toBe(rolesNumber); + }); }); }); diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f234011569..bdb05698b5 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -1,30 +1,111 @@ -var request = require('request'); -var Config = require("../src/Config"); -var defaultColumns = require('../src/Controllers/SchemaController').defaultColumns; -var authenticationLoader = require('../src/Adapters/Auth'); -var path = require('path'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const defaultColumns = require('../lib/Controllers/SchemaController') + .defaultColumns; +const authenticationLoader = require('../lib/Adapters/Auth'); +const path = require('path'); +const responses = { + gpgames: { playerId: 'userId' }, + instagram: { data: { id: 'userId' } }, + janrainengage: { stat: 'ok', profile: { identifier: 'userId' } }, + janraincapture: { stat: 'ok', result: 'userId' }, + line: { userId: 'userId' }, + vkontakte: { response: [{ id: 'userId' }] }, + google: { sub: 'userId' }, + wechat: { errcode: 0 }, + weibo: { uid: 'userId' }, + qq: 'callback( {"openid":"userId"} );', // yes it's like that, run eval in the client :P + phantauth: { sub: 'userId' }, + microsoft: { id: 'userId', mail: 'userMail' }, +}; describe('AuthenticationProviders', function() { - ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter", "janrainengage", "janraincapture", "vkontakte"].map(function(providerName){ - it("Should validate structure of " + providerName, (done) => { - var provider = require("../src/Adapters/Auth/" + providerName); - jequal(typeof provider.validateAuthData, "function"); - jequal(typeof provider.validateAppId, "function"); - const authDataPromise = provider.validateAuthData({}, {}); - const validateAppIdPromise = provider.validateAppId("app", "key", {}); - jequal(authDataPromise.constructor, Promise.prototype.constructor); + [ + 'apple', + 'gcenter', + 'gpgames', + 'facebook', + 'facebookaccountkit', + 'github', + 'instagram', + 'google', + 'linkedin', + 'meetup', + 'twitter', + 'janrainengage', + 'janraincapture', + 'line', + 'vkontakte', + 'qq', + 'spotify', + 'wechat', + 'weibo', + 'phantauth', + 'microsoft', + ].map(function(providerName) { + it('Should validate structure of ' + providerName, done => { + const provider = require('../lib/Adapters/Auth/' + providerName); + jequal(typeof provider.validateAuthData, 'function'); + jequal(typeof provider.validateAppId, 'function'); + const validateAuthDataPromise = provider.validateAuthData({}, {}); + const validateAppIdPromise = provider.validateAppId('app', 'key', {}); + jequal( + validateAuthDataPromise.constructor, + Promise.prototype.constructor + ); jequal(validateAppIdPromise.constructor, Promise.prototype.constructor); - authDataPromise.then(()=>{}, ()=>{}); - validateAppIdPromise.then(()=>{}, ()=>{}); + validateAuthDataPromise.then( + () => {}, + () => {} + ); + validateAppIdPromise.then( + () => {}, + () => {} + ); done(); }); + + it(`should provide the right responses for adapter ${providerName}`, async () => { + const noResponse = ['twitter', 'apple', 'gcenter']; + if (noResponse.includes(providerName)) { + return; + } + spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( + options => { + if ( + options === + 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.59&grant_type=client_credentials' + ) { + return { + access_token: 'access_token', + }; + } + return Promise.resolve(responses[providerName] || { id: 'userId' }); + } + ); + spyOn( + require('../lib/Adapters/Auth/httpsRequest'), + 'request' + ).and.callFake(() => { + return Promise.resolve(responses[providerName] || { id: 'userId' }); + }); + const provider = require('../lib/Adapters/Auth/' + providerName); + let params = {}; + if (providerName === 'vkontakte') { + params = { + appIds: 'appId', + appSecret: 'appSecret', + }; + } + await provider.validateAuthData({ id: 'userId' }, params); + }); }); - var getMockMyOauthProvider = function() { + const getMockMyOauthProvider = function() { return { authData: { - id: "12345", - access_token: "12345", + id: '12345', + access_token: '12345', expiration_date: new Date().toJSON(), }, shouldError: false, @@ -35,7 +116,7 @@ describe('AuthenticationProviders', function() { authenticate: function(options) { if (this.shouldError) { - options.error(this, "An error occurred"); + options.error(this, 'An error occurred'); } else if (this.shouldCancel) { options.error(this, null); } else { @@ -55,90 +136,101 @@ describe('AuthenticationProviders', function() { return true; }, getAuthType: function() { - return "myoauth"; + return 'myoauth'; }, deauthenticate: function() { this.loggedOut = true; this.restoreAuthentication(null); - } + }, }; }; Parse.User.extend({ extended: function() { return true; - } + }, }); - var createOAuthUser = function(callback) { + const createOAuthUser = function(callback) { return createOAuthUserWithSessionToken(undefined, callback); - } + }; - var createOAuthUserWithSessionToken = function(token, callback) { - var jsonBody = { + const createOAuthUserWithSessionToken = function(token, callback) { + const jsonBody = { authData: { - myoauth: getMockMyOauthProvider().authData - } + myoauth: getMockMyOauthProvider().authData, + }, }; - var options = { - headers: {'X-Parse-Application-Id': 'test', + const options = { + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', 'X-Parse-Installation-Id': 'yolo', 'X-Parse-Session-Token': token, - 'Content-Type': 'application/json' }, + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/users', body: jsonBody, - json: true }; - - return new Promise((resolve) => { - request.post(options, (err, res, body) => { - resolve({err, res, body}); + return request(options) + .then(response => { if (callback) { - callback(err, res, body); + callback(null, response, response.data); } + return { + res: response, + body: response.data, + }; + }) + .catch(error => { + if (callback) { + callback(error); + } + throw error; }); - }); - } + }; - it("should create user with REST API", done => { + it('should create user with REST API', done => { createOAuthUser((error, response, body) => { expect(error).toBe(null); - var b = body; + const b = body; ok(b.sessionToken); expect(b.objectId).not.toBeNull(); expect(b.objectId).not.toBeUndefined(); - var sessionToken = b.sessionToken; - var q = new Parse.Query("_Session"); + const sessionToken = b.sessionToken; + const q = new Parse.Query('_Session'); q.equalTo('sessionToken', sessionToken); - q.first({useMasterKey: true}).then((res) => { - if (!res) { + q.first({ useMasterKey: true }) + .then(res => { + if (!res) { + fail('should not fail fetching the session'); + done(); + return; + } + expect(res.get('installationId')).toEqual('yolo'); + done(); + }) + .catch(() => { fail('should not fail fetching the session'); done(); - return; - } - expect(res.get("installationId")).toEqual('yolo'); - done(); - }).fail(() => { - fail('should not fail fetching the session'); - done(); - }) + }); }); }); - it("should only create a single user with REST API", (done) => { - var objectId; + it('should only create a single user with REST API', done => { + let objectId; createOAuthUser((error, response, body) => { expect(error).toBe(null); - var b = body + const b = body; expect(b.objectId).not.toBeNull(); expect(b.objectId).not.toBeUndefined(); objectId = b.objectId; createOAuthUser((error, response, body) => { expect(error).toBe(null); - var b = body; + const b = body; expect(b.objectId).not.toBeNull(); expect(b.objectId).not.toBeUndefined(); expect(b.objectId).toBe(objectId); @@ -147,83 +239,72 @@ describe('AuthenticationProviders', function() { }); }); - it("should fail to link if session token don't match user", (done) => { - Parse.User.signUp('myUser', 'password').then((user) => { - return createOAuthUserWithSessionToken(user.getSessionToken()); - }).then(() => { - return Parse.User.logOut(); - }).then(() => { - return Parse.User.signUp('myUser2', 'password'); - }).then((user) => { - return createOAuthUserWithSessionToken(user.getSessionToken()); - }).then(({ body }) => { - expect(body.code).toBe(208); - expect(body.error).toBe('this auth is already used'); - done(); - }).catch(done.fail); + it("should fail to link if session token don't match user", done => { + Parse.User.signUp('myUser', 'password') + .then(user => { + return createOAuthUserWithSessionToken(user.getSessionToken()); + }) + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.signUp('myUser2', 'password'); + }) + .then(user => { + return createOAuthUserWithSessionToken(user.getSessionToken()); + }) + .then(fail, ({ data }) => { + expect(data.code).toBe(208); + expect(data.error).toBe('this auth is already used'); + done(); + }) + .catch(done.fail); }); - it("unlink and link with custom provider", (done) => { - var provider = getMockMyOauthProvider(); + it('unlink and link with custom provider', async () => { + const provider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("myoauth", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - - model._unlinkFrom("myoauth", { - success: function(model) { - - ok(!model._isLinked("myoauth"), - "User should not be linked to myoauth"); - ok(!provider.synchronizedUserId, "User id should be cleared"); - ok(!provider.synchronizedAuthToken, "Auth token should be cleared"); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared"); - // make sure the auth data is properly deleted - var config = Config.get(Parse.applicationId); - config.database.adapter.find('_User', { - fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), - }, { objectId: model.id }, {}) - .then(res => { - expect(res.length).toBe(1); - expect(res[0]._auth_data_myoauth).toBeUndefined(); - expect(res[0]._auth_data_myoauth).not.toBeNull(); - - model._linkWith("myoauth", { - success: function(model) { - ok(provider.synchronizedUserId, "User id should have a value"); - ok(provider.synchronizedAuthToken, - "Auth token should have a value"); - ok(provider.synchronizedExpiration, - "Expiration should have a value"); - ok(model._isLinked("myoauth"), - "User should be linked to myoauth"); - done(); - }, - error: function() { - ok(false, "linking again should succeed"); - done(); - } - }); - }); - }, - error: function() { - ok(false, "unlinking should succeed"); - done(); - } - }); + const model = await Parse.User._logInWith('myoauth'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + + await model._unlinkFrom('myoauth'); + ok(!model._isLinked('myoauth'), 'User should not be linked to myoauth'); + ok(!provider.synchronizedUserId, 'User id should be cleared'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared'); + // make sure the auth data is properly deleted + const config = Config.get(Parse.applicationId); + const res = await config.database.adapter.find( + '_User', + { + fields: Object.assign( + {}, + defaultColumns._Default, + defaultColumns._Installation + ), }, - error: function() { - ok(false, "linking should have worked"); - done(); - } - }); + { objectId: model.id }, + {} + ); + expect(res.length).toBe(1); + expect(res[0]._auth_data_myoauth).toBeUndefined(); + expect(res[0]._auth_data_myoauth).not.toBeNull(); + + await model._linkWith('myoauth'); + + ok(provider.synchronizedUserId, 'User id should have a value'); + ok(provider.synchronizedAuthToken, 'Auth token should have a value'); + ok(provider.synchronizedExpiration, 'Expiration should have a value'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); }); function validateValidator(validator) { @@ -232,117 +313,1180 @@ describe('AuthenticationProviders', function() { function validateAuthenticationHandler(authenticationHandler) { expect(authenticationHandler).not.toBeUndefined(); - expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); - expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); + expect(typeof authenticationHandler.getValidatorForProvider).toBe( + 'function' + ); + expect(typeof authenticationHandler.getValidatorForProvider).toBe( + 'function' + ); } function validateAuthenticationAdapter(authAdapter) { expect(authAdapter).not.toBeUndefined(); - if (!authAdapter) { return; } + if (!authAdapter) { + return; + } expect(typeof authAdapter.validateAuthData).toBe('function'); expect(typeof authAdapter.validateAppId).toBe('function'); } - it('properly loads custom adapter', (done) => { - var validAuthData = { + it('properly loads custom adapter', done => { + const validAuthData = { id: 'hello', - token: 'world' - } + token: 'world', + }; const adapter = { validateAppId: function() { return Promise.resolve(); }, validateAuthData: function(authData) { - if (authData.id == validAuthData.id && authData.token == validAuthData.token) { + if ( + authData.id == validAuthData.id && + authData.token == validAuthData.token + ) { return Promise.resolve(); } return Promise.reject(); - } + }, }; const authDataSpy = spyOn(adapter, 'validateAuthData').and.callThrough(); const appIdSpy = spyOn(adapter, 'validateAppId').and.callThrough(); const authenticationHandler = authenticationLoader({ - customAuthentication: adapter + customAuthentication: adapter, }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const validator = authenticationHandler.getValidatorForProvider( + 'customAuthentication' + ); validateValidator(validator); - validator(validAuthData).then(() => { - expect(authDataSpy).toHaveBeenCalled(); - // AppIds are not provided in the adapter, should not be called - expect(appIdSpy).not.toHaveBeenCalled(); - done(); - }, (err) => { - jfail(err); - done(); - }) + validator(validAuthData).then( + () => { + expect(authDataSpy).toHaveBeenCalled(); + // AppIds are not provided in the adapter, should not be called + expect(appIdSpy).not.toHaveBeenCalled(); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('properly loads custom adapter module object', (done) => { + it('properly loads custom adapter module object', done => { const authenticationHandler = authenticationLoader({ - customAuthentication: path.resolve('./spec/support/CustomAuth.js') + customAuthentication: path.resolve('./spec/support/CustomAuth.js'), }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const validator = authenticationHandler.getValidatorForProvider( + 'customAuthentication' + ); validateValidator(validator); validator({ - token: 'my-token' - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); - }) + token: 'my-token', + }).then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('properly loads custom adapter module object', (done) => { + it('properly loads custom adapter module object (again)', done => { const authenticationHandler = authenticationLoader({ - customAuthentication: { module: path.resolve('./spec/support/CustomAuthFunction.js'), options: { token: 'valid-token' }} + customAuthentication: { + module: path.resolve('./spec/support/CustomAuthFunction.js'), + options: { token: 'valid-token' }, + }, }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const validator = authenticationHandler.getValidatorForProvider( + 'customAuthentication' + ); validateValidator(validator); validator({ - token: 'valid-token' - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); - }) + token: 'valid-token', + }).then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); it('properly loads a default adapter with options', () => { const options = { facebook: { - appIds: ['a', 'b'] - } + appIds: ['a', 'b'], + appSecret: 'secret', + }, }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter('facebook', options); + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('facebook', options); validateAuthenticationAdapter(adapter); expect(appIds).toEqual(['a', 'b']); expect(providerOptions).toEqual(options.facebook); }); + it('should handle Facebook appSecret for validating appIds', async () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ id: 'a' }); + }); + const options = { + facebook: { + appIds: ['a', 'b'], + appSecret: 'secret_sauce', + }, + }; + const authData = { + access_token: 'badtoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('facebook', options); + await adapter.validateAppId(appIds, authData, providerOptions); + expect( + httpsRequest.get.calls.first().args[0].includes('appsecret_proof') + ).toBe(true); + }); + + it('should handle Facebook appSecret for validating auth data', async () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve(); + }); + const options = { + facebook: { + appIds: ['a', 'b'], + appSecret: 'secret_sauce', + }, + }; + const authData = { + id: 'test', + access_token: 'test', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'facebook', + options + ); + await adapter.validateAuthData(authData, providerOptions); + expect( + httpsRequest.get.calls.first().args[0].includes('appsecret_proof') + ).toBe(true); + }); + it('properly loads a custom adapter with options', () => { const options = { custom: { validateAppId: () => {}, validateAuthData: () => {}, - appIds: ['a', 'b'] - } + appIds: ['a', 'b'], + }, }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter('custom', options); + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('custom', options); validateAuthenticationAdapter(adapter); expect(appIds).toEqual(['a', 'b']); expect(providerOptions).toEqual(options.custom); }); + + it('properly loads Facebook accountkit adapter with options', () => { + const options = { + facebookaccountkit: { + appIds: ['a', 'b'], + appSecret: 'secret', + }, + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('facebookaccountkit', options); + validateAuthenticationAdapter(adapter); + expect(appIds).toEqual(['a', 'b']); + expect(providerOptions.appSecret).toEqual('secret'); + }); + + it('should fail if Facebook appIds is not configured properly', done => { + const options = { + facebookaccountkit: { + appIds: [], + }, + }; + const { adapter, appIds } = authenticationLoader.loadAuthAdapter( + 'facebookaccountkit', + options + ); + adapter.validateAppId(appIds).then(done.fail, err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + }); + + it('should fail to validate Facebook accountkit auth with bad token', done => { + const options = { + facebookaccountkit: { + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'badtoken', + }; + const { adapter } = authenticationLoader.loadAuthAdapter( + 'facebookaccountkit', + options + ); + adapter.validateAuthData(authData).then(done.fail, err => { + expect(err.code).toBe(190); + expect(err.type).toBe('OAuthException'); + done(); + }); + }); + + it('should fail to validate Facebook accountkit auth with bad token regardless of app secret proof', done => { + const options = { + facebookaccountkit: { + appIds: ['a', 'b'], + appSecret: 'badsecret', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'badtoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'facebookaccountkit', + options + ); + adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { + expect(err.code).toBe(190); + expect(err.type).toBe('OAuthException'); + done(); + }); + }); +}); + +describe('google auth adapter', () => { + const google = require('../lib/Adapters/Auth/google'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('should use id_token for validation is passed', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ sub: 'userId' }); + }); + await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + }); + + it('should use id_token for validation is passed and responds with user_id', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ user_id: 'userId' }); + }); + await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + }); + + it('should use access_token for validation is passed and responds with user_id', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ user_id: 'userId' }); + }); + await google.validateAuthData( + { id: 'userId', access_token: 'the_token' }, + {} + ); + }); + + it('should use access_token for validation is passed with sub', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ sub: 'userId' }); + }); + await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + }); + + it('should fail when the id_token is invalid', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ sub: 'badId' }); + }); + try { + await google.validateAuthData( + { id: 'userId', id_token: 'the_token' }, + {} + ); + fail(); + } catch (e) { + expect(e.message).toBe('Google auth is invalid for this user.'); + } + }); + + it('should fail when the access_token is invalid', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ sub: 'badId' }); + }); + try { + await google.validateAuthData( + { id: 'userId', access_token: 'the_token' }, + {} + ); + fail(); + } catch (e) { + expect(e.message).toBe('Google auth is invalid for this user.'); + } + }); +}); + +describe('google play games service auth', () => { + const gpgames = require('../lib/Adapters/Auth/gpgames'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('validateAuthData should pass validation', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ playerId: 'userId' }); + }); + await gpgames.validateAuthData({ + id: 'userId', + access_token: 'access_token', + }); + }); + + it('validateAuthData should throw error', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ playerId: 'invalid' }); + }); + try { + await gpgames.validateAuthData({ + id: 'userId', + access_token: 'access_token', + }); + } catch (e) { + expect(e.message).toBe( + 'Google Play Games Services - authData is invalid for this user.' + ); + } + }); +}); + +describe('oauth2 auth adapter', () => { + const oauth2 = require('../lib/Adapters/Auth/oauth2'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('properly loads OAuth2 adapter via the "oauth2" option', () => { + const options = { + oauth2Authentication: { + oauth2: true, + }, + }; + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + expect(loadedAuthAdapter.adapter).toEqual(oauth2); + }); + + it('properly loads OAuth2 adapter with options', () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + debug: true, + }, + }; + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + const appIds = loadedAuthAdapter.appIds; + const providerOptions = loadedAuthAdapter.providerOptions; + expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual( + 'https://example.com/introspect' + ); + expect(providerOptions.useridField).toEqual('sub'); + expect(providerOptions.appidField).toEqual('appId'); + expect(appIds).toEqual(['a', 'b']); + expect(providerOptions.authorizationHeader).toEqual( + 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' + ); + expect(providerOptions.debug).toEqual(true); + }); + + it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + appIds: ['a', 'b'], + appidField: 'appId', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 token introspection endpoint URL is missing from configuration!' + ); + } + }); + + it('validateAppId appidField optional', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not reach here + fail(e); + } + }); + + it('validateAppId should fail without appIds', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' + ); + } + }); + + it('validateAppId should fail empty appIds', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: [], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' + ); + } + }); + + it('validateAppId invalid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({}); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe('OAuth2 access token is invalid for this user.'); + } + }); + + it('validateAppId invalid accessToken appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ active: true }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." + ); + } + }); + + it('validateAppId valid accessToken appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: 'a', + }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + }); + + it('validateAppId valid accessToken appId array', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: ['a'], + }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + }); + + it('validateAppId valid accessToken invalid appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: 'unknown', + }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." + ); + } + }); + + it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 token introspection endpoint URL is missing from configuration!' + ); + } + }); + + it('validateAuthData invalid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({}); + }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + expect(e.message).toBe('OAuth2 access token is invalid for this user.'); + } + expect(httpsRequest.request).toHaveBeenCalledWith( + { + hostname: 'example.com', + path: '/introspect', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': 15, + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }, + 'token=sometoken' + ); + }); + + it('validateAuthData valid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + sub: 'fakeid', + }); + }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + expect(httpsRequest.request).toHaveBeenCalledWith( + { + hostname: 'example.com', + path: '/introspect', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': 15, + }, + }, + 'token=sometoken' + ); + }); + + it('validateAuthData valid accessToken without useridField', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + sub: 'fakeid', + }); + }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + }); +}); + +describe('apple signin auth adapter', () => { + const apple = require('../lib/Adapters/Auth/apple'); + const jwt = require('jsonwebtoken'); + const util = require('util'); + + it('(using client id as string) should throw error with missing id_token', async () => { + try { + await apple.validateAuthData({}, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('(using client id as array) should throw error with missing id_token', async () => { + try { + await apple.validateAuthData({}, { client_id: ['secret'] }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('should throw error if public key used to encode token is not available', async () => { + const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; + try { + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + ); + } + }); + + it('should use algorithm from key header to verify id_token', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual( + fakeDecodedToken.header.alg + ); + }); + + it('should not verify invalid id_token', async () => { + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it('(using client id as array) should not verify invalid id_token', async () => { + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { client_id: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('(using client id as string) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array with multiple items) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret', 'secret 123'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array) should throw error with with invalid jwt issuer', async () => { + try { + await apple.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT APPLE TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: ['INSERT CLIENT ID HERE'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'jwt issuer invalid. expected: https://appleid.apple.com' + ); + } + }); + + it('(using client id as string) should throw error with with invalid jwt issuer', async () => { + try { + await apple.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT APPLE TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'jwt issuer invalid. expected: https://appleid.apple.com' + ); + } + }); + + it('(using client id as string) should throw error with invalid jwt client_id', async () => { + try { + await apple.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + it('(using client id as array) should throw error with invalid jwt client_id', async () => { + try { + await apple.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + it('should throw error with invalid user id', async () => { + try { + await apple.validateAuthData( + { id: 'invalid user', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt subject invalid. expected: invalid user'); + } + }); +}); + +describe('Apple Game Center Auth adapter', () => { + const gcenter = require('../lib/Adapters/Auth/gcenter'); + + it('validateAuthData should validate', async () => { + // real token is used + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + } catch (e) { + fail(); + } + }); + + it('validateAuthData invalid signature id', async () => { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: '1234', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Apple Game Center - invalid signature'); + } + }); + + it('validateAuthData invalid public key url', async () => { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'invalid.com', + timestamp: 1565257031287, + signature: '1234', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe( + 'Apple Game Center - invalid publicKeyUrl: invalid.com' + ); + } + }); +}); + +describe('phant auth adapter', () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('validateAuthData should throw for invalid auth', async () => { + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter } = authenticationLoader.loadAuthAdapter('phantauth', {}); + + spyOn(httpsRequest, 'get').and.callFake(() => + Promise.resolve({ sub: 'invalidID' }) + ); + try { + await adapter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('PhantAuth auth is invalid for this user.'); + } + }); +}); + +describe('microsoft graph auth adapter', () => { + const microsoft = require('../lib/Adapters/Auth/microsoft'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('should use access_token for validation is passed and responds with id and mail', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ id: 'userId', mail: 'userMail' }); + }); + await microsoft.validateAuthData({ + id: 'userId', + access_token: 'the_token', + }); + }); + + it('should fail to validate Microsoft Graph auth with bad token', done => { + const authData = { + id: 'fake-id', + mail: 'fake@mail.com', + access_token: 'very.long.bad.token', + }; + microsoft.validateAuthData(authData).then(done.fail, err => { + expect(err.code).toBe(101); + expect(err.message).toBe( + 'Microsoft Graph auth is invalid for this user.' + ); + done(); + }); + }); }); diff --git a/spec/CLI.spec.js b/spec/CLI.spec.js index efe9ee1fb2..a635d0f9b3 100644 --- a/spec/CLI.spec.js +++ b/spec/CLI.spec.js @@ -1,32 +1,35 @@ 'use strict'; -import commander from '../src/cli/utils/commander'; -import definitions from '../src/cli/definitions/parse-server'; -import liveQueryDefinitions from '../src/cli/definitions/parse-live-query-server'; +const commander = require('../lib/cli/utils/commander').default; +const definitions = require('../lib/cli/definitions/parse-server').default; +const liveQueryDefinitions = require('../lib/cli/definitions/parse-live-query-server') + .default; +const path = require('path'); +const { spawn } = require('child_process'); -var testDefinitions = { - 'arg0': 'PROGRAM_ARG_0', - 'arg1': { +const testDefinitions = { + arg0: 'PROGRAM_ARG_0', + arg1: { env: 'PROGRAM_ARG_1', - required: true + required: true, }, - 'arg2': { + arg2: { env: 'PROGRAM_ARG_2', action: function(value) { - var intValue = parseInt(value); + const intValue = parseInt(value); if (!Number.isInteger(intValue)) { throw 'arg2 is invalid'; } return intValue; - } + }, + }, + arg3: {}, + arg4: { + default: 'arg4Value', }, - 'arg3': {}, - 'arg4': { - default: 'arg4Value' - } }; describe('commander additions', () => { - afterEach((done) => { + afterEach(done => { commander.options = []; delete commander.arg0; delete commander.arg1; @@ -36,9 +39,20 @@ describe('commander additions', () => { done(); }); - it('should load properly definitions from args', (done) => { + it('should load properly definitions from args', done => { commander.loadDefinitions(testDefinitions); - commander.parse(['node','./CLI.spec.js','--arg0', 'arg0Value', '--arg1', 'arg1Value', '--arg2', '2', '--arg3', 'some']); + commander.parse([ + 'node', + './CLI.spec.js', + '--arg0', + 'arg0Value', + '--arg1', + 'arg1Value', + '--arg2', + '2', + '--arg3', + 'some', + ]); expect(commander.arg0).toEqual('arg0Value'); expect(commander.arg1).toEqual('arg1Value'); expect(commander.arg2).toEqual(2); @@ -47,12 +61,12 @@ describe('commander additions', () => { done(); }); - it('should load properly definitions from env', (done) => { + it('should load properly definitions from env', done => { commander.loadDefinitions(testDefinitions); commander.parse([], { - 'PROGRAM_ARG_0': 'arg0ENVValue', - 'PROGRAM_ARG_1': 'arg1ENVValue', - 'PROGRAM_ARG_2': '3', + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: '3', }); expect(commander.arg0).toEqual('arg0ENVValue'); expect(commander.arg1).toEqual('arg1ENVValue'); @@ -61,14 +75,17 @@ describe('commander additions', () => { done(); }); - it('should load properly use args over env', (done) => { + it('should load properly use args over env', done => { commander.loadDefinitions(testDefinitions); - commander.parse(['node','./CLI.spec.js','--arg0', 'arg0Value', '--arg4', ''], { - 'PROGRAM_ARG_0': 'arg0ENVValue', - 'PROGRAM_ARG_1': 'arg1ENVValue', - 'PROGRAM_ARG_2': '4', - 'PROGRAM_ARG_4': 'arg4ENVValue' - }); + commander.parse( + ['node', './CLI.spec.js', '--arg0', 'arg0Value', '--arg4', ''], + { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: '4', + PROGRAM_ARG_4: 'arg4ENVValue', + } + ); expect(commander.arg0).toEqual('arg0Value'); expect(commander.arg1).toEqual('arg1ENVValue'); expect(commander.arg2).toEqual(4); @@ -76,24 +93,34 @@ describe('commander additions', () => { done(); }); - it('should fail in action as port is invalid', (done) => { + it('should fail in action as port is invalid', done => { commander.loadDefinitions(testDefinitions); - expect(()=> { - commander.parse(['node','./CLI.spec.js','--arg0', 'arg0Value'], { - 'PROGRAM_ARG_0': 'arg0ENVValue', - 'PROGRAM_ARG_1': 'arg1ENVValue', - 'PROGRAM_ARG_2': 'hello', + expect(() => { + commander.parse(['node', './CLI.spec.js', '--arg0', 'arg0Value'], { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: 'hello', }); }).toThrow('arg2 is invalid'); done(); }); - it('should not override config.json', (done) => { + it('should not override config.json', done => { + spyOn(console, 'log').and.callFake(() => {}); commander.loadDefinitions(testDefinitions); - commander.parse(['node','./CLI.spec.js','--arg0', 'arg0Value', './spec/configs/CLIConfig.json'], { - 'PROGRAM_ARG_0': 'arg0ENVValue', - 'PROGRAM_ARG_1': 'arg1ENVValue', - }); + commander.parse( + [ + 'node', + './CLI.spec.js', + '--arg0', + 'arg0Value', + './spec/configs/CLIConfig.json', + ], + { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + } + ); const options = commander.getOptions(); expect(options.arg2).toBe(8888); expect(options.arg3).toBe('hello'); //config value @@ -101,28 +128,46 @@ describe('commander additions', () => { done(); }); - it('should fail with invalid values in JSON', (done) => { + it('should fail with invalid values in JSON', done => { commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(['node','./CLI.spec.js','--arg0', 'arg0Value', './spec/configs/CLIConfigFail.json'], { - 'PROGRAM_ARG_0': 'arg0ENVValue', - 'PROGRAM_ARG_1': 'arg1ENVValue', - }); + commander.parse( + [ + 'node', + './CLI.spec.js', + '--arg0', + 'arg0Value', + './spec/configs/CLIConfigFail.json', + ], + { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + } + ); }).toThrow('arg2 is invalid'); done(); }); - it('should fail when too many apps are set', (done) => { + it('should fail when too many apps are set', done => { commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(['node','./CLI.spec.js','./spec/configs/CLIConfigFailTooManyApps.json']); + commander.parse([ + 'node', + './CLI.spec.js', + './spec/configs/CLIConfigFailTooManyApps.json', + ]); }).toThrow('Multiple apps are not supported'); done(); }); - it('should load config from apps', (done) => { + it('should load config from apps', done => { + spyOn(console, 'log').and.callFake(() => {}); commander.loadDefinitions(testDefinitions); - commander.parse(['node', './CLI.spec.js', './spec/configs/CLIConfigApps.json']); + commander.parse([ + 'node', + './CLI.spec.js', + './spec/configs/CLIConfigApps.json', + ]); const options = commander.getOptions(); expect(options.arg1).toBe('my_app'); expect(options.arg2).toBe(8888); @@ -131,10 +176,14 @@ describe('commander additions', () => { done(); }); - it('should fail when passing an invalid arguement', (done) => { + it('should fail when passing an invalid arguement', done => { commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(['node', './CLI.spec.js', './spec/configs/CLIConfigUnknownArg.json']); + commander.parse([ + 'node', + './CLI.spec.js', + './spec/configs/CLIConfigUnknownArg.json', + ]); }).toThrow('error: unknown option myArg'); done(); }); @@ -160,7 +209,7 @@ describe('definitions', () => { it('should throw when using deprecated facebookAppIds', () => { expect(() => { - definitions.facebookAppIds.action() + definitions.facebookAppIds.action(); }).toThrow(); }); }); @@ -173,7 +222,10 @@ describe('LiveQuery definitions', () => { if (typeof definition.env !== 'undefined') { expect(typeof definition.env).toBe('string'); } - expect(typeof definition.help).toBe('string'); + expect(typeof definition.help).toBe( + 'string', + `help for ${key} should be a string` + ); if (typeof definition.required !== 'undefined') { expect(typeof definition.required).toBe('boolean'); } @@ -183,3 +235,84 @@ describe('LiveQuery definitions', () => { } }); }); + +describe('execution', () => { + const binPath = path.resolve(__dirname, '../bin/parse-server'); + let childProcess; + + afterEach(async () => { + if (childProcess) { + childProcess.kill(); + } + }); + + it('shoud start Parse Server', done => { + childProcess = spawn(binPath, [ + '--appId', + 'test', + '--masterKey', + 'test', + '--databaseURI', + 'mongodb://localhost/test', + ]); + childProcess.stdout.on('data', data => { + data = data.toString(); + if (data.includes('parse-server running on')) { + done(); + } + }); + childProcess.stderr.on('data', data => { + done.fail(data.toString()); + }); + }); + + it('shoud start Parse Server with GraphQL', done => { + childProcess = spawn(binPath, [ + '--appId', + 'test', + '--masterKey', + 'test', + '--databaseURI', + 'mongodb://localhost/test', + '--mountGraphQL', + ]); + let output = ''; + childProcess.stdout.on('data', data => { + data = data.toString(); + output += data; + if (data.includes('GraphQL running on')) { + expect(output).toMatch('parse-server running on'); + done(); + } + }); + childProcess.stderr.on('data', data => { + done.fail(data.toString()); + }); + }); + + it('shoud start Parse Server with GraphQL and Playground', done => { + childProcess = spawn(binPath, [ + '--appId', + 'test', + '--masterKey', + 'test', + '--databaseURI', + 'mongodb://localhost/test', + '--mountGraphQL', + '--mountPlayground', + ]); + let output = ''; + childProcess.stdout.on('data', data => { + data = data.toString(); + output += data; + if (data.includes('Playground running on')) { + expect(output).toMatch('GraphQL running on'); + expect(output).toMatch('parse-server running on'); + done(); + } + }); + childProcess.stderr.on('data', data => { + done.fail(data.toString()); + }); + }); +}); diff --git a/spec/CacheController.spec.js b/spec/CacheController.spec.js index 1ad99a992d..12f4269fd4 100644 --- a/spec/CacheController.spec.js +++ b/spec/CacheController.spec.js @@ -1,24 +1,24 @@ -var CacheController = require('../src/Controllers/CacheController.js').default; +const CacheController = require('../lib/Controllers/CacheController.js') + .default; describe('CacheController', function() { - var FakeCacheAdapter; - var FakeAppID = 'foo'; - var KEY = 'hello'; + let FakeCacheAdapter; + const FakeAppID = 'foo'; + const KEY = 'hello'; beforeEach(() => { FakeCacheAdapter = { get: () => Promise.resolve(null), put: jasmine.createSpy('put'), del: jasmine.createSpy('del'), - clear: jasmine.createSpy('clear') - } + clear: jasmine.createSpy('clear'), + }; spyOn(FakeCacheAdapter, 'get').and.callThrough(); }); - - it('should expose role and user caches', (done) => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + it('should expose role and user caches', done => { + const cache = new CacheController(FakeCacheAdapter, FakeAppID); expect(cache.role).not.toEqual(null); expect(cache.role.get).not.toEqual(null); @@ -28,27 +28,26 @@ describe('CacheController', function() { done(); }); - - ['role', 'user'].forEach((cacheName) => { + ['role', 'user'].forEach(cacheName => { it('should prefix ' + cacheName + ' cache', () => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID)[cacheName]; + const cache = new CacheController(FakeCacheAdapter, FakeAppID)[cacheName]; cache.put(KEY, 'world'); - var firstPut = FakeCacheAdapter.put.calls.first(); + const firstPut = FakeCacheAdapter.put.calls.first(); expect(firstPut.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); cache.get(KEY); - var firstGet = FakeCacheAdapter.get.calls.first(); + const firstGet = FakeCacheAdapter.get.calls.first(); expect(firstGet.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); cache.del(KEY); - var firstDel = FakeCacheAdapter.del.calls.first(); + const firstDel = FakeCacheAdapter.del.calls.first(); expect(firstDel.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); }); }); it('should clear the entire cache', () => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + const cache = new CacheController(FakeCacheAdapter, FakeAppID); cache.clear(); expect(FakeCacheAdapter.clear.calls.count()).toEqual(1); @@ -60,15 +59,13 @@ describe('CacheController', function() { expect(FakeCacheAdapter.clear.calls.count()).toEqual(3); }); - it('should handle cache rejections', (done) => { - + it('should handle cache rejections', done => { FakeCacheAdapter.get = () => Promise.reject(); - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + const cache = new CacheController(FakeCacheAdapter, FakeAppID); cache.get('foo').then(done, () => { fail('Promise should not be rejected.'); }); }); - }); diff --git a/spec/Client.spec.js b/spec/Client.spec.js index 14b1795529..400503d289 100644 --- a/spec/Client.spec.js +++ b/spec/Client.spec.js @@ -1,10 +1,11 @@ -var Client = require('../src/LiveQuery/Client').Client; -var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; +const Client = require('../lib/LiveQuery/Client').Client; +const ParseWebSocket = require('../lib/LiveQuery/ParseWebSocketServer') + .ParseWebSocket; describe('Client', function() { it('can be initialized', function() { - var parseWebSocket = new ParseWebSocket({}); - var client = new Client(1, parseWebSocket); + const parseWebSocket = new ParseWebSocket({}); + const client = new Client(1, parseWebSocket); expect(client.id).toBe(1); expect(client.parseWebSocket).toBe(parseWebSocket); @@ -12,8 +13,8 @@ describe('Client', function() { }); it('can push response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; Client.pushResponse(parseWebSocket, 'message'); @@ -21,13 +22,13 @@ describe('Client', function() { }); it('can push error', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; Client.pushError(parseWebSocket, 1, 'error', true); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('error'); expect(messageJSON.error).toBe('error'); expect(messageJSON.code).toBe(1); @@ -35,13 +36,13 @@ describe('Client', function() { }); it('can add subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); expect(client.subscriptionInfos.size).toBe(1); @@ -49,73 +50,76 @@ describe('Client', function() { }); it('can get subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); - var subscriptionInfoAgain = client.getSubscriptionInfo(1); + const subscriptionInfoAgain = client.getSubscriptionInfo(1); expect(subscriptionInfoAgain).toBe(subscriptionInfo); }); it('can delete subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); client.deleteSubscriptionInfo(1); expect(client.subscriptionInfos.size).toBe(0); }); - it('can generate ParseObject JSON with null selected field', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); - expect(client._toJSONWithFields(parseObjectJSON, null)).toBe(parseObjectJSON); + expect(client._toJSONWithFields(parseObjectJSON, null)).toBe( + parseObjectJSON + ); }); it('can generate ParseObject JSON with undefined selected field', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); - expect(client._toJSONWithFields(parseObjectJSON, undefined)).toBe(parseObjectJSON); + expect(client._toJSONWithFields(parseObjectJSON, undefined)).toBe( + parseObjectJSON + ); }); it('can generate ParseObject JSON with selected fields', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); expect(client._toJSONWithFields(parseObjectJSON, ['test'])).toEqual({ className: 'test', @@ -123,22 +127,24 @@ describe('Client', function() { updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }); }); it('can generate ParseObject JSON with nonexistent selected fields', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var client = new Client(1, {}); - var limitedParseObject = client._toJSONWithFields(parseObjectJSON, ['name']); + const client = new Client(1, {}); + const limitedParseObject = client._toJSONWithFields(parseObjectJSON, [ + 'name', + ]); expect(limitedParseObject).toEqual({ className: 'test', @@ -151,64 +157,64 @@ describe('Client', function() { }); it('can push connect response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushConnect(); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('connected'); expect(messageJSON.clientId).toBe(1); }); it('can push subscribe response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushSubscribe(2); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('subscribed'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); }); it('can push unsubscribe response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushUnsubscribe(2); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('unsubscribed'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); }); it('can push create response', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushCreate(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('create'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); @@ -216,23 +222,23 @@ describe('Client', function() { }); it('can push enter response', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushEnter(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('enter'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); @@ -240,23 +246,23 @@ describe('Client', function() { }); it('can push update response', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushUpdate(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('update'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); @@ -264,23 +270,23 @@ describe('Client', function() { }); it('can push leave response', function() { - var parseObjectJSON = { - key : 'value', + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushLeave(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('leave'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); diff --git a/spec/ClientSDK.spec.js b/spec/ClientSDK.spec.js index 985c1e86a1..987770833c 100644 --- a/spec/ClientSDK.spec.js +++ b/spec/ClientSDK.spec.js @@ -1,41 +1,49 @@ -var ClientSDK = require('../src/ClientSDK'); +const ClientSDK = require('../lib/ClientSDK'); describe('ClientSDK', () => { it('should properly parse the SDK versions', () => { const clientSDKFromVersion = ClientSDK.fromString; expect(clientSDKFromVersion('i1.1.1')).toEqual({ sdk: 'i', - version: '1.1.1' + version: '1.1.1', }); expect(clientSDKFromVersion('i1')).toEqual({ sdk: 'i', - version: '1' + version: '1', }); expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({ sdk: 'apple-tv', - version: '1.13.0' + version: '1.13.0', }); expect(clientSDKFromVersion('js1.9.0')).toEqual({ sdk: 'js', - version: '1.9.0' + version: '1.9.0', }); }); it('should properly sastisfy', () => { - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js1.9.0")).toBe(true); + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js1.9.0') + ).toBe(true); - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js2.0.0")).toBe(true); + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js2.0.0') + ).toBe(true); - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js1.8.0")).toBe(false); + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js1.8.0') + ).toBe(false); - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })(undefined)).toBe(true); - }) -}) + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })(undefined) + ).toBe(true); + }); +}); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 7ee53ca27c..5688498318 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -1,149 +1,195 @@ -"use strict" -const Parse = require("parse/node"); -const rp = require('request-promise'); -const InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').InMemoryCacheAdapter; +'use strict'; +const Config = require('../lib/Config'); +const Parse = require('parse/node'); +const request = require('../lib/request'); +const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') + .InMemoryCacheAdapter; describe('Cloud Code', () => { it('can load absolute cloud code file', done => { - reconfigureServer({ cloud: __dirname + '/cloud/cloudCodeRelativeFile.js' }) - .then(() => { - Parse.Cloud.run('cloudCodeInFile', {}, result => { - expect(result).toEqual('It is possible to define cloud code in a file.'); - done(); - }); - }) + reconfigureServer({ + cloud: __dirname + '/cloud/cloudCodeRelativeFile.js', + }).then(() => { + Parse.Cloud.run('cloudCodeInFile', {}).then(result => { + expect(result).toEqual( + 'It is possible to define cloud code in a file.' + ); + done(); + }); + }); }); it('can load relative cloud code file', done => { - reconfigureServer({ cloud: './spec/cloud/cloudCodeAbsoluteFile.js' }) - .then(() => { - Parse.Cloud.run('cloudCodeInFile', {}, result => { - expect(result).toEqual('It is possible to define cloud code in a file.'); + reconfigureServer({ cloud: './spec/cloud/cloudCodeAbsoluteFile.js' }).then( + () => { + Parse.Cloud.run('cloudCodeInFile', {}).then(result => { + expect(result).toEqual( + 'It is possible to define cloud code in a file.' + ); done(); }); - }) + } + ); }); it('can create functions', done => { - Parse.Cloud.define('hello', (req, res) => { - res.success('Hello world!'); + Parse.Cloud.define('hello', () => { + return 'Hello world!'; }); - Parse.Cloud.run('hello', {}, result => { + Parse.Cloud.run('hello', {}).then(result => { expect(result).toEqual('Hello world!'); done(); }); }); it('is cleared cleared after the previous test', done => { - Parse.Cloud.run('hello', {}) - .catch(error => { - expect(error.code).toEqual(141); - done(); - }); + Parse.Cloud.run('hello', {}).catch(error => { + expect(error.code).toEqual(141); + done(); + }); }); it('basic beforeSave rejection', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFail', function(req, res) { - res.error('You shall not pass!'); + Parse.Cloud.beforeSave('BeforeSaveFail', function() { + throw new Error('You shall not pass!'); }); - var obj = new Parse.Object('BeforeSaveFail'); + const obj = new Parse.Object('BeforeSaveFail'); obj.set('foo', 'bar'); - obj.save().then(() => { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, () => { - done(); - }) + obj.save().then( + () => { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, + () => { + done(); + } + ); }); - it('returns an error', (done) => { - Parse.Cloud.define('cloudCodeWithError', (req, res) => { + it('returns an error', done => { + Parse.Cloud.define('cloudCodeWithError', () => { /* eslint-disable no-undef */ foo.bar(); /* eslint-enable no-undef */ - res.success('I better throw an error.'); + return 'I better throw an error.'; }); - Parse.Cloud.run('cloudCodeWithError') - .then( - () => done.fail('should not succeed'), - e => { - expect(e).toEqual(new Parse.Error(1, undefined)); - done(); - }); + Parse.Cloud.run('cloudCodeWithError').then( + () => done.fail('should not succeed'), + e => { + expect(e).toEqual(new Parse.Error(141, 'foo is not defined')); + done(); + } + ); }); it('beforeSave rejection with custom error code', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function (req, res) { - res.error(999, 'Nope'); + Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function() { + throw new Parse.Error(999, 'Nope'); }); - var obj = new Parse.Object('BeforeSaveFailWithErrorCode'); + const obj = new Parse.Object('BeforeSaveFailWithErrorCode'); obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailWithErrorCode class.'); - done(); - }, function(error) { - expect(error.code).toEqual(999); - expect(error.message).toEqual('Nope'); - done(); - }); + obj.save().then( + function() { + fail( + 'Should not have been able to save BeforeSaveFailWithErrorCode class.' + ); + done(); + }, + function(error) { + expect(error.code).toEqual(999); + expect(error.message).toEqual('Nope'); + done(); + } + ); }); it('basic beforeSave rejection via promise', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); - }); + Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function() { + const query = new Parse.Query('Yolo'); + return query.find().then( + () => { + throw 'Nope'; + }, + () => { + return Promise.response(); + } + ); }); - var obj = new Parse.Object('BeforeSaveFailWithPromise'); + const obj = new Parse.Object('BeforeSaveFailWithPromise'); obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); - done(); - }) + obj.save().then( + function() { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, + function(error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + done(); + } + ); }); it('test beforeSave changed object success', function(done) { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { req.object.set('foo', 'baz'); - res.success(); }); - var obj = new Parse.Object('BeforeSaveChanged'); + const obj = new Parse.Object('BeforeSaveChanged'); obj.set('foo', 'bar'); - obj.save().then(function() { - var query = new Parse.Query('BeforeSaveChanged'); - query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); - done(); - }, function(error) { + obj.save().then( + function() { + const query = new Parse.Query('BeforeSaveChanged'); + query.get(obj.id).then( + function(objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + }, + function(error) { fail(error); done(); - }); - }, function(error) { - fail(error); - done(); + } + ); + }); + + it("test beforeSave changed object fail doesn't change object", async function() { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { + if (req.object.has('fail')) { + return Promise.reject(new Error('something went wrong')); + } + + return Promise.resolve(); }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + await obj.save(); + obj.set('foo', 'baz').set('fail', true); + try { + await obj.save(); + } catch (e) { + await obj.fetch(); + expect(obj.get('foo')).toBe('bar'); + } }); - it('test beforeSave returns value on create and update', (done) => { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + it('test beforeSave returns value on create and update', done => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { req.object.set('foo', 'baz'); - res.success(); }); - var obj = new Parse.Object('BeforeSaveChanged'); + const obj = new Parse.Object('BeforeSaveChanged'); obj.set('foo', 'bing'); obj.save().then(() => { expect(obj.get('foo')).toEqual('baz'); @@ -151,64 +197,315 @@ describe('Cloud Code', () => { return obj.save().then(() => { expect(obj.get('foo')).toEqual('baz'); done(); - }) - }) + }); + }); + }); + + it('test beforeSave applies changes when beforeSave returns true', done => { + Parse.Cloud.beforeSave('Insurance', function(req) { + req.object.set('rate', '$49.99/Month'); + return true; + }); + + const insurance = new Parse.Object('Insurance'); + insurance.set('rate', '$5.00/Month'); + insurance.save().then(insurance => { + expect(insurance.get('rate')).toEqual('$49.99/Month'); + done(); + }); + }); + + it('test beforeSave applies changes and resolves returned promise', done => { + Parse.Cloud.beforeSave('Insurance', function(req) { + req.object.set('rate', '$49.99/Month'); + return new Parse.Query('Pet').get(req.object.get('pet').id).then(pet => { + pet.set('healthy', true); + return pet.save(); + }); + }); + + const pet = new Parse.Object('Pet'); + pet.set('healthy', false); + pet.save().then(pet => { + const insurance = new Parse.Object('Insurance'); + insurance.set('pet', pet); + insurance.set('rate', '$5.00/Month'); + insurance.save().then(insurance => { + expect(insurance.get('rate')).toEqual('$49.99/Month'); + new Parse.Query('Pet').get(insurance.get('pet').id).then(pet => { + expect(pet.get('healthy')).toEqual(true); + done(); + }); + }); + }); + }); + + it('beforeSave should be called only if user fulfills permissions', async () => { + const triggeruser = new Parse.User(); + triggeruser.setUsername('triggeruser'); + triggeruser.setPassword('triggeruser'); + await triggeruser.signUp(); + + const triggeruser2 = new Parse.User(); + triggeruser2.setUsername('triggeruser2'); + triggeruser2.setPassword('triggeruser2'); + await triggeruser2.signUp(); + + const triggeruser3 = new Parse.User(); + triggeruser3.setUsername('triggeruser3'); + triggeruser3.setPassword('triggeruser3'); + await triggeruser3.signUp(); + + const triggeruser4 = new Parse.User(); + triggeruser4.setUsername('triggeruser4'); + triggeruser4.setPassword('triggeruser4'); + await triggeruser4.signUp(); + + const triggeruser5 = new Parse.User(); + triggeruser5.setUsername('triggeruser5'); + triggeruser5.setPassword('triggeruser5'); + await triggeruser5.signUp(); + + const triggerroleacl = new Parse.ACL(); + triggerroleacl.setPublicReadAccess(true); + + const triggerrole = new Parse.Role(); + triggerrole.setName('triggerrole'); + triggerrole.setACL(triggerroleacl); + triggerrole.getUsers().add(triggeruser); + triggerrole.getUsers().add(triggeruser3); + await triggerrole.save(); + + const config = Config.get('test'); + const schema = await config.database.loadSchema(); + await schema.addClassIfNotExists( + 'triggerclass', + { + someField: { type: 'String' }, + pointerToUser: { type: 'Pointer', targetClass: '_User' }, + }, + { + find: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + create: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + get: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + update: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + addField: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + delete: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + readUserFields: ['pointerToUser'], + writeUserFields: ['pointerToUser'], + }, + {} + ); + + let called = 0; + Parse.Cloud.beforeSave('triggerclass', () => { + called++; + }); + + const triggerobject = new Parse.Object('triggerclass'); + triggerobject.set('someField', 'someValue'); + triggerobject.set('someField2', 'someValue'); + const triggerobjectacl = new Parse.ACL(); + triggerobjectacl.setPublicReadAccess(false); + triggerobjectacl.setPublicWriteAccess(false); + triggerobjectacl.setRoleReadAccess(triggerrole, true); + triggerobjectacl.setRoleWriteAccess(triggerrole, true); + triggerobjectacl.setReadAccess(triggeruser.id, true); + triggerobjectacl.setWriteAccess(triggeruser.id, true); + triggerobjectacl.setReadAccess(triggeruser2.id, true); + triggerobjectacl.setWriteAccess(triggeruser2.id, true); + triggerobject.setACL(triggerobjectacl); + + await triggerobject.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(1); + await triggerobject.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(2); + await triggerobject.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(3); + await triggerobject.save(undefined, { + sessionToken: triggeruser3.getSessionToken(), + }); + expect(called).toBe(4); + + const triggerobject2 = new Parse.Object('triggerclass'); + triggerobject2.set('someField', 'someValue'); + triggerobject2.set('someField22', 'someValue'); + const triggerobjectacl2 = new Parse.ACL(); + triggerobjectacl2.setPublicReadAccess(false); + triggerobjectacl2.setPublicWriteAccess(false); + triggerobjectacl2.setReadAccess(triggeruser.id, true); + triggerobjectacl2.setWriteAccess(triggeruser.id, true); + triggerobjectacl2.setReadAccess(triggeruser2.id, true); + triggerobjectacl2.setWriteAccess(triggeruser2.id, true); + triggerobjectacl2.setReadAccess(triggeruser5.id, true); + triggerobjectacl2.setWriteAccess(triggeruser5.id, true); + triggerobject2.setACL(triggerobjectacl2); + + await triggerobject2.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(5); + await triggerobject2.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(6); + await triggerobject2.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(7); + + let catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser3.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(101); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser4.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(101); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser5.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(101); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + const triggerobject3 = new Parse.Object('triggerclass'); + triggerobject3.set('someField', 'someValue'); + triggerobject3.set('someField33', 'someValue'); + + catched = false; + try { + await triggerobject3.save(undefined, { + sessionToken: triggeruser4.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(119); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject3.save(undefined, { + sessionToken: triggeruser5.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(119); + } + expect(catched).toBe(true); + expect(called).toBe(7); }); it('test afterSave ran and created an object', function(done) { Parse.Cloud.afterSave('AfterSaveTest', function(req) { - var obj = new Parse.Object('AfterSaveProof'); + const obj = new Parse.Object('AfterSaveProof'); obj.set('proof', req.object.id); - obj.save(); + obj.save().then(test); }); - var obj = new Parse.Object('AfterSaveTest'); + const obj = new Parse.Object('AfterSaveTest'); obj.save(); - setTimeout(function() { - var query = new Parse.Query('AfterSaveProof'); + function test() { + const query = new Parse.Query('AfterSaveProof'); query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - done(); - }, function(error) { - fail(error); - done(); - }); - }, 500); + query.find().then( + function(results) { + expect(results.length).toEqual(1); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + } }); it('test afterSave ran on created object and returned a promise', function(done) { Parse.Cloud.afterSave('AfterSaveTest2', function(req) { const obj = req.object; - if(!obj.existed()) - { - const promise = new Parse.Promise(); - setTimeout(function(){ - obj.set('proof', obj.id); - obj.save().then(function(){ - promise.resolve(); - }); - }, 1000); - - return promise; + if (!obj.existed()) { + return new Promise(resolve => { + setTimeout(function() { + obj.set('proof', obj.id); + obj.save().then(function() { + resolve(); + }); + }, 1000); + }); } }); const obj = new Parse.Object('AfterSaveTest2'); - obj.save().then(function(){ + obj.save().then(function() { const query = new Parse.Query('AfterSaveTest2'); query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - const savedObject = results[0]; - expect(savedObject.get('proof')).toEqual(obj.id); - done(); - }, - function(error) { - fail(error); - done(); - }); + query.find().then( + function(results) { + expect(results.length).toEqual(1); + const savedObject = results[0]; + expect(savedObject.get('proof')).toEqual(obj.id); + done(); + }, + function(error) { + fail(error); + done(); + } + ); }); }); @@ -216,78 +513,77 @@ describe('Cloud Code', () => { xit('test afterSave ignoring promise, object not found', function(done) { Parse.Cloud.afterSave('AfterSaveTest2', function(req) { const obj = req.object; - if(!obj.existed()) - { - const promise = new Parse.Promise(); - setTimeout(function(){ - obj.set('proof', obj.id); - obj.save().then(function(){ - promise.resolve(); - }); - }, 1000); - - return promise; + if (!obj.existed()) { + return new Promise(resolve => { + setTimeout(function() { + obj.set('proof', obj.id); + obj.save().then(function() { + resolve(); + }); + }, 1000); + }); } }); const obj = new Parse.Object('AfterSaveTest2'); - obj.save().then(function(){ + obj.save().then(function() { done(); - }) + }); const query = new Parse.Query('AfterSaveTest2'); query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(0); - }, - function(error) { - fail(error); - }); + query.find().then( + function(results) { + expect(results.length).toEqual(0); + }, + function(error) { + fail(error); + } + ); }); it('test afterSave rejecting promise', function(done) { Parse.Cloud.afterSave('AfterSaveTest2', function() { - const promise = new Parse.Promise(); - setTimeout(function(){ - promise.reject("THIS SHOULD BE IGNORED"); - }, 1000); - - return promise; + return new Promise((resolve, reject) => { + setTimeout(function() { + reject('THIS SHOULD BE IGNORED'); + }, 1000); + }); }); const obj = new Parse.Object('AfterSaveTest2'); - obj.save().then(function(){ - done(); - }, function(error){ - fail(error); - done(); - }) + obj.save().then( + function() { + done(); + }, + function(error) { + fail(error); + done(); + } + ); }); it('test afterDelete returning promise, object is deleted when destroy resolves', function(done) { Parse.Cloud.afterDelete('AfterDeleteTest2', function(req) { - const promise = new Parse.Promise(); - - setTimeout(function(){ - const obj = new Parse.Object('AfterDeleteTestProof'); - obj.set('proof', req.object.id); - obj.save().then(function(){ - promise.resolve(); - }); - - }, 1000); - - return promise; + return new Promise(resolve => { + setTimeout(function() { + const obj = new Parse.Object('AfterDeleteTestProof'); + obj.set('proof', req.object.id); + obj.save().then(function() { + resolve(); + }); + }, 1000); + }); }); const errorHandler = function(error) { fail(error); done(); - } + }; const obj = new Parse.Object('AfterDeleteTest2'); - obj.save().then(function(){ - obj.destroy().then(function(){ + obj.save().then(function() { + obj.destroy().then(function() { const query = new Parse.Query('AfterDeleteTestProof'); query.equalTo('proof', obj.id); query.find().then(function(results) { @@ -296,36 +592,33 @@ describe('Cloud Code', () => { expect(deletedObject.get('proof')).toEqual(obj.id); done(); }, errorHandler); - }, errorHandler) + }, errorHandler); }, errorHandler); }); it('test afterDelete ignoring promise, object is not yet deleted', function(done) { Parse.Cloud.afterDelete('AfterDeleteTest2', function(req) { - const promise = new Parse.Promise(); - - setTimeout(function(){ - const obj = new Parse.Object('AfterDeleteTestProof'); - obj.set('proof', req.object.id); - obj.save().then(function(){ - promise.resolve(); - }); - - }, 1000); - - return promise; + return new Promise(resolve => { + setTimeout(function() { + const obj = new Parse.Object('AfterDeleteTestProof'); + obj.set('proof', req.object.id); + obj.save().then(function() { + resolve(); + }); + }, 1000); + }); }); const errorHandler = function(error) { fail(error); done(); - } + }; const obj = new Parse.Object('AfterDeleteTest2'); - obj.save().then(function(){ - obj.destroy().then(function(){ + obj.save().then(function() { + obj.destroy().then(function() { done(); - }) + }); const query = new Parse.Query('AfterDeleteTestProof'); query.equalTo('proof', obj.id); @@ -336,112 +629,133 @@ describe('Cloud Code', () => { }); it('test beforeSave happens on update', function(done) { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { req.object.set('foo', 'baz'); - res.success(); }); - var obj = new Parse.Object('BeforeSaveChanged'); + const obj = new Parse.Object('BeforeSaveChanged'); obj.set('foo', 'bar'); - obj.save().then(function() { - obj.set('foo', 'bar'); - return obj.save(); - }).then(function() { - var query = new Parse.Query('BeforeSaveChanged'); - return query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); - done(); - }); - }, function(error) { - fail(error); - done(); - }); + obj + .save() + .then(function() { + obj.set('foo', 'bar'); + return obj.save(); + }) + .then( + function() { + const query = new Parse.Query('BeforeSaveChanged'); + return query.get(obj.id).then(function(objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }); + }, + function(error) { + fail(error); + done(); + } + ); }); it('test beforeDelete failure', function(done) { - Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) { - res.error('Nope'); + Parse.Cloud.beforeDelete('BeforeDeleteFail', function() { + throw 'Nope'; }); - var obj = new Parse.Object('BeforeDeleteFail'); - var id; + const obj = new Parse.Object('BeforeDeleteFail'); + let id; obj.set('foo', 'bar'); - obj.save().then(() => { - id = obj.id; - return obj.destroy(); - }).then(() => { - fail('obj.destroy() should have failed, but it succeeded'); - done(); - }, (error) => { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); - - var objAgain = new Parse.Object('BeforeDeleteFail', {objectId: id}); - return objAgain.fetch(); - }).then((objAgain) => { - if (objAgain) { - expect(objAgain.get('foo')).toEqual('bar'); - } else { - fail("unable to fetch the object ", id); - } - done(); - }, (error) => { - // We should have been able to fetch the object again - fail(error); - }); + obj + .save() + .then(() => { + id = obj.id; + return obj.destroy(); + }) + .then( + () => { + fail('obj.destroy() should have failed, but it succeeded'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + + const objAgain = new Parse.Object('BeforeDeleteFail', { + objectId: id, + }); + return objAgain.fetch(); + } + ) + .then( + objAgain => { + if (objAgain) { + expect(objAgain.get('foo')).toEqual('bar'); + } else { + fail('unable to fetch the object ', id); + } + done(); + }, + error => { + // We should have been able to fetch the object again + fail(error); + } + ); }); it('basic beforeDelete rejection via promise', function(done) { - Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); + Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function() { + const query = new Parse.Query('Yolo'); + return query.find().then(() => { + throw 'Nope'; }); }); - var obj = new Parse.Object('BeforeDeleteFailWithPromise'); + const obj = new Parse.Object('BeforeDeleteFailWithPromise'); obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); + obj.save().then( + function() { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, + function(error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); - done(); - }) + done(); + } + ); }); it('test afterDelete ran and created an object', function(done) { Parse.Cloud.afterDelete('AfterDeleteTest', function(req) { - var obj = new Parse.Object('AfterDeleteProof'); + const obj = new Parse.Object('AfterDeleteProof'); obj.set('proof', req.object.id); - obj.save(); + obj.save().then(test); }); - var obj = new Parse.Object('AfterDeleteTest'); + const obj = new Parse.Object('AfterDeleteTest'); obj.save().then(function() { obj.destroy(); }); - setTimeout(function() { - var query = new Parse.Query('AfterDeleteProof'); + function test() { + const query = new Parse.Query('AfterDeleteProof'); query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - done(); - }, function(error) { - fail(error); - done(); - }); - }, 500); + query.find().then( + function(results) { + expect(results.length).toEqual(1); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + } }); it('test cloud function return types', function(done) { - Parse.Cloud.define('foo', function(req, res) { - res.success({ + Parse.Cloud.define('foo', function() { + return { object: { __type: 'Object', className: 'Foo', @@ -451,29 +765,31 @@ describe('Cloud Code', () => { __type: 'Object', className: 'Bar', objectId: '234', - x: 3 - } + x: 3, + }, }, - array: [{ - __type: 'Object', - className: 'Bar', - objectId: '345', - x: 2 - }], - a: 2 - }); + array: [ + { + __type: 'Object', + className: 'Bar', + objectId: '345', + x: 2, + }, + ], + a: 2, + }; }); - Parse.Cloud.run('foo').then((result) => { + Parse.Cloud.run('foo').then(result => { expect(result.object instanceof Parse.Object).toBeTruthy(); if (!result.object) { - fail("Unable to run foo"); + fail('Unable to run foo'); done(); return; } expect(result.object.className).toEqual('Foo'); expect(result.object.get('x')).toEqual(2); - var bar = result.object.get('relation'); + const bar = result.object.get('relation'); expect(bar instanceof Parse.Object).toBeTruthy(); expect(bar.className).toEqual('Bar'); expect(bar.get('x')).toEqual(3); @@ -485,73 +801,87 @@ describe('Cloud Code', () => { }); it('test cloud function request params types', function(done) { - Parse.Cloud.define('params', function(req, res) { + Parse.Cloud.define('params', function(req) { expect(req.params.date instanceof Date).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); expect(req.params.dateList[0] instanceof Date).toBe(true); expect(req.params.dateList[0].getTime()).toBe(1463907600000); expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); - expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); - expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); + expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe( + true + ); + expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe( + 1463907600000 + ); + expect( + req.params.complexStructure.deepDate2[0].date instanceof Date + ).toBe(true); + expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe( + 1463907600000 + ); // Regression for #2294 expect(req.params.file instanceof Parse.File).toBe(true); expect(req.params.file.url()).toEqual('https://some.url'); // Regression for #2204 expect(req.params.array).toEqual(['a', 'b', 'c']); expect(Array.isArray(req.params.array)).toBe(true); - expect(req.params.arrayOfArray).toEqual([['a', 'b', 'c'], ['d', 'e','f']]); + expect(req.params.arrayOfArray).toEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ]); expect(Array.isArray(req.params.arrayOfArray)).toBe(true); expect(Array.isArray(req.params.arrayOfArray[0])).toBe(true); expect(Array.isArray(req.params.arrayOfArray[1])).toBe(true); - return res.success({}); + return {}; }); const params = { - 'date': { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', }, - 'dateList': [ + dateList: [ { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, ], - 'lol': 'hello', - 'complexStructure': { - 'date': [ + lol: 'hello', + complexStructure: { + date: [ { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, ], - 'deepDate': { - 'date': [ + deepDate: { + date: [ { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - ] + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], }, - 'deepDate2': [ + deepDate2: [ { - 'date': { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - } - ] + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + }, + ], }, - 'file': Parse.File.fromJSON({ + file: Parse.File.fromJSON({ __type: 'File', name: 'name', - url: 'https://some.url' + url: 'https://some.url', }), - 'array': ['a', 'b', 'c'], - 'arrayOfArray': [['a', 'b', 'c'], ['d', 'e', 'f']] + array: ['a', 'b', 'c'], + arrayOfArray: [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ], }; Parse.Cloud.run('params', params).then(() => { done(); @@ -559,15 +889,15 @@ describe('Cloud Code', () => { }); it('test cloud function should echo keys', function(done) { - Parse.Cloud.define('echoKeys', function(req, res){ - return res.success({ + Parse.Cloud.define('echoKeys', function() { + return { applicationId: Parse.applicationId, masterKey: Parse.masterKey, - javascriptKey: Parse.javascriptKey - }) + javascriptKey: Parse.javascriptKey, + }; }); - Parse.Cloud.run('echoKeys').then((result) => { + Parse.Cloud.run('echoKeys').then(result => { expect(result.applicationId).toEqual(Parse.applicationId); expect(result.masterKey).toEqual(Parse.masterKey); expect(result.javascriptKey).toEqual(Parse.javascriptKey); @@ -576,19 +906,18 @@ describe('Cloud Code', () => { }); it('should properly create an object in before save', done => { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { req.object.set('foo', 'baz'); - res.success(); }); - Parse.Cloud.define('createBeforeSaveChangedObject', function(req, res){ - var obj = new Parse.Object('BeforeSaveChanged'); - obj.save().then(() => { - res.success(obj); - }) - }) + Parse.Cloud.define('createBeforeSaveChangedObject', function() { + const obj = new Parse.Object('BeforeSaveChanged'); + return obj.save().then(() => { + return obj; + }); + }); - Parse.Cloud.run('createBeforeSaveChangedObject').then((res) => { + Parse.Cloud.run('createBeforeSaveChangedObject').then(res => { expect(res.get('foo')).toEqual('baz'); done(); }); @@ -597,8 +926,8 @@ describe('Cloud Code', () => { it('dirtyKeys are set on update', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - var object = req.object; + Parse.Cloud.beforeSave('GameScore', req => { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); if (triggerTime == 0) { @@ -610,68 +939,82 @@ describe('Cloud Code', () => { expect(object.dirty('foo')).toBeTruthy(); expect(object.get('foo')).toEqual('baz'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - fail(error); - done(); - }); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function(error) { + fail(error); + done(); + } + ); }); it('test beforeSave unchanged success', function(done) { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) { - res.success(); + Parse.Cloud.beforeSave('BeforeSaveUnchanged', function() { + return; }); - var obj = new Parse.Object('BeforeSaveUnchanged'); + const obj = new Parse.Object('BeforeSaveUnchanged'); obj.set('foo', 'bar'); - obj.save().then(function() { - done(); - }, function(error) { - fail(error); - done(); - }); + obj.save().then( + function() { + done(); + }, + function(error) { + fail(error); + done(); + } + ); }); it('test beforeDelete success', function(done) { - Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) { - res.success(); + Parse.Cloud.beforeDelete('BeforeDeleteTest', function() { + return; }); - var obj = new Parse.Object('BeforeDeleteTest'); + const obj = new Parse.Object('BeforeDeleteTest'); obj.set('foo', 'bar'); - obj.save().then(function() { - return obj.destroy(); - }).then(function() { - var objAgain = new Parse.Object('BeforeDeleteTest', obj.id); - return objAgain.fetch().then(fail, done); - }, function(error) { - fail(error); - done(); - }); + obj + .save() + .then(function() { + return obj.destroy(); + }) + .then( + function() { + const objAgain = new Parse.Object('BeforeDeleteTest', obj.id); + return objAgain.fetch().then(fail, () => done()); + }, + function(error) { + fail(error); + done(); + } + ); }); - it('test save triggers get user', function(done) { - Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) { + it('test save triggers get user', async done => { + Parse.Cloud.beforeSave('SaveTriggerUser', function(req) { if (req.user && req.user.id) { - res.success(); + return; } else { - res.error('No user present on request object for beforeSave.'); + throw new Error('No user present on request object for beforeSave.'); } }); @@ -681,82 +1024,96 @@ describe('Cloud Code', () => { } }); - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp(null, { - success: function() { - var obj = new Parse.Object('SaveTriggerUser'); - obj.save().then(function() { - done(); - }, function(error) { - fail(error); - done(); - }); + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + await user.signUp(); + const obj = new Parse.Object('SaveTriggerUser'); + obj.save().then( + function() { + done(); + }, + function(error) { + fail(error); + done(); } - }); + ); }); - it('beforeSave change propagates through the save response', (done) => { - Parse.Cloud.beforeSave('ChangingObject', function(request, response) { + it('beforeSave change propagates through the save response', done => { + Parse.Cloud.beforeSave('ChangingObject', function(request) { request.object.set('foo', 'baz'); - response.success(); }); const obj = new Parse.Object('ChangingObject'); - obj.save({ foo: 'bar' }).then((objAgain) => { - expect(objAgain.get('foo')).toEqual('baz'); - done(); - }, () => { - fail('Should not have failed to save.'); - done(); - }); + obj.save({ foo: 'bar' }).then( + objAgain => { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, + () => { + fail('Should not have failed to save.'); + done(); + } + ); }); - it('beforeSave change propagates through the afterSave #1931', (done) => { - Parse.Cloud.beforeSave('ChangingObject', function(request, response) { + it('beforeSave change propagates through the afterSave #1931', done => { + Parse.Cloud.beforeSave('ChangingObject', function(request) { request.object.unset('file'); request.object.unset('date'); - response.success(); }); Parse.Cloud.afterSave('ChangingObject', function(request) { - expect(request.object.has("file")).toBe(false); - expect(request.object.has("date")).toBe(false); + expect(request.object.has('file')).toBe(false); + expect(request.object.has('date')).toBe(false); expect(request.object.get('file')).toBeUndefined(); return Promise.resolve(); }); - const file = new Parse.File("yolo.txt", [1,2,3], "text/plain"); - file.save().then(() => { - const obj = new Parse.Object('ChangingObject'); - return obj.save({ file, date: new Date() }) - }).then(() => { - done(); - }, () => { - fail(); - done(); - }) + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(() => { + const obj = new Parse.Object('ChangingObject'); + return obj.save({ file, date: new Date() }); + }) + .then( + () => { + done(); + }, + () => { + fail(); + done(); + } + ); }); - it('test cloud function parameter validation success', (done) => { + it('test cloud function parameter validation success', done => { // Register a function with validation - Parse.Cloud.define('functionWithParameterValidation', (req, res) => { - res.success('works'); - }, (request) => { - return request.params.success === 100; - }); + Parse.Cloud.define( + 'functionWithParameterValidation', + () => { + return 'works'; + }, + request => { + return request.params.success === 100; + } + ); - Parse.Cloud.run('functionWithParameterValidation', {"success":100}).then(() => { - done(); - }, () => { - fail('Validation should not have failed.'); - done(); - }); + Parse.Cloud.run('functionWithParameterValidation', { success: 100 }).then( + () => { + done(); + }, + () => { + fail('Validation should not have failed.'); + done(); + } + ); }); it('doesnt receive stale user in cloud code functions after user has been updated with master key (regression test for #1836)', done => { - Parse.Cloud.define('testQuery', function(request, response) { - response.success(request.user.get('data')); + Parse.Cloud.define('testQuery', function(request) { + return request.user.get('data'); }); Parse.User.signUp('user', 'pass') @@ -768,7 +1125,7 @@ describe('Cloud Code', () => { .then(result => { expect(result).toEqual('AAA'); Parse.User.current().set('data', 'BBB'); - return Parse.User.current().save(null, {useMasterKey: true}); + return Parse.User.current().save(null, { useMasterKey: true }); }) .then(() => Parse.Cloud.run('testQuery')) .then(result => { @@ -784,8 +1141,8 @@ describe('Cloud Code', () => { const cacheAdapter = new InMemoryCacheAdapter({ ttl: 100000000 }); reconfigureServer({ cacheAdapter }) .then(() => { - Parse.Cloud.define('checkStaleUser', (request, response) => { - response.success(request.user.get('data')); + Parse.Cloud.define('checkStaleUser', request => { + return request.user.get('data'); }); user = new Parse.User(); @@ -796,171 +1153,177 @@ describe('Cloud Code', () => { }) .then(user => { session1 = user.getSessionToken(); - return rp({ - uri: 'http://localhost:8378/1/login?username=test&password=moon-y', - json: true, + return request({ + url: 'http://localhost:8378/1/login?username=test&password=moon-y', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', }, - }) + }); }) - .then(body => { - session2 = body.sessionToken; - + .then(response => { + session2 = response.data.sessionToken; //Ensure both session tokens are in the cache - return Parse.Cloud.run('checkStaleUser') + return Parse.Cloud.run('checkStaleUser', { sessionToken: session2 }); }) - .then(() => rp({ - method: 'POST', - uri: 'http://localhost:8378/1/functions/checkStaleUser', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': session2, - } - })) - .then(() => Parse.Promise.all([cacheAdapter.get('test:user:' + session1), cacheAdapter.get('test:user:' + session2)])) + .then(() => + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/checkStaleUser', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + }, + }) + ) + .then(() => + Promise.all([ + cacheAdapter.get('test:user:' + session1), + cacheAdapter.get('test:user:' + session2), + ]) + ) .then(cachedVals => { expect(cachedVals[0].objectId).toEqual(user.id); expect(cachedVals[1].objectId).toEqual(user.id); //Change with session 1 and then read with session 2. user.set('data', 'second data'); - return user.save() + return user.save(); }) - .then(() => rp({ - method: 'POST', - uri: 'http://localhost:8378/1/functions/checkStaleUser', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': session2, - } - })) - .then(body => { - expect(body.result).toEqual('second data'); + .then(() => + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/checkStaleUser', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + }, + }) + ) + .then(response => { + expect(response.data.result).toEqual('second data'); done(); }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); + .catch(done.fail); }); it('trivial beforeSave should not affect fetched pointers (regression test for #1238)', done => { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', (req, res) => { - res.success(); - }); + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {}); - var TestObject = Parse.Object.extend("TestObject"); - var NoBeforeSaveObject = Parse.Object.extend("NoBeforeSave"); - var BeforeSaveObject = Parse.Object.extend("BeforeSaveUnchanged"); + const TestObject = Parse.Object.extend('TestObject'); + const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveUnchanged'); - var aTestObject = new TestObject(); - aTestObject.set("foo", "bar"); - aTestObject.save() + const aTestObject = new TestObject(); + aTestObject.set('foo', 'bar'); + aTestObject + .save() .then(aTestObject => { - var aNoBeforeSaveObj = new NoBeforeSaveObject(); - aNoBeforeSaveObj.set("aTestObject", aTestObject); - expect(aNoBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); + const aNoBeforeSaveObj = new NoBeforeSaveObject(); + aNoBeforeSaveObj.set('aTestObject', aTestObject); + expect(aNoBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); return aNoBeforeSaveObj.save(); }) .then(aNoBeforeSaveObj => { - expect(aNoBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); + expect(aNoBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); - var aBeforeSaveObj = new BeforeSaveObject(); - aBeforeSaveObj.set("aTestObject", aTestObject); - expect(aBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); + const aBeforeSaveObj = new BeforeSaveObject(); + aBeforeSaveObj.set('aTestObject', aTestObject); + expect(aBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); return aBeforeSaveObj.save(); }) .then(aBeforeSaveObj => { - expect(aBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); + expect(aBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); done(); }); }); it('beforeSave should not affect fetched pointers', done => { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', (req, res) => { - res.success(); - }); + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => {}); - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req) { req.object.set('foo', 'baz'); - res.success(); }); - var TestObject = Parse.Object.extend("TestObject"); - var BeforeSaveUnchangedObject = Parse.Object.extend("BeforeSaveUnchanged"); - var BeforeSaveChangedObject = Parse.Object.extend("BeforeSaveChanged"); + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveUnchangedObject = Parse.Object.extend( + 'BeforeSaveUnchanged' + ); + const BeforeSaveChangedObject = Parse.Object.extend('BeforeSaveChanged'); - var aTestObject = new TestObject(); - aTestObject.set("foo", "bar"); - aTestObject.save() + const aTestObject = new TestObject(); + aTestObject.set('foo', 'bar'); + aTestObject + .save() .then(aTestObject => { - var aBeforeSaveUnchangedObject = new BeforeSaveUnchangedObject(); - aBeforeSaveUnchangedObject.set("aTestObject", aTestObject); - expect(aBeforeSaveUnchangedObject.get("aTestObject").get("foo")).toEqual("bar"); + const aBeforeSaveUnchangedObject = new BeforeSaveUnchangedObject(); + aBeforeSaveUnchangedObject.set('aTestObject', aTestObject); + expect( + aBeforeSaveUnchangedObject.get('aTestObject').get('foo') + ).toEqual('bar'); return aBeforeSaveUnchangedObject.save(); }) .then(aBeforeSaveUnchangedObject => { - expect(aBeforeSaveUnchangedObject.get("aTestObject").get("foo")).toEqual("bar"); - - var aBeforeSaveChangedObject = new BeforeSaveChangedObject(); - aBeforeSaveChangedObject.set("aTestObject", aTestObject); - expect(aBeforeSaveChangedObject.get("aTestObject").get("foo")).toEqual("bar"); + expect( + aBeforeSaveUnchangedObject.get('aTestObject').get('foo') + ).toEqual('bar'); + + const aBeforeSaveChangedObject = new BeforeSaveChangedObject(); + aBeforeSaveChangedObject.set('aTestObject', aTestObject); + expect(aBeforeSaveChangedObject.get('aTestObject').get('foo')).toEqual( + 'bar' + ); return aBeforeSaveChangedObject.save(); }) .then(aBeforeSaveChangedObject => { - expect(aBeforeSaveChangedObject.get("aTestObject").get("foo")).toEqual("bar"); - expect(aBeforeSaveChangedObject.get("foo")).toEqual("baz"); + expect(aBeforeSaveChangedObject.get('aTestObject').get('foo')).toEqual( + 'bar' + ); + expect(aBeforeSaveChangedObject.get('foo')).toEqual('baz'); done(); }); }); it('should fully delete objects when using `unset` with beforeSave (regression test for #1840)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + const TestObject = Parse.Object.extend('TestObject'); + const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; object.set('before', 'save'); - res.success(); }); Parse.Cloud.define('removeme', (req, res) => { - var testObject = new TestObject(); - testObject.save() + const testObject = new TestObject(); + return testObject + .save() .then(testObject => { - var object = new NoBeforeSaveObject({remove: testObject}); + const object = new NoBeforeSaveObject({ remove: testObject }); return object.save(); }) .then(object => { object.unset('remove'); return object.save(); }) - .then(object => { - res.success(object); - }).catch(res.error); + .catch(res.error); }); Parse.Cloud.define('removeme2', (req, res) => { - var testObject = new TestObject(); - testObject.save() + const testObject = new TestObject(); + return testObject + .save() .then(testObject => { - var object = new BeforeSaveObject({remove: testObject}); + const object = new BeforeSaveObject({ remove: testObject }); return object.save(); }) .then(object => { object.unset('remove'); return object.save(); }) - .then(object => { - res.success(object); - }).catch(res.error); + .catch(res.error); }); Parse.Cloud.run('removeme') @@ -973,7 +1336,8 @@ describe('Cloud Code', () => { expect(aBeforeSaveObj.get('before')).toEqual('save'); expect(aBeforeSaveObj.get('remove')).toEqual(undefined); done(); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); }); @@ -983,106 +1347,112 @@ describe('Cloud Code', () => { TODO: fix for Postgres trying to delete a field that doesn't exists doesn't play nice */ - it_exclude_dbs(['postgres'])('should fully delete objects when using `unset` with beforeSave (regression test for #1840)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); - - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; - object.set('before', 'save'); - object.unset('remove'); - res.success(); - }); - - let object; - const testObject = new TestObject({key: 'value'}); - testObject.save().then(() => { - object = new BeforeSaveObject(); - return object.save().then(() => { - object.set({remove:testObject}) - return object.save(); + it_exclude_dbs(['postgres'])( + 'should fully delete objects when using `unset` and `set` with beforeSave (regression test for #1840)', + done => { + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; + object.set('before', 'save'); + object.unset('remove'); }); - }).then((objectAgain) => { - expect(objectAgain.get('remove')).toBeUndefined(); - expect(object.get('remove')).toBeUndefined(); - done(); - }).fail((err) => { - jfail(err); - done(); - }); - }); + + let object; + const testObject = new TestObject({ key: 'value' }); + testObject + .save() + .then(() => { + object = new BeforeSaveObject(); + return object.save().then(() => { + object.set({ remove: testObject }); + return object.save(); + }); + }) + .then(objectAgain => { + expect(objectAgain.get('remove')).toBeUndefined(); + expect(object.get('remove')).toBeUndefined(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + } + ); it('should not include relation op (regression test for #1606)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); let testObj; - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; object.set('before', 'save'); testObj = new TestObject(); - testObj.save().then(() => { + return testObj.save().then(() => { object.relation('testsRelation').add(testObj); - res.success(); - }, res.error); + }); }); const object = new BeforeSaveObject(); - object.save().then((objectAgain) => { - // Originally it would throw as it would be a non-relation - expect(() => { objectAgain.relation('testsRelation') }).not.toThrow(); - done(); - }).fail((err) => { - jfail(err); - done(); - }) + object + .save() + .then(objectAgain => { + // Originally it would throw as it would be a non-relation + expect(() => { + objectAgain.relation('testsRelation'); + }).not.toThrow(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); /** * Checks that incrementing a value to a zero in a beforeSave hook * does not result in that key being omitted from the response. */ - it('before save increment does not return undefined', (done) => { - Parse.Cloud.define("cloudIncrementClassFunction", function (req, res) { - const CloudIncrementClass = Parse.Object.extend("CloudIncrementClass"); + it('before save increment does not return undefined', done => { + Parse.Cloud.define('cloudIncrementClassFunction', function(req) { + const CloudIncrementClass = Parse.Object.extend('CloudIncrementClass'); const obj = new CloudIncrementClass(); obj.id = req.params.objectId; - obj.save().then( - function (savedObj) { - res.success(savedObj); - }); + return obj.save(); }); - Parse.Cloud.beforeSave("CloudIncrementClass", function (req, res) { + Parse.Cloud.beforeSave('CloudIncrementClass', function(req) { const obj = req.object; - if(!req.master) { + if (!req.master) { obj.increment('points', -10); obj.increment('num', -9); } - res.success(); }); const CloudIncrementClass = Parse.Object.extend('CloudIncrementClass'); const obj = new CloudIncrementClass(); obj.set('points', 10); obj.set('num', 10); - obj.save(null, {useMasterKey: true}) - .then(function() { - Parse.Cloud.run('cloudIncrementClassFunction', { objectId: obj.id }) - .then(function(savedObj) { - expect(savedObj.get('num')).toEqual(1); - expect(savedObj.get('points')).toEqual(0); - done(); - }); - }); + obj.save(null, { useMasterKey: true }).then(function() { + Parse.Cloud.run('cloudIncrementClassFunction', { objectId: obj.id }).then( + function(savedObj) { + expect(savedObj.get('num')).toEqual(1); + expect(savedObj.get('points')).toEqual(0); + done(); + } + ); + }); }); /** * Verifies that an afterSave hook throwing an exception * will not prevent a successful save response from being returned */ - it('should succeed on afterSave exception', (done) => { - Parse.Cloud.afterSave("AfterSaveTestClass", function () { - throw "Exception"; + it('should succeed on afterSave exception', done => { + Parse.Cloud.afterSave('AfterSaveTestClass', function() { + throw 'Exception'; }); const AfterSaveTestClass = Parse.Object.extend('AfterSaveTestClass'); const obj = new AfterSaveTestClass(); @@ -1090,173 +1460,197 @@ describe('Cloud Code', () => { }); describe('cloud jobs', () => { - it('should define a job', (done) => { + it('should define a job', done => { expect(() => { - Parse.Cloud.job('myJob', (req, res) => { - res.success(); - }); + Parse.Cloud.job('myJob', () => {}); }).not.toThrow(); - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/jobs/myJob', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': Parse.masterKey, }, - }).then(() => { - done(); - }, (err) => { - fail(err); - done(); - }); + }).then( + () => { + done(); + }, + err => { + fail(err); + done(); + } + ); }); - it('should not run without master key', (done) => { + it('should not run without master key', done => { expect(() => { - Parse.Cloud.job('myJob', (req, res) => { - res.success(); - }); + Parse.Cloud.job('myJob', () => {}); }).not.toThrow(); - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/jobs/myJob', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', }, - }).then(() => { - fail('Expected to be unauthorized'); - done(); - }, (err) => { - expect(err.statusCode).toBe(403); - done(); - }); + }).then( + () => { + fail('Expected to be unauthorized'); + done(); + }, + err => { + expect(err.status).toBe(403); + done(); + } + ); }); - it('should run with master key', (done) => { + it('should run with master key', done => { expect(() => { Parse.Cloud.job('myJob', (req, res) => { expect(req.functionName).toBeUndefined(); expect(req.jobName).toBe('myJob'); expect(typeof req.jobId).toBe('string'); - expect(typeof res.success).toBe('function'); - expect(typeof res.error).toBe('function'); - expect(typeof res.message).toBe('function'); - res.success(); + expect(typeof req.message).toBe('function'); + expect(typeof res).toBe('undefined'); done(); }); }).not.toThrow(); - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/jobs/myJob', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': Parse.masterKey, }, - }).then(() => { - }, (err) => { - fail(err); - done(); - }); + }).then( + () => {}, + err => { + fail(err); + done(); + } + ); }); - it('should run with master key basic auth', (done) => { + it('should run with master key basic auth', done => { expect(() => { Parse.Cloud.job('myJob', (req, res) => { expect(req.functionName).toBeUndefined(); expect(req.jobName).toBe('myJob'); expect(typeof req.jobId).toBe('string'); - expect(typeof res.success).toBe('function'); - expect(typeof res.error).toBe('function'); - expect(typeof res.message).toBe('function'); - res.success(); + expect(typeof req.message).toBe('function'); + expect(typeof res).toBe('undefined'); done(); }); }).not.toThrow(); - rp.post({ + request({ + method: 'POST', url: `http://${Parse.applicationId}:${Parse.masterKey}@localhost:8378/1/jobs/myJob`, - }).then(() => { - }, (err) => { - fail(err); - done(); - }); + }).then( + () => {}, + err => { + fail(err); + done(); + } + ); }); - it('should set the message / success on the job', (done) => { - Parse.Cloud.job('myJob', (req, res) => { - res.message('hello'); - res.message().then(() => { - return getJobStatus(req.jobId); - }).then((jobStatus) => { - expect(jobStatus.get('message')).toEqual('hello'); - expect(jobStatus.get('status')).toEqual('running'); - return res.success().then(() => { + it('should set the message / success on the job', done => { + Parse.Cloud.job('myJob', req => { + req.message('hello'); + const promise = req + .message() + .then(() => { return getJobStatus(req.jobId); + }) + .then(jobStatus => { + expect(jobStatus.get('message')).toEqual('hello'); + expect(jobStatus.get('status')).toEqual('running'); }); - }).then((jobStatus) => { - expect(jobStatus.get('message')).toEqual('hello'); - expect(jobStatus.get('status')).toEqual('succeeded'); - done(); - }).catch(err => { - console.error(err); - jfail(err); - done(); - }); + promise + .then(() => { + return getJobStatus(req.jobId); + }) + .then(jobStatus => { + expect(jobStatus.get('message')).toEqual('hello'); + expect(jobStatus.get('status')).toEqual('succeeded'); + done(); + }) + .catch(err => { + console.error(err); + jfail(err); + done(); + }); + return promise; }); - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/jobs/myJob', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': Parse.masterKey, }, - }).then(() => { - }, (err) => { - fail(err); - done(); - }); + }).then( + () => {}, + err => { + fail(err); + done(); + } + ); }); - it('should set the failure on the job', (done) => { - Parse.Cloud.job('myJob', (req, res) => { - res.error('Something went wrong').then(() => { - return getJobStatus(req.jobId); - }).then((jobStatus) => { - expect(jobStatus.get('message')).toEqual('Something went wrong'); - expect(jobStatus.get('status')).toEqual('failed'); - done(); - }).catch(err => { - jfail(err); - done(); - }); + it('should set the failure on the job', done => { + Parse.Cloud.job('myJob', req => { + const promise = Promise.reject('Something went wrong'); + new Promise(resolve => setTimeout(resolve, 200)) + .then(() => { + return getJobStatus(req.jobId); + }) + .then(jobStatus => { + expect(jobStatus.get('message')).toEqual('Something went wrong'); + expect(jobStatus.get('status')).toEqual('failed'); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + return promise; }); - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/jobs/myJob', headers: { 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey, - }, - }).then(() => { - }, (err) => { - fail(err); - done(); - }); + 'X-Parse-Master-Key': Parse.masterKey, + }, + }).then( + () => {}, + err => { + fail(err); + done(); + } + ); }); function getJobStatus(jobId) { const q = new Parse.Query('_JobStatus'); - return q.get(jobId, {useMasterKey: true}); + return q.get(jobId, { useMasterKey: true }); } }); }); describe('cloud functions', () => { - it('Should have request ip', (done) => { - Parse.Cloud.define('myFunction', (req, res) => { + it('Should have request ip', done => { + Parse.Cloud.define('myFunction', req => { expect(req.ip).toBeDefined(); - res.success("success"); + return 'success'; }); Parse.Cloud.run('myFunction', {}).then(() => done()); @@ -1264,10 +1658,9 @@ describe('cloud functions', () => { }); describe('beforeSave hooks', () => { - it('should have request headers', (done) => { - Parse.Cloud.beforeSave('MyObject', (req, res) => { + it('should have request headers', done => { + Parse.Cloud.beforeSave('MyObject', req => { expect(req.headers).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); @@ -1275,10 +1668,9 @@ describe('beforeSave hooks', () => { myObject.save().then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.beforeSave('MyObject', (req, res) => { + it('should have request ip', done => { + Parse.Cloud.beforeSave('MyObject', req => { expect(req.ip).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); @@ -1288,21 +1680,19 @@ describe('beforeSave hooks', () => { }); describe('afterSave hooks', () => { - it('should have request headers', (done) => { - Parse.Cloud.afterSave('MyObject', (req) => { + it('should have request headers', done => { + Parse.Cloud.afterSave('MyObject', req => { expect(req.headers).toBeDefined(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() - .then(() => done()); + myObject.save().then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.afterSave('MyObject', (req, res) => { + it('should have request ip', done => { + Parse.Cloud.afterSave('MyObject', req => { expect(req.ip).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); @@ -1312,70 +1702,79 @@ describe('afterSave hooks', () => { }); describe('beforeDelete hooks', () => { - it('should have request headers', (done) => { - Parse.Cloud.beforeDelete('MyObject', (req, res) => { + it('should have request headers', done => { + Parse.Cloud.beforeDelete('MyObject', req => { expect(req.headers).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() + myObject + .save() .then(myObj => myObj.destroy()) .then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.beforeDelete('MyObject', (req, res) => { + it('should have request ip', done => { + Parse.Cloud.beforeDelete('MyObject', req => { expect(req.ip).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() + myObject + .save() .then(myObj => myObj.destroy()) .then(() => done()); }); }); describe('afterDelete hooks', () => { - it('should have request headers', (done) => { - Parse.Cloud.afterDelete('MyObject', (req) => { + it('should have request headers', done => { + Parse.Cloud.afterDelete('MyObject', req => { expect(req.headers).toBeDefined(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() + myObject + .save() .then(myObj => myObj.destroy()) .then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.afterDelete('MyObject', (req) => { + it('should have request ip', done => { + Parse.Cloud.afterDelete('MyObject', req => { expect(req.ip).toBeDefined(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() + myObject + .save() .then(myObj => myObj.destroy()) .then(() => done()); }); }); describe('beforeFind hooks', () => { - it('should add beforeFind trigger', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should add beforeFind trigger', done => { + Parse.Cloud.beforeFind('MyObject', req => { const q = req.query; expect(q instanceof Parse.Query).toBe(true); const jsonQuery = q.toJSON(); expect(jsonQuery.where.key).toEqual('value'); - expect(jsonQuery.where.some).toEqual({'$gt': 10}); + expect(jsonQuery.where.some).toEqual({ $gt: 10 }); expect(jsonQuery.include).toEqual('otherKey,otherValue'); + expect(jsonQuery.excludeKeys).toBe('exclude'); expect(jsonQuery.limit).toEqual(100); expect(jsonQuery.skip).toBe(undefined); + expect(jsonQuery.order).toBe('key'); + expect(jsonQuery.keys).toBe('select'); + expect(jsonQuery.readPreference).toBe('PRIMARY'); + expect(jsonQuery.includeReadPreference).toBe('SECONDARY'); + expect(jsonQuery.subqueryReadPreference).toBe('SECONDARY_PREFERRED'); + expect(req.isGet).toEqual(false); }); @@ -1384,13 +1783,17 @@ describe('beforeFind hooks', () => { query.greaterThan('some', 10); query.include('otherKey'); query.include('otherValue'); + query.ascending('key'); + query.select('select'); + query.exclude('exclude'); + query.readPreference('PRIMARY', 'SECONDARY', 'SECONDARY_PREFERRED'); query.find().then(() => { done(); }); }); - it('should use modify', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should use modify', done => { + Parse.Cloud.beforeFind('MyObject', req => { const q = req.query; q.equalTo('forced', true); }); @@ -1403,7 +1806,7 @@ describe('beforeFind hooks', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { const query = new Parse.Query('MyObject'); query.equalTo('forced', false); - query.find().then((results) => { + query.find().then(results => { expect(results.length).toBe(1); const firstResult = results[0]; expect(firstResult.get('forced')).toBe(true); @@ -1412,8 +1815,8 @@ describe('beforeFind hooks', () => { }); }); - it('should use the modified the query', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should use the modified the query', done => { + Parse.Cloud.beforeFind('MyObject', req => { const q = req.query; const otherQuery = new Parse.Query('MyObject'); otherQuery.equalTo('forced', true); @@ -1428,71 +1831,127 @@ describe('beforeFind hooks', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { const query = new Parse.Query('MyObject'); query.equalTo('forced', false); - query.find().then((results) => { + query.find().then(results => { expect(results.length).toBe(2); done(); }); }); }); - it('should reject queries', (done) => { + it('should use the modified exclude query', async () => { + Parse.Cloud.beforeFind('MyObject', req => { + const q = req.query; + q.exclude('number'); + }); + + const obj = new Parse.Object('MyObject'); + obj.set('number', 100); + obj.set('string', 'hello'); + await obj.save(); + + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('number')).toBeUndefined(); + expect(results[0].get('string')).toBe('hello'); + }); + + it('should reject queries', done => { Parse.Cloud.beforeFind('MyObject', () => { return Promise.reject('Do not run that query'); }); const query = new Parse.Query('MyObject'); - query.find().then(() => { - fail('should not succeed'); - done(); - }, (err) => { - expect(err.code).toBe(1); - expect(err.message).toEqual('Do not run that query'); - done(); - }); + query.find().then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(1); + expect(err.message).toEqual('Do not run that query'); + done(); + } + ); }); - it('should handle empty where', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should handle empty where', done => { + Parse.Cloud.beforeFind('MyObject', req => { const otherQuery = new Parse.Query('MyObject'); otherQuery.equalTo('some', true); return Parse.Query.or(req.query, otherQuery); }); - rp.get({ + request({ url: 'http://localhost:8378/1/classes/MyObject', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', }, - }).then(() => { - done(); - }, (err) => { - fail(err); - done(); + }).then( + () => { + done(); + }, + err => { + fail(err); + done(); + } + ); + }); + + it('should handle sorting where', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const query = req.query; + query.ascending('score'); + return query; }); + + const count = 20; + const objects = []; + while (objects.length != count) { + const object = new Parse.Object('MyObject'); + object.set('score', Math.floor(Math.random() * 100)); + objects.push(object); + } + Parse.Object.saveAll(objects) + .then(() => { + const query = new Parse.Query('MyObject'); + return query.find(); + }) + .then(objects => { + let lastScore = -1; + objects.forEach(element => { + expect(element.get('score') >= lastScore).toBe(true); + lastScore = element.get('score'); + }); + }) + .then(done) + .catch(done.fail); }); - it('should add beforeFind trigger using get API',(done) => { + it('should add beforeFind trigger using get API', done => { const hook = { method: function(req) { expect(req.isGet).toEqual(true); return Promise.resolve(); - } + }, }; spyOn(hook, 'method').and.callThrough(); Parse.Cloud.beforeFind('MyObject', hook.method); const obj = new Parse.Object('MyObject'); obj.set('secretField', 'SSID'); obj.save().then(function() { - rp({ + request({ method: 'GET', - uri: 'http://localhost:8378/1/classes/MyObject/' + obj.id, + url: 'http://localhost:8378/1/classes/MyObject/' + obj.id, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, json: true, - }).then(body => { + }).then(response => { + const body = response.data; expect(body.secretField).toEqual('SSID'); expect(hook.method).toHaveBeenCalled(); done(); @@ -1500,176 +1959,200 @@ describe('beforeFind hooks', () => { }); }); - it('should have request headers', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should have request headers', done => { + Parse.Cloud.beforeFind('MyObject', req => { expect(req.headers).toBeDefined(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() - .then((myObj) => { + myObject + .save() + .then(myObj => { const query = new Parse.Query('MyObject'); query.equalTo('objectId', myObj.id); - return Promise.all([ - query.get(myObj.id), - query.first(), - query.find(), - ]); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); }) .then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should have request ip', done => { + Parse.Cloud.beforeFind('MyObject', req => { expect(req.ip).toBeDefined(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() - .then((myObj) => { + myObject + .save() + .then(myObj => { const query = new Parse.Query('MyObject'); query.equalTo('objectId', myObj.id); - return Promise.all([ - query.get(myObj.id), - query.first(), - query.find(), - ]); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); }) .then(() => done()); }); }); describe('afterFind hooks', () => { - it('should add afterFind trigger using get',(done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { - for(let i = 0 ; i < req.objects.length ; i++){ - req.objects[i].set("secretField","###"); + it('should add afterFind trigger using get', done => { + Parse.Cloud.afterFind('MyObject', req => { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); } - res.success(req.objects); + return req.objects; }); const obj = new Parse.Object('MyObject'); obj.set('secretField', 'SSID'); - obj.save().then(function() { - const query = new Parse.Query('MyObject'); - query.get(obj.id).then(function(result) { - expect(result.get('secretField')).toEqual('###'); - done(); - }, function(error) { + obj.save().then( + function() { + const query = new Parse.Query('MyObject'); + query.get(obj.id).then( + function(result) { + expect(result.get('secretField')).toEqual('###'); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + }, + function(error) { fail(error); done(); - }); - }, function(error) { - fail(error); - done(); - }); + } + ); }); - it('should add afterFind trigger using find',(done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { - for(let i = 0 ; i < req.objects.length ; i++){ - req.objects[i].set("secretField","###"); + it('should add afterFind trigger using find', done => { + Parse.Cloud.afterFind('MyObject', req => { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); } - res.success(req.objects); + return req.objects; }); const obj = new Parse.Object('MyObject'); obj.set('secretField', 'SSID'); - obj.save().then(function() { - const query = new Parse.Query('MyObject'); - query.equalTo('objectId',obj.id); - query.find().then(function(results) { - expect(results[0].get('secretField')).toEqual('###'); - done(); - }, function(error) { + obj.save().then( + function() { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function(results) { + expect(results[0].get('secretField')).toEqual('###'); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + }, + function(error) { fail(error); done(); - }); - }, function(error) { - fail(error); - done(); - }); + } + ); }); - it('should filter out results',(done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { + it('should filter out results', done => { + Parse.Cloud.afterFind('MyObject', req => { const filteredResults = []; - for(let i = 0 ; i < req.objects.length ; i++){ - if(req.objects[i].get("secretField") === "SSID1") { + for (let i = 0; i < req.objects.length; i++) { + if (req.objects[i].get('secretField') === 'SSID1') { filteredResults.push(req.objects[i]); } } - res.success(filteredResults); + return filteredResults; }); const obj0 = new Parse.Object('MyObject'); obj0.set('secretField', 'SSID1'); const obj1 = new Parse.Object('MyObject'); obj1.set('secretField', 'SSID2'); - Parse.Object.saveAll([obj0, obj1]).then(function() { - const query = new Parse.Query('MyObject'); - query.find().then(function(results) { - expect(results[0].get('secretField')).toEqual('SSID1'); - expect(results.length).toEqual(1); - done(); - }, function(error) { + Parse.Object.saveAll([obj0, obj1]).then( + function() { + const query = new Parse.Query('MyObject'); + query.find().then( + function(results) { + expect(results[0].get('secretField')).toEqual('SSID1'); + expect(results.length).toEqual(1); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + }, + function(error) { fail(error); done(); - }); - }, function(error) { - fail(error); - done(); - }); + } + ); }); - it('should handle failures',(done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { - res.error(Parse.Error.SCRIPT_FAILED, "It should fail"); + it('should handle failures', done => { + Parse.Cloud.afterFind('MyObject', () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); }); const obj = new Parse.Object('MyObject'); obj.set('secretField', 'SSID'); - obj.save().then(function() { - const query = new Parse.Query('MyObject'); - query.equalTo('objectId',obj.id); - query.find().then(function() { - fail("AfterFind should handle response failure correctly"); - done(); - }, function() { + obj.save().then( + function() { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function() { + fail('AfterFind should handle response failure correctly'); + done(); + }, + function() { + done(); + } + ); + }, + function() { done(); - }); - }, function() { - done(); - }); + } + ); }); - it('should also work with promise',(done) => { - Parse.Cloud.afterFind('MyObject', (req) => { - const promise = new Parse.Promise(); - setTimeout(function(){ - for(let i = 0 ; i < req.objects.length ; i++){ - req.objects[i].set("secretField","###"); - } - promise.resolve(req.objects); - }, 1000); - return promise; + it('should also work with promise', done => { + Parse.Cloud.afterFind('MyObject', req => { + return new Promise(resolve => { + setTimeout(function() { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); + } + resolve(req.objects); + }, 1000); + }); }); const obj = new Parse.Object('MyObject'); obj.set('secretField', 'SSID'); - obj.save().then(function() { - const query = new Parse.Query('MyObject'); - query.equalTo('objectId',obj.id); - query.find().then(function(results) { - expect(results[0].get('secretField')).toEqual('###'); - done(); - }, function(error) { + obj.save().then( + function() { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function(results) { + expect(results[0].get('secretField')).toEqual('###'); + done(); + }, + function(error) { + fail(error); + } + ); + }, + function(error) { fail(error); - }); - }, function(error) { - fail(error); - }); + } + ); }); - it('should alter select', (done) => { - Parse.Cloud.beforeFind('MyObject', (req) => { + it('should alter select', done => { + Parse.Cloud.beforeFind('MyObject', req => { req.query.select('white'); return req.query; }); @@ -1677,120 +2160,502 @@ describe('afterFind hooks', () => { const obj0 = new Parse.Object('MyObject') .set('white', true) .set('black', true); - obj0.save() - .then(() => { - new Parse.Query('MyObject') - .first() - .then(result => { - expect(result.get('white')).toBe(true); - expect(result.get('black')).toBe(undefined); - done(); - }); + obj0.save().then(() => { + new Parse.Query('MyObject').first().then(result => { + expect(result.get('white')).toBe(true); + expect(result.get('black')).toBe(undefined); + done(); }); + }); }); - it('should not alter select', (done) => { + it('should not alter select', done => { const obj0 = new Parse.Object('MyObject') .set('white', true) .set('black', true); - obj0.save() - .then(() => { - new Parse.Query('MyObject') - .first() - .then(result => { - expect(result.get('white')).toBe(true); - expect(result.get('black')).toBe(true); - done(); - }); + obj0.save().then(() => { + new Parse.Query('MyObject').first().then(result => { + expect(result.get('white')).toBe(true); + expect(result.get('black')).toBe(true); + done(); }); + }); }); - it('should set count to true on beforeFind hooks if query is count', (done) => { + it('should set count to true on beforeFind hooks if query is count', done => { const hook = { method: function(req) { expect(req.count).toBe(true); return Promise.resolve(); - } + }, }; spyOn(hook, 'method').and.callThrough(); Parse.Cloud.beforeFind('Stuff', hook.method); - new Parse.Query('Stuff').count().then((count) => { + new Parse.Query('Stuff').count().then(count => { expect(count).toBe(0); expect(hook.method).toHaveBeenCalled(); done(); }); }); - it('should set count to false on beforeFind hooks if query is not count', (done) => { + it('should set count to false on beforeFind hooks if query is not count', done => { const hook = { method: function(req) { expect(req.count).toBe(false); return Promise.resolve(); - } + }, }; spyOn(hook, 'method').and.callThrough(); Parse.Cloud.beforeFind('Stuff', hook.method); - new Parse.Query('Stuff').find().then((res) => { + new Parse.Query('Stuff').find().then(res => { expect(res.length).toBe(0); expect(hook.method).toHaveBeenCalled(); done(); }); }); - it('should have request headers', (done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { + it('should have request headers', done => { + Parse.Cloud.afterFind('MyObject', req => { expect(req.headers).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() - .then((myObj) => { + myObject + .save() + .then(myObj => { const query = new Parse.Query('MyObject'); query.equalTo('objectId', myObj.id); - return Promise.all([ - query.get(myObj.id), - query.first(), - query.find(), - ]); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); }) .then(() => done()); }); - it('should have request ip', (done) => { - Parse.Cloud.afterFind('MyObject', (req, res) => { + it('should have request ip', done => { + Parse.Cloud.afterFind('MyObject', req => { expect(req.ip).toBeDefined(); - res.success(); }); const MyObject = Parse.Object.extend('MyObject'); const myObject = new MyObject(); - myObject.save() - .then((myObj) => { + myObject + .save() + .then(myObj => { const query = new Parse.Query('MyObject'); query.equalTo('objectId', myObj.id); - return Promise.all([ - query.get(myObj.id), - query.first(), - query.find(), - ]); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); }) - .then(() => done()); + .then(() => done()) + .catch(done.fail); }); it('should validate triggers correctly', () => { expect(() => { Parse.Cloud.beforeSave('_Session', () => {}); - }).toThrow('Triggers are not supported for _Session class.'); + }).toThrow( + 'Only the afterLogout trigger is allowed for the _Session class.' + ); expect(() => { Parse.Cloud.afterSave('_Session', () => {}); - }).toThrow('Triggers are not supported for _Session class.'); + }).toThrow( + 'Only the afterLogout trigger is allowed for the _Session class.' + ); expect(() => { Parse.Cloud.beforeSave('_PushStatus', () => {}); }).toThrow('Only afterSave is allowed on _PushStatus'); expect(() => { Parse.Cloud.afterSave('_PushStatus', () => {}); }).not.toThrow(); + expect(() => { + Parse.Cloud.beforeLogin(() => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.beforeLogin('_User', () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.beforeLogin(Parse.User, () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.beforeLogin('SomeClass', () => {}); + }).toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.afterLogin(() => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.afterLogin('_User', () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.afterLogin(Parse.User, () => {}); + }).not.toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.afterLogin('SomeClass', () => {}); + }).toThrow( + 'Only the _User class is allowed for the beforeLogin and afterLogin triggers' + ); + expect(() => { + Parse.Cloud.afterLogout(() => {}); + }).not.toThrow(); + expect(() => { + Parse.Cloud.afterLogout('_Session', () => {}); + }).not.toThrow(); + expect(() => { + Parse.Cloud.afterLogout('_User', () => {}); + }).toThrow( + 'Only the _Session class is allowed for the afterLogout trigger.' + ); + expect(() => { + Parse.Cloud.afterLogout('SomeClass', () => {}); + }).toThrow( + 'Only the _Session class is allowed for the afterLogout trigger.' + ); + }); + + it('should skip afterFind hooks for aggregate', done => { + const hook = { + method: function() { + return Promise.reject(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.afterFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + const pipeline = [ + { + group: { objectId: {} }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query('MyObject'); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + expect(hook.method).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should skip afterFind hooks for distinct', done => { + const hook = { + method: function() { + return Promise.reject(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.afterFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + obj.set('score', 10); + obj + .save() + .then(() => { + const query = new Parse.Query('MyObject'); + return query.distinct('score'); + }) + .then(results => { + expect(results[0]).toEqual(10); + expect(hook.method).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should expose context in before and afterSave', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('MyClass', req => { + req.context = { + key: 'value', + otherKey: 1, + }; + calledBefore = true; + }); + Parse.Cloud.afterSave('MyClass', req => { + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledAfter = true; + }); + + const object = new Parse.Object('MyClass'); + await object.save(); + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should expose context in before and afterSave and let keys be set individually', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('MyClass', req => { + req.context.some = 'value'; + req.context.yolo = 1; + calledBefore = true; + }); + Parse.Cloud.afterSave('MyClass', req => { + expect(req.context.yolo).toBe(1); + expect(req.context.some).toBe('value'); + calledAfter = true; + }); + + const object = new Parse.Object('MyClass'); + await object.save(); + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); +}); + +describe('beforeLogin hook', () => { + it('should run beforeLogin with correct credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + const user = await Parse.User.logIn('tupac', 'shakur'); + expect(hit).toBe(1); + expect(user).toBeDefined(); + expect(user.getUsername()).toBe('tupac'); + expect(user.getSessionToken()).toBeDefined(); + done(); + }); + + it('should be able to block login if an error is thrown', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + await user.save({ isBanned: true }); + + try { + await Parse.User.logIn('tupac', 'shakur'); + throw new Error('should not have been logged in.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + done(); + }); + + it('should be able to block login if an error is thrown even if the user has a attached file', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save(); + await user.save({ isBanned: true, file }); + + try { + await Parse.User.logIn('tupac', 'shakur'); + throw new Error('should not have been logged in.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + done(); + }); + + it('should not run beforeLogin with incorrect credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + try { + await Parse.User.logIn('tony', 'shakur'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(hit).toBe(0); + done(); + }); + + it('should not run beforeLogin on sign up', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + expect(user).toBeDefined(); + expect(hit).toBe(0); + done(); + }); + + it('should trigger afterLogout hook on logout', async done => { + let userId; + Parse.Cloud.afterLogout(req => { + expect(req.object.className).toEqual('_Session'); + expect(req.object.id).toBeDefined(); + const user = req.object.get('user'); + expect(user).toBeDefined(); + userId = user.id; + }); + + const user = await Parse.User.signUp('user', 'pass'); + await Parse.User.logOut(); + expect(user.id).toBe(userId); + done(); + }); + + it('should have expected data in request', async done => { + Parse.Cloud.beforeLogin(req => { + expect(req.object).toBeDefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeUndefined(); + }); + + await Parse.User.signUp('tupac', 'shakur'); + await Parse.User.logIn('tupac', 'shakur'); + done(); + }); + + it('afterFind should not be triggered when saving an object', async () => { + let beforeSaves = 0; + Parse.Cloud.beforeSave('SavingTest', () => { + beforeSaves++; + }); + + let afterSaves = 0; + Parse.Cloud.afterSave('SavingTest', () => { + afterSaves++; + }); + + let beforeFinds = 0; + Parse.Cloud.beforeFind('SavingTest', () => { + beforeFinds++; + }); + + let afterFinds = 0; + Parse.Cloud.afterFind('SavingTest', () => { + afterFinds++; + }); + + const obj = new Parse.Object('SavingTest'); + obj.set('someField', 'some value 1'); + await obj.save(); + + expect(beforeSaves).toEqual(1); + expect(afterSaves).toEqual(1); + expect(beforeFinds).toEqual(0); + expect(afterFinds).toEqual(0); + + obj.set('someField', 'some value 2'); + await obj.save(); + + expect(beforeSaves).toEqual(2); + expect(afterSaves).toEqual(2); + expect(beforeFinds).toEqual(0); + expect(afterFinds).toEqual(0); + + await obj.fetch(); + + expect(beforeSaves).toEqual(2); + expect(afterSaves).toEqual(2); + expect(beforeFinds).toEqual(1); + expect(afterFinds).toEqual(1); + + obj.set('someField', 'some value 3'); + await obj.save(); + + expect(beforeSaves).toEqual(3); + expect(afterSaves).toEqual(3); + expect(beforeFinds).toEqual(1); + expect(afterFinds).toEqual(1); + }); +}); + +describe('afterLogin hook', () => { + it('should run afterLogin after successful login', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + const user = await Parse.User.logIn('testuser', 'p@ssword'); + expect(hit).toBe(1); + expect(user).toBeDefined(); + expect(user.getUsername()).toBe('testuser'); + expect(user.getSessionToken()).toBeDefined(); + done(); + }); + + it('should not run afterLogin after unsuccessful login', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.User.logIn('testuser', 'badpassword'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(hit).toBe(0); + done(); + }); + + it('should not run afterLogin on sign up', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + const user = await Parse.User.signUp('testuser', 'p@ssword'); + expect(user).toBeDefined(); + expect(hit).toBe(0); + done(); + }); + + it('should have expected data in request', async done => { + Parse.Cloud.afterLogin(req => { + expect(req.object).toBeDefined(); + expect(req.user).toBeDefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeUndefined(); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + await Parse.User.logIn('testuser', 'p@ssword'); + done(); }); }); diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js index 5842cf85fd..85de9476d9 100644 --- a/spec/CloudCodeLogger.spec.js +++ b/spec/CloudCodeLogger.spec.js @@ -1,105 +1,119 @@ -const LoggerController = require('../src/Controllers/LoggerController').LoggerController; -const WinstonLoggerAdapter = require('../src/Adapters/Logger/WinstonLoggerAdapter').WinstonLoggerAdapter; +const LoggerController = require('../lib/Controllers/LoggerController') + .LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; const fs = require('fs'); +const Config = require('../lib/Config'); const loremFile = __dirname + '/support/lorem.txt'; -describe("Cloud Code Logger", () => { +describe('Cloud Code Logger', () => { let user; - - beforeEach(done => { + let spy; + beforeEach(async () => { Parse.User.enableUnsafeCurrentUser(); return reconfigureServer({ // useful to flip to false for fine tuning :). silent: true, - }).then(() => { - return Parse.User.signUp('tester', 'abc') - .then(loggedInUser => user = loggedInUser) - .then(() => Parse.User.logIn(user.get('username'), 'abc')) - .then(() => done()) - }); + }) + .then(() => { + return Parse.User.signUp('tester', 'abc') + .catch(() => {}) + .then(loggedInUser => (user = loggedInUser)) + .then(() => Parse.User.logIn(user.get('username'), 'abc')); + }) + .then(() => { + spy = spyOn( + Config.get('test').loggerController.adapter, + 'log' + ).and.callThrough(); + }); }); // Note that helpers takes care of logout. // see helpers.js:afterEach - it("should expose log to functions", done => { - var logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.define("loggerTest", (req, res) => { + it('should expose log to functions', () => { + const spy = spyOn( + Config.get('test').loggerController, + 'log' + ).and.callThrough(); + Parse.Cloud.define('loggerTest', req => { req.log.info('logTest', 'info log', { info: 'some log' }); req.log.error('logTest', 'error log', { error: 'there was an error' }); - res.success({}); + return {}; }); - Parse.Cloud.run('loggerTest').then(() => { - return logController.getLogs({ from: Date.now() - 500, size: 1000 }); - }).then((res) => { - expect(res.length).not.toBe(0); - const lastLogs = res.slice(0, 3); - const cloudFunctionMessage = lastLogs[0]; - const errorMessage = lastLogs[1]; - const infoMessage = lastLogs[2]; - expect(cloudFunctionMessage.level).toBe('info'); - expect(cloudFunctionMessage.params).toEqual({}); - expect(cloudFunctionMessage.message).toMatch(/Ran cloud function loggerTest for user [^ ]* with:\n {2}Input: {}\n {2}Result: {}/); - expect(cloudFunctionMessage.functionName).toEqual('loggerTest'); - expect(errorMessage.level).toBe('error'); - expect(errorMessage.error).toBe('there was an error'); - expect(errorMessage.message).toBe('logTest error log'); - expect(infoMessage.level).toBe('info'); - expect(infoMessage.info).toBe('some log'); - expect(infoMessage.message).toBe('logTest info log'); - done(); + return Parse.Cloud.run('loggerTest').then(() => { + expect(spy).toHaveBeenCalledTimes(3); + const cloudFunctionMessage = spy.calls.all()[2]; + const errorMessage = spy.calls.all()[1]; + const infoMessage = spy.calls.all()[0]; + expect(cloudFunctionMessage.args[0]).toBe('info'); + expect(cloudFunctionMessage.args[1][1].params).toEqual({}); + expect(cloudFunctionMessage.args[1][0]).toMatch( + /Ran cloud function loggerTest for user [^ ]* with:\n {2}Input: {}\n {2}Result: {}/ + ); + expect(cloudFunctionMessage.args[1][1].functionName).toEqual( + 'loggerTest' + ); + expect(errorMessage.args[0]).toBe('error'); + expect(errorMessage.args[1][2].error).toBe('there was an error'); + expect(errorMessage.args[1][0]).toBe('logTest'); + expect(errorMessage.args[1][1]).toBe('error log'); + expect(infoMessage.args[0]).toBe('info'); + expect(infoMessage.args[1][2].info).toBe('some log'); + expect(infoMessage.args[1][0]).toBe('logTest'); + expect(infoMessage.args[1][1]).toBe('info log'); }); }); it('trigger should obfuscate password', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.beforeSave(Parse.User, (req, res) => { - res.success(req.object); + Parse.Cloud.beforeSave(Parse.User, req => { + return req.object; }); Parse.User.signUp('tester123', 'abc') - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then((res) => { - const entry = res[0]; - expect(entry.message).not.toMatch(/password":"abc/); - expect(entry.message).toMatch(/\*\*\*\*\*\*\*\*/); + .then(() => { + const entry = spy.calls.mostRecent().args; + expect(entry[1]).not.toMatch(/password":"abc/); + expect(entry[1]).toMatch(/\*\*\*\*\*\*\*\*/); done(); }) .then(null, e => done.fail(e)); }); - it("should expose log to trigger", (done) => { - var logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.beforeSave("MyObject", (req, res) => { + it('should expose log to trigger', done => { + Parse.Cloud.beforeSave('MyObject', req => { req.log.info('beforeSave MyObject', 'info log', { info: 'some log' }); - req.log.error('beforeSave MyObject', 'error log', { error: 'there was an error' }); - res.success({}); + req.log.error('beforeSave MyObject', 'error log', { + error: 'there was an error', + }); + return {}; }); const obj = new Parse.Object('MyObject'); obj.save().then(() => { - return logController.getLogs({ from: Date.now() - 500, size: 1000 }) - }).then((res) => { - expect(res.length).not.toBe(0); - const lastLogs = res.slice(0, 3); - const cloudTriggerMessage = lastLogs[0]; - const errorMessage = lastLogs[1]; - const infoMessage = lastLogs[2]; - expect(cloudTriggerMessage.level).toBe('info'); - expect(cloudTriggerMessage.triggerType).toEqual('beforeSave'); - expect(cloudTriggerMessage.message).toMatch(/beforeSave triggered for MyObject for user [^ ]*\n {2}Input: {}\n {2}Result: {}/); - expect(cloudTriggerMessage.user).toBe(user.id); - expect(errorMessage.level).toBe('error'); - expect(errorMessage.error).toBe('there was an error'); - expect(errorMessage.message).toBe('beforeSave MyObject error log'); - expect(infoMessage.level).toBe('info'); - expect(infoMessage.info).toBe('some log'); - expect(infoMessage.message).toBe('beforeSave MyObject info log'); + const lastCalls = spy.calls.all().reverse(); + const cloudTriggerMessage = lastCalls[0].args; + const errorMessage = lastCalls[1].args; + const infoMessage = lastCalls[2].args; + expect(cloudTriggerMessage[0]).toBe('info'); + expect(cloudTriggerMessage[2].triggerType).toEqual('beforeSave'); + expect(cloudTriggerMessage[1]).toMatch( + /beforeSave triggered for MyObject for user [^ ]*\n {2}Input: {}\n {2}Result: {"object":{}}/ + ); + expect(cloudTriggerMessage[2].user).toBe(user.id); + expect(errorMessage[0]).toBe('error'); + expect(errorMessage[3].error).toBe('there was an error'); + expect(errorMessage[1] + ' ' + errorMessage[2]).toBe( + 'beforeSave MyObject error log' + ); + expect(infoMessage[0]).toBe('info'); + expect(infoMessage[3].info).toBe('some log'); + expect(infoMessage[1] + ' ' + infoMessage[2]).toBe( + 'beforeSave MyObject info log' + ); done(); }); }); @@ -112,43 +126,39 @@ describe("Cloud Code Logger", () => { }); it('should truncate input and result of long lines', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); const longString = fs.readFileSync(loremFile, 'utf8'); - Parse.Cloud.define('aFunction', (req, res) => { - res.success(req.params); + Parse.Cloud.define('aFunction', req => { + return req.params; }); Parse.Cloud.run('aFunction', { longString }) - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then(logs => { - const log = logs[0]; - expect(log.level).toEqual('info'); - expect(log.message).toMatch( - /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {.*?\(truncated\)$/m); + .then(() => { + const log = spy.calls.mostRecent().args; + expect(log[0]).toEqual('info'); + expect(log[1]).toMatch( + /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {.*?\(truncated\)$/m + ); done(); }) .then(null, e => done.fail(e)); }); it('should log an afterSave', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - Parse.Cloud.afterSave("MyObject", () => { }); + Parse.Cloud.afterSave('MyObject', () => {}); new Parse.Object('MyObject') .save() - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then((logs) => { - const log = logs[0]; - expect(log.triggerType).toEqual('afterSave'); + .then(() => { + const log = spy.calls.mostRecent().args; + expect(log[2].triggerType).toEqual('afterSave'); done(); }) - // catch errors - not that the error is actually useful :( + // catch errors - not that the error is actually useful :( .then(null, e => done.fail(e)); }); it('should log a denied beforeSave', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - Parse.Cloud.beforeSave("MyObject", (req, res) => { - res.error('uh oh!'); + Parse.Cloud.beforeSave('MyObject', () => { + throw 'uh oh!'; }); new Parse.Object('MyObject') @@ -157,58 +167,64 @@ describe("Cloud Code Logger", () => { () => done.fail('this is not supposed to succeed'), () => new Promise(resolve => setTimeout(resolve, 100)) ) - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then(logs => { - const log = logs[1]; // 0 is the 'uh oh!' from rejection... - expect(log.level).toEqual('error'); - expect(log.error).toEqual({ code: 141, message: 'uh oh!' }); - done() + .then(() => { + const logs = spy.calls.all().reverse(); + const log = logs[1].args; // 0 is the 'uh oh!' from rejection... + expect(log[0]).toEqual('error'); + const error = log[2].error; + expect(error instanceof Parse.Error).toBeTruthy(); + expect(error.code).toBe(141); + expect(error.message).toBe('uh oh!'); + done(); }); }); it('should log cloud function success', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.define('aFunction', (req, res) => { - res.success('it worked!'); + Parse.Cloud.define('aFunction', () => { + return 'it worked!'; }); - Parse.Cloud.run('aFunction', { foo: 'bar' }) - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then(logs => { - const log = logs[0]; - expect(log.level).toEqual('info'); - expect(log.message).toMatch( - /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Result: "it worked!/); - done(); - }); + Parse.Cloud.run('aFunction', { foo: 'bar' }).then(() => { + const log = spy.calls.mostRecent().args; + expect(log[0]).toEqual('info'); + expect(log[1]).toMatch( + /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Result: "it worked!/ + ); + done(); + }); }); it('should log cloud function failure', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.define('aFunction', (req, res) => { - res.error('it failed!'); + Parse.Cloud.define('aFunction', () => { + throw 'it failed!'; }); Parse.Cloud.run('aFunction', { foo: 'bar' }) - .then(null, () => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then(logs => { - const log = logs[2]; - expect(log.level).toEqual('error'); - expect(log.message).toMatch( - /Failed running cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Error: {"code":141,"message":"it failed!"}/); + .catch(() => {}) + .then(() => { + const logs = spy.calls.all().reverse(); + expect(logs[0].args[1]).toBe('Parse error: '); + expect(logs[0].args[2].message).toBe('it failed!'); + + const log = logs[1].args; + expect(log[0]).toEqual('error'); + expect(log[1]).toMatch( + /Failed running cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Error:/ + ); + const errorString = JSON.stringify(new Parse.Error(141, 'it failed!')); + expect(log[1].indexOf(errorString)).toBeGreaterThan(0); done(); - }); + }) + .catch(done.fail); }); xit('should log a changed beforeSave indicating a change', done => { const logController = new LoggerController(new WinstonLoggerAdapter()); - Parse.Cloud.beforeSave("MyObject", (req, res) => { + Parse.Cloud.beforeSave('MyObject', req => { const myObj = req.object; myObj.set('aChange', true); - res.success(myObj); + return myObj; }); new Parse.Object('MyObject') @@ -228,19 +244,33 @@ describe("Cloud Code Logger", () => { }).pend('needs more work.....'); it('cloud function should obfuscate password', done => { - const logController = new LoggerController(new WinstonLoggerAdapter()); - - Parse.Cloud.define('testFunction', (req, res) => { - res.success(1002,'verify code success'); + Parse.Cloud.define('testFunction', () => { + return 'verify code success'; }); - Parse.Cloud.run('testFunction', {username:'hawk',password:'123456'}) - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then((res) => { - const entry = res[0]; - expect(entry.params.password).toMatch(/\*\*\*\*\*\*\*\*/); + Parse.Cloud.run('testFunction', { username: 'hawk', password: '123456' }) + .then(() => { + const entry = spy.calls.mostRecent().args; + expect(entry[2].params.password).toMatch(/\*\*\*\*\*\*\*\*/); done(); }) .then(null, e => done.fail(e)); }); + + it('should only log once for object not found', async () => { + const config = Config.get('test'); + const spy = spyOn(config.loggerController, 'error').and.callThrough(); + try { + const object = new Parse.Object('Object'); + object.id = 'invalid'; + await object.fetch(); + } catch (e) { + /**/ + } + expect(spy).toHaveBeenCalled(); + expect(spy.calls.count()).toBe(1); + const { args } = spy.calls.mostRecent(); + expect(args[0]).toBe('Parse error: '); + expect(args[1].message).toBe('Object not found.'); + }); }); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index 423b18a147..af373e64f2 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -1,51 +1,58 @@ -var DatabaseController = require('../src/Controllers/DatabaseController.js'); -var validateQuery = DatabaseController._validateQuery; +const DatabaseController = require('../lib/Controllers/DatabaseController.js'); +const validateQuery = DatabaseController._validateQuery; describe('DatabaseController', function() { - describe('validateQuery', function() { - - it('should restructure simple cases of SERVER-13732', (done) => { - var query = {$or: [{a: 1}, {a: 2}], _rperm: {$in: ['a', 'b']}, foo: 3}; + it('should not restructure simple cases of SERVER-13732', done => { + const query = { + $or: [{ a: 1 }, { a: 2 }], + _rperm: { $in: ['a', 'b'] }, + foo: 3, + }; validateQuery(query); - expect(query).toEqual({$or: [{a: 1, _rperm: {$in: ['a', 'b']}, foo: 3}, - {a: 2, _rperm: {$in: ['a', 'b']}, foo: 3}]}); + expect(query).toEqual({ + $or: [{ a: 1 }, { a: 2 }], + _rperm: { $in: ['a', 'b'] }, + foo: 3, + }); done(); }); - it('should not restructure SERVER-13732 queries with $nears', (done) => { - var query = {$or: [{a: 1}, {b: 1}], c: {$nearSphere: {}}}; + it('should not restructure SERVER-13732 queries with $nears', done => { + let query = { $or: [{ a: 1 }, { b: 1 }], c: { $nearSphere: {} } }; validateQuery(query); - expect(query).toEqual({$or: [{a: 1}, {b: 1}], c: {$nearSphere: {}}}); - - query = {$or: [{a: 1}, {b: 1}], c: {$near: {}}}; + expect(query).toEqual({ + $or: [{ a: 1 }, { b: 1 }], + c: { $nearSphere: {} }, + }); + query = { $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }; validateQuery(query); - expect(query).toEqual({$or: [{a: 1}, {b: 1}], c: {$near: {}}}); - + expect(query).toEqual({ $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }); done(); }); - - it('should push refactored keys down a tree for SERVER-13732', (done) => { - var query = {a: 1, $or: [{$or: [{b: 1}, {b: 2}]}, - {$or: [{c: 1}, {c: 2}]}]}; + it('should not push refactored keys down a tree for SERVER-13732', done => { + const query = { + a: 1, + $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], + }; validateQuery(query); - expect(query).toEqual({$or: [{$or: [{b: 1, a: 1}, {b: 2, a: 1}]}, - {$or: [{c: 1, a: 1}, {c: 2, a: 1}]}]}); + expect(query).toEqual({ + a: 1, + $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], + }); done(); }); - it('should reject invalid queries', (done) => { - expect(() => validateQuery({$or: {'a': 1}})).toThrow(); + it('should reject invalid queries', done => { + expect(() => validateQuery({ $or: { a: 1 } })).toThrow(); done(); }); - it('should accept valid queries', (done) => { - expect(() => validateQuery({$or: [{'a': 1}, {'b': 2}]})).not.toThrow(); + it('should accept valid queries', done => { + expect(() => validateQuery({ $or: [{ a: 1 }, { b: 2 }] })).not.toThrow(); done(); }); - }); - }); diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 7f8c982333..ccbb09d2ed 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1,239 +1,258 @@ -"use strict"; +'use strict'; -const request = require('request'); -const requestp = require('request-promise'); -const Config = require('../src/Config'); - -describe("Email Verification Token Expiration: ", () => { +const Config = require('../lib/Config'); +const request = require('../lib/request'); +describe('Email Verification Token Expiration: ', () => { it('show the invalid verification link page, if the user clicks on the verify email link after the email verify token expires', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 0.5, // 0.5 second - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { - // wait for 1 second - simulate user behavior to some extent + }) + .then(() => { + // wait for 1 second - simulate user behavior to some extent setTimeout(() => { expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test'); + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test' + ); done(); }); }, 1000); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); }); }); it('emailVerified should set to false, if the user does not verify their email before the email verify token expires', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 0.5, // 0.5 second - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { - // wait for 1 second - simulate user behavior to some extent + }) + .then(() => { + // wait for 1 second - simulate user behavior to some extent setTimeout(() => { expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - user.fetch() + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + user + .fetch() .then(() => { expect(user.get('emailVerified')).toEqual(false); done(); }) - .catch(() => { + .catch(error => { jfail(error); done(); }); }); }, 1000); - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); it('if user clicks on the email verify link before email verification token expiration then show the verify email success page', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity'); + }) + .then(() => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity' + ); done(); }); - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); it('if user clicks on the email verify link before email verification token expiration then emailVerified should be true', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - user.fetch() + }) + .then(() => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + user + .fetch() .then(() => { expect(user.get('emailVerified')).toEqual(true); done(); }) - .catch((error) => { + .catch(error => { jfail(error); done(); }); }); - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); it('if user clicks on the email verify link before email verification token expiration then user should be able to login', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - Parse.User.logIn("testEmailVerifyTokenValidity", "expiringToken") + }) + .then(() => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken') .then(user => { expect(typeof user).toBe('object'); expect(user.get('emailVerified')).toBe(true); done(); }) - .catch((error) => { + .catch(error => { jfail(error); done(); }); }); - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); it('sets the _email_verify_token_expires_at and _email_verify_token fields after user SignUp', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { user.setUsername('sets_email_verify_token_expires_at'); @@ -243,7 +262,9 @@ describe("Email Verification Token Expiration: ", () => { }) .then(() => { const config = Config.get('test'); - return config.database.find('_User', {username: 'sets_email_verify_token_expires_at'}); + return config.database.find('_User', { + username: 'sets_email_verify_token_expires_at', + }); }) .then(results => { expect(results.length).toBe(1); @@ -262,43 +283,50 @@ describe("Email Verification Token Expiration: ", () => { }); it('unsets the _email_verify_token_expires_at and _email_verify_token fields in the User class if email verification is successful', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("unsets_email_verify_token_expires_at"); - user.setPassword("expiringToken"); + user.setUsername('unsets_email_verify_token_expires_at'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); }) .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); const config = Config.get('test'); - return config.database.find('_User', {username: 'unsets_email_verify_token_expires_at'}).then((results) => { - expect(results.length).toBe(1); - return results[0]; - }) + return config.database + .find('_User', { + username: 'unsets_email_verify_token_expires_at', + }) + .then(results => { + expect(results.length).toBe(1); + return results[0]; + }) .then(user => { expect(typeof user).toBe('object'); expect(user.emailVerified).toEqual(true); expect(typeof user._email_verify_token).toBe('undefined'); - expect(typeof user._email_verify_token_expires_at).toBe('undefined'); + expect(typeof user._email_verify_token_expires_at).toBe( + 'undefined' + ); done(); }) .catch(error => { @@ -314,38 +342,37 @@ describe("Email Verification Token Expiration: ", () => { }); it('clicking on the email verify link by an email VERIFIED user that was setup before enabling the expire email verify token should show email verify email success', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - var serverConfig = { + sendMail: () => {}, + }; + const serverConfig = { appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }; // setup server WITHOUT enabling the expire email verify token flag reconfigureServer(serverConfig) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); }) .then(() => { - return new Promise((resolve, reject) => { - request.get(sendEmailOptions.link, { followRedirect: false, }) - .on('error', error => reject(error)) - .on('response', (response) => { - expect(response.statusCode).toEqual(302); - resolve(user.fetch()); - }); + return request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + return user.fetch(); }); }) .then(() => { @@ -355,47 +382,50 @@ describe("Email Verification Token Expiration: ", () => { return reconfigureServer(serverConfig); }) .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity'); + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity' + ); done(); }); }) - .catch((error) => { + .catch(error => { jfail(error); done(); }); }); it('clicking on the email verify link by an email UNVERIFIED user that was setup before enabling the expire email verify token should show invalid verficiation link page', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - var serverConfig = { + sendMail: () => {}, + }; + const serverConfig = { appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }; // setup server WITHOUT enabling the expire email verify token flag reconfigureServer(serverConfig) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); }) .then(() => { - // just get the user again - DO NOT email verify the user + // just get the user again - DO NOT email verify the user return user.fetch(); }) .then(() => { @@ -405,22 +435,24 @@ describe("Email Verification Token Expiration: ", () => { return reconfigureServer(serverConfig); }) .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test'); + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=testEmailVerifyTokenValidity&appId=test' + ); done(); }); }) - .catch((error) => { + .catch(error => { jfail(error); done(); }); }); it('setting the email on the user should set a new email verification token and new expiration date for the token when expire email verify token flag is set', done => { - const user = new Parse.User(); let userBeforeEmailReset; @@ -430,28 +462,30 @@ describe("Email Verification Token Expiration: ", () => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }; const serverConfig = { appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }; reconfigureServer(serverConfig) .then(() => { - user.setUsername("newEmailVerifyTokenOnEmailReset"); - user.setPassword("expiringToken"); + user.setUsername('newEmailVerifyTokenOnEmailReset'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); }) .then(() => { const config = Config.get('test'); - return config.database.find('_User', {username: 'newEmailVerifyTokenOnEmailReset'}).then((results) => { - return results[0]; - }); + return config.database + .find('_User', { username: 'newEmailVerifyTokenOnEmailReset' }) + .then(results => { + return results[0]; + }); }) .then(userFromDb => { expect(typeof userFromDb).toBe('object'); @@ -459,48 +493,55 @@ describe("Email Verification Token Expiration: ", () => { // trigger another token generation by setting the email user.set('email', 'user@parse.com'); - return new Promise((resolve) => { - // wait for half a sec to get a new expiration time + return new Promise(resolve => { + // wait for half a sec to get a new expiration time setTimeout(() => resolve(user.save()), 500); }); }) .then(() => { const config = Config.get('test'); - return config.database.find('_User', {username: 'newEmailVerifyTokenOnEmailReset'}).then((results) => { - return results[0]; - }); + return config.database + .find('_User', { username: 'newEmailVerifyTokenOnEmailReset' }) + .then(results => { + return results[0]; + }); }) .then(userAfterEmailReset => { expect(typeof userAfterEmailReset).toBe('object'); - expect(userBeforeEmailReset._email_verify_token).not.toEqual(userAfterEmailReset._email_verify_token); - expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual(userAfterEmailReset.__email_verify_token_expires_at); + expect(userBeforeEmailReset._email_verify_token).not.toEqual( + userAfterEmailReset._email_verify_token + ); + expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual( + userAfterEmailReset.__email_verify_token_expires_at + ); expect(sendEmailOptions).toBeDefined(); done(); }) - .catch((error) => { + .catch(error => { jfail(error); done(); }); }); it('should send a new verification email when a resend is requested and the user is UNVERIFIED', done => { - var user = new Parse.User(); - var sendEmailOptions; - var sendVerificationEmailCallCount = 0; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + let userBeforeRequest; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; sendVerificationEmailCallCount++; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { user.setUsername('resends_verification_token'); @@ -509,28 +550,56 @@ describe("Email Verification Token Expiration: ", () => { return user.signUp(); }) .then(() => { + const config = Config.get('test'); + return config.database + .find('_User', { username: 'resends_verification_token' }) + .then(results => { + return results[0]; + }); + }) + .then(newUser => { + // store this user before we make our email request + userBeforeRequest = newUser; + expect(sendVerificationEmailCallCount).toBe(1); - return requestp.post({ - uri: 'http://localhost:8378/1/verificationEmailRequest', + return request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', body: { - email: 'user@parse.com' + email: 'user@parse.com', }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true, - resolveWithFullResponse: true, - simple: false // this promise is only rejected if the call itself failed - }) - .then((response) => { - expect(response.statusCode).toBe(200); - expect(sendVerificationEmailCallCount).toBe(2); - expect(sendEmailOptions).toBeDefined(); - done(); + }); + }) + .then(response => { + expect(response.status).toBe(200); + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + + // query for this user again + const config = Config.get('test'); + return config.database + .find('_User', { username: 'resends_verification_token' }) + .then(results => { + return results[0]; }); }) + .then(userAfterRequest => { + // verify that our token & expiration has been changed for this new request + expect(typeof userAfterRequest).toBe('object'); + expect(userBeforeRequest._email_verify_token).not.toEqual( + userAfterRequest._email_verify_token + ); + expect(userBeforeRequest._email_verify_token_expires_at).not.toEqual( + userAfterRequest.__email_verify_token_expires_at + ); + done(); + }) .catch(error => { jfail(error); done(); @@ -538,23 +607,23 @@ describe("Email Verification Token Expiration: ", () => { }); it('should not send a new verification email when a resend is requested and the user is VERIFIED', done => { - var user = new Parse.User(); - var sendEmailOptions; - var sendVerificationEmailCallCount = 0; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; sendVerificationEmailCallCount++; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { user.setUsername('no_new_verification_token_once_verified'); @@ -563,34 +632,31 @@ describe("Email Verification Token Expiration: ", () => { return user.signUp(); }) .then(() => { - return requestp.get({ + return request({ url: sendEmailOptions.link, - followRedirect: false, - resolveWithFullResponse: true, - simple: false - }) - .then((response) => { - expect(response.statusCode).toEqual(302); - }); + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + }); }) .then(() => { expect(sendVerificationEmailCallCount).toBe(1); - return requestp.post({ - uri: 'http://localhost:8378/1/verificationEmailRequest', + return request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', body: { - email: 'user@parse.com' + email: 'user@parse.com', }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true, - resolveWithFullResponse: true, - simple: false // this promise is only rejected if the call itself failed }) - .then((response) => { - expect(response.statusCode).toBe(400); + .then(fail, res => res) + .then(response => { + expect(response.status).toBe(400); expect(sendVerificationEmailCallCount).toBe(1); done(); }); @@ -602,39 +668,40 @@ describe("Email Verification Token Expiration: ", () => { }); it('should not send a new verification email if this user does not exist', done => { - var sendEmailOptions; - var sendVerificationEmailCallCount = 0; - var emailAdapter = { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; sendVerificationEmailCallCount++; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - return requestp.post({ - uri: 'http://localhost:8378/1/verificationEmailRequest', + return request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', body: { - email: 'user@parse.com' + email: 'user@parse.com', }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true, - resolveWithFullResponse: true, - simple: false }) + .then(fail) + .catch(response => response) .then(response => { - expect(response.statusCode).toBe(400); + expect(response.status).toBe(400); expect(sendVerificationEmailCallCount).toBe(0); expect(sendEmailOptions).not.toBeDefined(); done(); @@ -647,42 +714,43 @@ describe("Email Verification Token Expiration: ", () => { }); it('should fail if no email is supplied', done => { - var sendEmailOptions; - var sendVerificationEmailCallCount = 0; - var emailAdapter = { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; sendVerificationEmailCallCount++; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - request.post({ - uri: 'http://localhost:8378/1/verificationEmailRequest', + request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', body: {}, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true, - resolveWithFullResponse: true, - simple: false - }, (err, response) => { - expect(response.statusCode).toBe(400); - expect(response.body.code).toBe(Parse.Error.EMAIL_MISSING); - expect(response.body.error).toBe('you must provide an email'); - expect(sendVerificationEmailCallCount).toBe(0); - expect(sendEmailOptions).not.toBeDefined(); - done(); - }); + }) + .then(fail, response => response) + .then(response => { + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.EMAIL_MISSING); + expect(response.data.error).toBe('you must provide an email'); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + done(); + }); }) .catch(error => { jfail(error); @@ -691,42 +759,45 @@ describe("Email Verification Token Expiration: ", () => { }); it('should fail if email is not a string', done => { - var sendEmailOptions; - var sendVerificationEmailCallCount = 0; - var emailAdapter = { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; sendVerificationEmailCallCount++; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - request.post({ - uri: 'http://localhost:8378/1/verificationEmailRequest', - body: {email: 3}, + request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 3 }, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true, - resolveWithFullResponse: true, - simple: false - }, (err, response) => { - expect(response.statusCode).toBe(400); - expect(response.body.code).toBe(Parse.Error.INVALID_EMAIL_ADDRESS); - expect(response.body.error).toBe('you must provide a valid email string'); - expect(sendVerificationEmailCallCount).toBe(0); - expect(sendEmailOptions).not.toBeDefined(); - done(); - }); + }) + .then(fail, res => res) + .then(response => { + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.INVALID_EMAIL_ADDRESS); + expect(response.data.error).toBe( + 'you must provide a valid email string' + ); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + done(); + }); }) .catch(error => { jfail(error); @@ -735,34 +806,36 @@ describe("Email Verification Token Expiration: ", () => { }); it('client should not see the _email_verify_token_expires_at field', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); user.set('email', 'user@parse.com'); return user.signUp(); }) .then(() => { - - user.fetch() + user + .fetch() .then(() => { expect(user.get('emailVerified')).toEqual(false); - expect(typeof user.get('_email_verify_token_expires_at')).toBe('undefined'); + expect(typeof user.get('_email_verify_token_expires_at')).toBe( + 'undefined' + ); expect(sendEmailOptions).toBeDefined(); done(); }) @@ -770,11 +843,73 @@ describe("Email Verification Token Expiration: ", () => { jfail(error); done(); }); - - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); -}) + it('emailVerified should be set to false after changing from an already verified email', done => { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken') + .then(user => { + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(true); + + user.set('email', 'newEmail@parse.com'); + return user.save(); + }) + .then(() => user.fetch()) + .then(user => { + expect(typeof user).toBe('object'); + expect(user.get('email')).toBe('newEmail@parse.com'); + expect(user.get('emailVerified')).toBe(false); + + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); +}); diff --git a/spec/EnableExpressErrorHandler.spec.js b/spec/EnableExpressErrorHandler.spec.js new file mode 100644 index 0000000000..48b6047092 --- /dev/null +++ b/spec/EnableExpressErrorHandler.spec.js @@ -0,0 +1,63 @@ +const ParseServer = require('../lib/index'); +const express = require('express'); +const request = require('../lib/request'); + +describe('Enable express error handler', () => { + it('should call the default handler in case of error, like updating a non existing object', done => { + const serverUrl = 'http://localhost:12667/parse'; + const appId = 'anOtherTestApp'; + const masterKey = 'anOtherTestMasterKey'; + let server; + + let lastError; + + const parseServer = ParseServer.ParseServer( + Object.assign({}, defaultConfiguration, { + appId: appId, + masterKey: masterKey, + serverURL: serverUrl, + enableExpressErrorHandler: true, + serverStartComplete: () => { + expect(Parse.applicationId).toEqual('anOtherTestApp'); + const app = express(); + app.use('/parse', parseServer); + + server = app.listen(12667); + + app.use(function(err, req, res, next) { + next; + lastError = err; + }); + + request({ + method: 'PUT', + url: serverUrl + '/classes/AnyClass/nonExistingId', + headers: { + 'X-Parse-Application-Id': appId, + 'X-Parse-Master-Key': masterKey, + 'Content-Type': 'application/json', + }, + body: { someField: 'blablabla' }, + }) + .then(() => { + fail('Should throw error'); + }) + .catch(response => { + const reqError = response.data; + expect(reqError).toBeDefined(); + expect(lastError).toBeDefined(); + + expect(lastError.code).toEqual(101); + expect(lastError.message).toEqual('Object not found.'); + + expect(lastError.code).toEqual(reqError.code); + expect(lastError.message).toEqual(reqError.error); + }) + .then(() => { + server.close(done); + }); + }, + }) + ); + }); +}); diff --git a/spec/EnableSingleSchemaCache.spec.js b/spec/EnableSingleSchemaCache.spec.js index 2638c56e46..6e8e5a2305 100644 --- a/spec/EnableSingleSchemaCache.spec.js +++ b/spec/EnableSingleSchemaCache.spec.js @@ -1,46 +1,55 @@ -const auth = require('../src/Auth'); -const Config = require('../src/Config'); -const rest = require('../src/rest'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); describe('Enable single schema cache', () => { - beforeEach((done) => { + beforeEach(done => { reconfigureServer({ enableSingleSchemaCache: true, - schemaCacheTTL: 30000 + schemaCacheTTL: 30000, }).then(() => { done(); }); }); - it('can perform multiple create and query operations', (done) => { + it('can perform multiple create and query operations', done => { let config = fakeRequestForConfig(); let nobody = auth.nobody(config); - rest.create(config, nobody, 'Foo', {type: 1}).then(() => { - config = fakeRequestForConfig(); - nobody = auth.nobody(config); - return rest.create(config, nobody, 'Foo', {type: 2}); - }).then(() => { - config = fakeRequestForConfig(); - nobody = auth.nobody(config); - return rest.create(config, nobody, 'Bar'); - }).then(() => { - config = fakeRequestForConfig(); - nobody = auth.nobody(config); - return rest.find(config, nobody, 'Bar', {type: 1}); - }).then(() => { - fail('Should throw error'); - done(); - }, (error) => { - config = fakeRequestForConfig(); - nobody = auth.nobody(config); - expect(error).toBeDefined(); - return rest.find(config, nobody, 'Foo', {type: 1}); - }).then((response) => { - config = fakeRequestForConfig(); - nobody = auth.nobody(config); - expect(response.results.length).toEqual(1); - done(); - }); + rest + .create(config, nobody, 'Foo', { type: 1 }) + .then(() => { + config = fakeRequestForConfig(); + nobody = auth.nobody(config); + return rest.create(config, nobody, 'Foo', { type: 2 }); + }) + .then(() => { + config = fakeRequestForConfig(); + nobody = auth.nobody(config); + return rest.create(config, nobody, 'Bar'); + }) + .then(() => { + config = fakeRequestForConfig(); + nobody = auth.nobody(config); + return rest.find(config, nobody, 'Bar', { type: 1 }); + }) + .then( + () => { + fail('Should throw error'); + done(); + }, + error => { + config = fakeRequestForConfig(); + nobody = auth.nobody(config); + expect(error).toBeDefined(); + return rest.find(config, nobody, 'Foo', { type: 1 }); + } + ) + .then(response => { + config = fakeRequestForConfig(); + nobody = auth.nobody(config); + expect(response.results.length).toEqual(1); + done(); + }); }); }); diff --git a/spec/EventEmitterPubSub.spec.js b/spec/EventEmitterPubSub.spec.js index 546206ffc0..9117662b20 100644 --- a/spec/EventEmitterPubSub.spec.js +++ b/spec/EventEmitterPubSub.spec.js @@ -1,12 +1,13 @@ -var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; +const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; describe('EventEmitterPubSub', function() { it('can publish and subscribe', function() { - var publisher = EventEmitterPubSub.createPublisher(); - var subscriber = EventEmitterPubSub.createSubscriber(); + const publisher = EventEmitterPubSub.createPublisher(); + const subscriber = EventEmitterPubSub.createSubscriber(); subscriber.subscribe('testChannel'); // Register mock checked for subscriber - var isChecked = false; + let isChecked = false; subscriber.on('message', function(channel, message) { isChecked = true; expect(channel).toBe('testChannel'); @@ -19,12 +20,12 @@ describe('EventEmitterPubSub', function() { }); it('can unsubscribe', function() { - var publisher = EventEmitterPubSub.createPublisher(); - var subscriber = EventEmitterPubSub.createSubscriber(); + const publisher = EventEmitterPubSub.createPublisher(); + const subscriber = EventEmitterPubSub.createSubscriber(); subscriber.subscribe('testChannel'); subscriber.unsubscribe('testChannel'); // Register mock checked for subscriber - var isCalled = false; + let isCalled = false; subscriber.on('message', function() { isCalled = true; }); @@ -35,7 +36,7 @@ describe('EventEmitterPubSub', function() { }); it('can unsubscribe not subscribing channel', function() { - var subscriber = EventEmitterPubSub.createSubscriber(); + const subscriber = EventEmitterPubSub.createSubscriber(); // Make sure subscriber does not throw exception subscriber.unsubscribe('testChannel'); diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 4bade0d91f..aef6448182 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -1,39 +1,48 @@ -const LoggerController = require('../src/Controllers/LoggerController').LoggerController; -const WinstonLoggerAdapter = require('../src/Adapters/Logger/WinstonLoggerAdapter').WinstonLoggerAdapter; -const GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; -const Config = require("../src/Config"); -const FilesController = require('../src/Controllers/FilesController').default; +const LoggerController = require('../lib/Controllers/LoggerController') + .LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const GridStoreAdapter = require('../lib/Adapters/Files/GridStoreAdapter') + .GridStoreAdapter; +const Config = require('../lib/Config'); +const FilesController = require('../lib/Controllers/FilesController').default; const mockAdapter = { createFile: () => { - return Parse.Promise.reject(new Error('it failed')); + return Promise.reject(new Error('it failed with xyz')); }, - deleteFile: () => { }, - getFileData: () => { }, - getFileLocation: () => 'xyz' -} + deleteFile: () => {}, + getFileData: () => {}, + getFileLocation: () => 'xyz', + validateFilename: () => { + return null; + }, +}; // Small additional tests to improve overall coverage -describe("FilesController",() =>{ - it("should properly expand objects", (done) => { - - var config = Config.get(Parse.applicationId); - var gridStoreAdapter = new GridStoreAdapter('mongodb://localhost:27017/parse'); - var filesController = new FilesController(gridStoreAdapter) - var result = filesController.expandFilesInObject(config, function(){}); +describe('FilesController', () => { + it('should properly expand objects', done => { + const config = Config.get(Parse.applicationId); + const gridStoreAdapter = new GridFSBucketAdapter( + 'mongodb://localhost:27017/parse' + ); + const filesController = new FilesController(gridStoreAdapter); + const result = filesController.expandFilesInObject(config, function() {}); expect(result).toBeUndefined(); - var fullFile = { + const fullFile = { type: '__type', - url: "http://an.url" - } + url: 'http://an.url', + }; - var anObject = { - aFile: fullFile - } + const anObject = { + aFile: fullFile, + }; filesController.expandFilesInObject(config, anObject); - expect(anObject.aFile.url).toEqual("http://an.url"); + expect(anObject.aFile.url).toEqual('http://an.url'); done(); }); @@ -42,24 +51,107 @@ describe("FilesController",() =>{ const logController = new LoggerController(new WinstonLoggerAdapter()); reconfigureServer({ filesAdapter: mockAdapter }) - .then(() => new Promise(resolve => setTimeout(resolve, 1000))) - .then(() => new Parse.File("yolo.txt", [1,2,3], "text/plain").save()) + .then(() => new Parse.File('yolo.txt', [1, 2, 3], 'text/plain').save()) .then( () => done.fail('should not succeed'), - () => setImmediate(() => Parse.Promise.as('done')) + () => setImmediate(() => Promise.resolve('done')) ) - .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) - .then((logs) => { + .then(() => new Promise(resolve => setTimeout(resolve, 200))) + .then(() => + logController.getLogs({ from: Date.now() - 1000, size: 1000 }) + ) + .then(logs => { // we get two logs here: 1. the source of the failure to save the file // and 2 the message that will be sent back to the client. - const log1 = logs.pop(); + + const log1 = logs.find( + x => x.message === 'Error creating a file: it failed with xyz' + ); expect(log1.level).toBe('error'); - expect(log1.message).toBe('it failed'); - const log2 = logs.pop(); + + const log2 = logs.find( + x => x.message === 'Could not store file: yolo.txt.' + ); expect(log2.level).toBe('error'); expect(log2.code).toBe(130); - expect(log2.message).toBe('Could not store file.'); + done(); }); }); + + it('should create a parse error when a string is returned', done => { + const mock2 = mockAdapter; + mock2.validateFilename = () => { + return 'Bad file! No biscuit!'; + }; + const filesController = new FilesController(mockAdapter); + const error = filesController.validateFilename(); + expect(typeof error).toBe('object'); + expect(error.message.indexOf('biscuit')).toBe(13); + expect(error.code).toBe(Parse.Error.INVALID_FILE_NAME); + done(); + }); + + it('should add a unique hash to the file name when the preserveFileName option is false', done => { + const config = Config.get(Parse.applicationId); + const gridStoreAdapter = new GridFSBucketAdapter( + 'mongodb://localhost:27017/parse' + ); + spyOn(gridStoreAdapter, 'createFile'); + gridStoreAdapter.createFile.and.returnValue(Promise.resolve()); + const fileName = 'randomFileName.pdf'; + const regexEscapedFileName = fileName.replace(/\./g, '\\$&'); + const filesController = new FilesController(gridStoreAdapter, null, { + preserveFileName: false, + }); + + filesController.createFile(config, fileName); + + expect(gridStoreAdapter.createFile).toHaveBeenCalledTimes(1); + expect(gridStoreAdapter.createFile.calls.mostRecent().args[0]).toMatch( + `^.{32}_${regexEscapedFileName}$` + ); + + done(); + }); + + it('should not add a unique hash to the file name when the preserveFileName option is true', done => { + const config = Config.get(Parse.applicationId); + const gridStoreAdapter = new GridFSBucketAdapter( + 'mongodb://localhost:27017/parse' + ); + spyOn(gridStoreAdapter, 'createFile'); + gridStoreAdapter.createFile.and.returnValue(Promise.resolve()); + const fileName = 'randomFileName.pdf'; + const filesController = new FilesController(gridStoreAdapter, null, { + preserveFileName: true, + }); + + filesController.createFile(config, fileName); + + expect(gridStoreAdapter.createFile).toHaveBeenCalledTimes(1); + expect(gridStoreAdapter.createFile.calls.mostRecent().args[0]).toEqual( + fileName + ); + + done(); + }); + + it('should reject slashes in file names', done => { + const gridStoreAdapter = new GridFSBucketAdapter( + 'mongodb://localhost:27017/parse' + ); + const fileName = 'foo/randomFileName.pdf'; + expect(gridStoreAdapter.validateFilename(fileName)).not.toBe(null); + done(); + }); + + it('should also reject slashes in file names', done => { + const gridStoreAdapter = new GridStoreAdapter( + 'mongodb://localhost:27017/parse' + ); + const fileName = 'foo/randomFileName.pdf'; + expect(gridStoreAdapter.validateFilename(fileName)).not.toBe(null); + done(); + }); }); diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js new file mode 100644 index 0000000000..d4b1911b52 --- /dev/null +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -0,0 +1,80 @@ +const GridStoreAdapter = require('../lib/Adapters/Files/GridStoreAdapter') + .GridStoreAdapter; +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const { randomString } = require('../lib/cryptoUtils'); +const databaseURI = 'mongodb://localhost:27017/parse'; + +async function expectMissingFile(gfsAdapter, name) { + try { + await gfsAdapter.getFileData(name); + fail('should have thrown'); + } catch (e) { + expect(e.message).toEqual('FileNotFound: file myFileName was not found'); + } +} + +describe('GridFSBucket and GridStore interop', () => { + beforeEach(async () => { + const gsAdapter = new GridStoreAdapter(databaseURI); + const db = await gsAdapter._connect(); + await db.dropDatabase(); + }); + + it('a file created in GridStore should be available in GridFS', async () => { + const gsAdapter = new GridStoreAdapter(databaseURI); + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await expectMissingFile(gfsAdapter, 'myFileName'); + const originalString = 'abcdefghi'; + await gsAdapter.createFile('myFileName', originalString); + const gsResult = await gsAdapter.getFileData('myFileName'); + expect(gsResult.toString('utf8')).toBe(originalString); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(originalString); + }); + + it('properly fetches a large file from GridFS', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const twoMegabytesFile = randomString(2048 * 1024); + await gfsAdapter.createFile('myFileName', twoMegabytesFile); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile); + }); + + it('properly deletes a file from GridFS', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await gfsAdapter.createFile('myFileName', 'a simple file'); + await gfsAdapter.deleteFile('myFileName'); + await expectMissingFile(gfsAdapter, 'myFileName'); + }, 1000000); + + it('properly overrides files', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await gfsAdapter.createFile('myFileName', 'a simple file'); + await gfsAdapter.createFile('myFileName', 'an overrided simple file'); + const data = await gfsAdapter.getFileData('myFileName'); + expect(data.toString('utf8')).toBe('an overrided simple file'); + const bucket = await gfsAdapter._getBucket(); + const documents = await bucket.find({ filename: 'myFileName' }).toArray(); + expect(documents.length).toBe(2); + await gfsAdapter.deleteFile('myFileName'); + await expectMissingFile(gfsAdapter, 'myFileName'); + }); + + it('handleShutdown, close connection', async () => { + const databaseURI = 'mongodb://localhost:27017/parse'; + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + + const db = await gfsAdapter._connect(); + const status = await db.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + + await gfsAdapter.handleShutdown(); + try { + await db.admin().serverStatus(); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toEqual('topology was destroyed'); + } + }); +}); diff --git a/spec/GridStoreAdapter.js b/spec/GridStoreAdapter.js deleted file mode 100644 index e27d59fcf6..0000000000 --- a/spec/GridStoreAdapter.js +++ /dev/null @@ -1,87 +0,0 @@ -var MongoClient = require("mongodb").MongoClient; -var GridStore = require("mongodb").GridStore; - -var GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; -var Config = require("../src/Config"); -var FilesController = require('../src/Controllers/FilesController').default; - - -// Small additional tests to improve overall coverage -describe_only_db('mongo')("GridStoreAdapter",() =>{ - it("should properly instanciate the GridStore when deleting a file", (done) => { - - var databaseURI = 'mongodb://localhost:27017/parse'; - var config = Config.get(Parse.applicationId); - var gridStoreAdapter = new GridStoreAdapter(databaseURI); - var filesController = new FilesController(gridStoreAdapter); - - // save original unlink before redefinition - var originalUnlink = GridStore.prototype.unlink; - - var gridStoreMode; - - // new unlink method that will capture the mode in which GridStore was opened - GridStore.prototype.unlink = function() { - - // restore original unlink during first call - GridStore.prototype.unlink = originalUnlink; - - gridStoreMode = this.mode; - - return originalUnlink.call(this); - }; - - - filesController.createFile(config, 'myFilename.txt', 'my file content', 'text/plain') - .then(myFile => { - - return MongoClient.connect(databaseURI) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.files').count().then(count => { - expect(count).toEqual(1); - return database; - }); - }) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.chunks').count().then(count => { - expect(count).toEqual(1); - return database.close(); - }); - }) - .then(() => { - return filesController.deleteFile(config, myFile.name); - }); - }) - .then(() => { - return MongoClient.connect(databaseURI) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.files').count().then(count => { - expect(count).toEqual(0); - return database; - }); - }) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.chunks').count().then(count => { - expect(count).toEqual(0); - return database.close(); - }); - }); - }) - .then(() => { - // Verify that gridStore was opened in read only mode - expect(gridStoreMode).toEqual('r'); - - done(); - }) - .catch(fail); - - }) -}); diff --git a/spec/GridStoreAdapter.spec.js b/spec/GridStoreAdapter.spec.js new file mode 100644 index 0000000000..ba6288b104 --- /dev/null +++ b/spec/GridStoreAdapter.spec.js @@ -0,0 +1,116 @@ +const MongoClient = require('mongodb').MongoClient; +const GridStore = require('mongodb').GridStore; + +const GridStoreAdapter = require('../lib/Adapters/Files/GridStoreAdapter') + .GridStoreAdapter; +const Config = require('../lib/Config'); +const FilesController = require('../lib/Controllers/FilesController').default; + +// Small additional tests to improve overall coverage +describe_only_db('mongo')('GridStoreAdapter', () => { + it('should properly instanciate the GridStore when deleting a file', async done => { + const databaseURI = 'mongodb://localhost:27017/parse'; + const config = Config.get(Parse.applicationId); + const gridStoreAdapter = new GridStoreAdapter(databaseURI); + const db = await gridStoreAdapter._connect(); + await db.dropDatabase(); + const filesController = new FilesController( + gridStoreAdapter, + Parse.applicationId, + {} + ); + + // save original unlink before redefinition + const originalUnlink = GridStore.prototype.unlink; + + let gridStoreMode; + + // new unlink method that will capture the mode in which GridStore was opened + GridStore.prototype.unlink = function() { + // restore original unlink during first call + GridStore.prototype.unlink = originalUnlink; + + gridStoreMode = this.mode; + + return originalUnlink.call(this); + }; + + filesController + .createFile(config, 'myFilename.txt', 'my file content', 'text/plain') + .then(myFile => { + return MongoClient.connect(databaseURI) + .then(client => { + const database = client.db(client.s.options.dbName); + // Verify the existance of the fs.files document + return database + .collection('fs.files') + .count() + .then(count => { + expect(count).toEqual(1); + return { database, client }; + }); + }) + .then(({ database, client }) => { + // Verify the existance of the fs.files document + return database + .collection('fs.chunks') + .count() + .then(count => { + expect(count).toEqual(1); + return client.close(); + }); + }) + .then(() => { + return filesController.deleteFile(config, myFile.name); + }); + }) + .then(() => { + return MongoClient.connect(databaseURI) + .then(client => { + const database = client.db(client.s.options.dbName); + // Verify the existance of the fs.files document + return database + .collection('fs.files') + .count() + .then(count => { + expect(count).toEqual(0); + return { database, client }; + }); + }) + .then(({ database, client }) => { + // Verify the existance of the fs.files document + return database + .collection('fs.chunks') + .count() + .then(count => { + expect(count).toEqual(0); + return client.close(); + }); + }); + }) + .then(() => { + // Verify that gridStore was opened in read only mode + expect(gridStoreMode).toEqual('r'); + + done(); + }) + .catch(fail); + }); + + it('handleShutdown, close connection', async () => { + const databaseURI = 'mongodb://localhost:27017/parse'; + const gridStoreAdapter = new GridStoreAdapter(databaseURI); + + const db = await gridStoreAdapter._connect(); + const status = await db.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + + await gridStoreAdapter.handleShutdown(); + try { + await db.admin().serverStatus(); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toEqual('topology was destroyed'); + } + }); +}); diff --git a/spec/HTTPRequest.spec.js b/spec/HTTPRequest.spec.js index 061b616f98..2e1921b305 100644 --- a/spec/HTTPRequest.spec.js +++ b/spec/HTTPRequest.spec.js @@ -1,116 +1,97 @@ 'use strict'; -var httpRequest = require("../src/cloud-code/httpRequest"), - HTTPResponse = require('../src/cloud-code/HTTPResponse').default, +const httpRequest = require('../lib/cloud-code/httpRequest'), + HTTPResponse = require('../lib/cloud-code/HTTPResponse').default, bodyParser = require('body-parser'), - express = require("express"); + express = require('express'); -var port = 13371; -var httpRequestServer = "http://localhost:" + port; +const port = 13371; +const httpRequestServer = 'http://localhost:' + port; -var app = express(); -app.use(bodyParser.json({ 'type': '*/*' })); -app.get("/hello", function(req, res){ - res.json({response: "OK"}); -}); +function startServer(done) { + const app = express(); + app.use(bodyParser.json({ type: '*/*' })); + app.get('/hello', function(req, res) { + res.json({ response: 'OK' }); + }); -app.get("/404", function(req, res){ - res.status(404); - res.send("NO"); -}); + app.get('/404', function(req, res) { + res.status(404); + res.send('NO'); + }); -app.get("/301", function(req, res){ - res.status(301); - res.location("/hello"); - res.send(); -}); + app.get('/301', function(req, res) { + res.status(301); + res.location('/hello'); + res.send(); + }); -app.post('/echo', function(req, res){ - res.json(req.body); -}); + app.post('/echo', function(req, res) { + res.json(req.body); + }); -app.get('/qs', function(req, res){ - res.json(req.query); -}); + app.get('/qs', function(req, res) { + res.json(req.query); + }); -app.listen(13371); + return app.listen(13371, undefined, done); +} +describe('httpRequest', () => { + let server; + beforeAll(done => { + server = startServer(done); + }); -describe("httpRequest", () => { - it("should do /hello", (done) => { - httpRequest({ - url: httpRequestServer + "/hello" - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + afterAll(done => { + server.close(done); }); - it("should do /hello with callback and promises", (done) => { - var calls = 0; + it('should do /hello', done => { httpRequest({ - url: httpRequestServer + "/hello", - success: function() { calls++; }, - error: function() { calls++; } - }).then(function(httpResponse){ - expect(calls).toBe(1); + url: httpRequestServer + '/hello', + }).then(function(httpResponse) { expect(httpResponse.status).toBe(200); expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); + expect(httpResponse.data.response).toEqual('OK'); done(); - }) + }, done.fail); }); - it("should do not follow redirects by default", (done) => { - + it('should do not follow redirects by default', done => { httpRequest({ - url: httpRequestServer + "/301" - }).then(function(httpResponse){ + url: httpRequestServer + '/301', + }).then(function(httpResponse) { expect(httpResponse.status).toBe(301); done(); - }, function(){ - fail("should not fail"); - done(); - }) + }, done.fail); }); - it("should follow redirects when set", (done) => { - + it('should follow redirects when set', done => { httpRequest({ - url: httpRequestServer + "/301", - followRedirects: true - }).then(function(httpResponse){ + url: httpRequestServer + '/301', + followRedirects: true, + }).then(function(httpResponse) { expect(httpResponse.status).toBe(200); expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); + expect(httpResponse.data.response).toEqual('OK'); done(); - }) + }, done.fail); }); - it("should fail on 404", (done) => { - var calls = 0; + it('should fail on 404', done => { + let calls = 0; httpRequest({ - url: httpRequestServer + "/404", - success: function() { + url: httpRequestServer + '/404', + }).then( + function() { calls++; - fail("should not succeed"); + fail('should not succeed'); done(); }, - error: function(httpResponse) { + function(httpResponse) { calls++; expect(calls).toBe(1); expect(httpResponse.status).toBe(404); @@ -119,131 +100,116 @@ describe("httpRequest", () => { expect(httpResponse.data).toBe(undefined); done(); } - }); - }) - - it("should fail on 404", (done) => { - httpRequest({ - url: httpRequestServer + "/404", - }).then(function(){ - fail("should not succeed"); - done(); - }, function(httpResponse){ - expect(httpResponse.status).toBe(404); - expect(httpResponse.buffer).toEqual(new Buffer('NO')); - expect(httpResponse.text).toEqual('NO'); - expect(httpResponse.data).toBe(undefined); - done(); - }) - }) + ); + }); - it("should post on echo", (done) => { - var calls = 0; + it('should post on echo', done => { httpRequest({ - method: "POST", - url: httpRequestServer + "/echo", + method: 'POST', + url: httpRequestServer + '/echo', body: { - foo: "bar" + foo: 'bar', }, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - success: function() { calls++; }, - error: function() { calls++; } - }).then(function(httpResponse){ - expect(calls).toBe(1); - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar"}); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + }).then( + function(httpResponse) { + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); + done(); + }, + function() { + fail('should not fail'); + done(); + } + ); }); - it("should encode a query string body by default", (done) => { + it('should encode a query string body by default', done => { const options = { - body: {"foo": "bar"}, - } + body: { foo: 'bar' }, + }; const result = httpRequest.encodeBody(options); expect(result.body).toEqual('foo=bar'); - expect(result.headers['Content-Type']).toEqual('application/x-www-form-urlencoded'); + expect(result.headers['Content-Type']).toEqual( + 'application/x-www-form-urlencoded' + ); done(); + }); - }) - - it("should encode a JSON body", (done) => { + it('should encode a JSON body', done => { const options = { - body: {"foo": "bar"}, - headers: {'Content-Type': 'application/json'} - } + body: { foo: 'bar' }, + headers: { 'Content-Type': 'application/json' }, + }; const result = httpRequest.encodeBody(options); expect(result.body).toEqual('{"foo":"bar"}'); done(); - - }) - it("should encode a www-form body", (done) => { + }); + it('should encode a www-form body', done => { const options = { - body: {"foo": "bar", "bar": "baz"}, - headers: {'cOntent-tYpe': 'application/x-www-form-urlencoded'} - } + body: { foo: 'bar', bar: 'baz' }, + headers: { 'cOntent-tYpe': 'application/x-www-form-urlencoded' }, + }; const result = httpRequest.encodeBody(options); - expect(result.body).toEqual("foo=bar&bar=baz"); + expect(result.body).toEqual('foo=bar&bar=baz'); done(); }); - it("should not encode a wrong content type", (done) => { + it('should not encode a wrong content type', done => { const options = { - body:{"foo": "bar", "bar": "baz"}, - headers: {'cOntent-tYpe': 'mime/jpeg'} - } + body: { foo: 'bar', bar: 'baz' }, + headers: { 'cOntent-tYpe': 'mime/jpeg' }, + }; const result = httpRequest.encodeBody(options); - expect(result.body).toEqual({"foo": "bar", "bar": "baz"}); + expect(result.body).toEqual({ foo: 'bar', bar: 'baz' }); done(); }); - it("should fail gracefully", (done) => { + it('should fail gracefully', done => { httpRequest({ - url: "http://not a good url", - success: function() { - fail("should not succeed"); - done(); - }, - error: function(error) { - expect(error).not.toBeUndefined(); - expect(error).not.toBeNull(); - done(); - } + url: 'http://not a good url', + }).then(done.fail, function(error) { + expect(error).not.toBeUndefined(); + expect(error).not.toBeNull(); + done(); }); }); - it("should params object to query string", (done) => { + it('should params object to query string', done => { httpRequest({ - url: httpRequestServer + "/qs", + url: httpRequestServer + '/qs', params: { - foo: "bar" + foo: 'bar', + }, + }).then( + function(httpResponse) { + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); + done(); + }, + function() { + fail('should not fail'); + done(); } - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar"}); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + ); }); - it("should params string to query string", (done) => { + it('should params string to query string', done => { httpRequest({ - url: httpRequestServer + "/qs", - params: "foo=bar&foo2=bar2" - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar", foo2: 'bar2'}); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + url: httpRequestServer + '/qs', + params: 'foo=bar&foo2=bar2', + }).then( + function(httpResponse) { + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar', foo2: 'bar2' }); + done(); + }, + function() { + fail('should not fail'); + done(); + } + ); }); it('should not crash with undefined body', () => { @@ -268,17 +234,17 @@ describe("httpRequest", () => { }); it('serialized httpResponse correctly with body object', () => { - const httpResponse = new HTTPResponse({}, {foo: "bar"}); + const httpResponse = new HTTPResponse({}, { foo: 'bar' }); Parse._encode(httpResponse); const serialized = JSON.stringify(httpResponse); const result = JSON.parse(serialized); expect(httpResponse.text).toEqual('{"foo":"bar"}'); - expect(httpResponse.data).toEqual({foo: 'bar'}); - expect(httpResponse.body).toEqual({foo: 'bar'}); + expect(httpResponse.data).toEqual({ foo: 'bar' }); + expect(httpResponse.body).toEqual({ foo: 'bar' }); expect(result.text).toEqual('{"foo":"bar"}'); - expect(result.data).toEqual({foo: 'bar'}); + expect(result.data).toEqual({ foo: 'bar' }); expect(result.body).toEqual(undefined); }); @@ -299,15 +265,17 @@ describe("httpRequest", () => { const serialized = JSON.stringify(httpResponse); const result = JSON.parse(serialized); expect(result.text).toEqual('{"foo":"bar"}'); - expect(result.data).toEqual({foo: 'bar'}); + expect(result.data).toEqual({ foo: 'bar' }); }); it('serialized httpResponse with Parse._encode should be allright', () => { const json = '{"foo":"bar"}'; const httpResponse = new HTTPResponse({}, new Buffer(json)); const encoded = Parse._encode(httpResponse); - let foundData, foundText, foundBody = false; - for(var key in encoded) { + let foundData, + foundText, + foundBody = false; + for (const key in encoded) { if (key == 'data') { foundData = true; } @@ -322,5 +290,4 @@ describe("httpRequest", () => { expect(foundText).toBe(true); expect(foundBody).toBe(false); }); - }); diff --git a/spec/InMemoryCache.spec.js b/spec/InMemoryCache.spec.js index 17c72d29ab..d3aeeb03be 100644 --- a/spec/InMemoryCache.spec.js +++ b/spec/InMemoryCache.spec.js @@ -1,42 +1,40 @@ -const InMemoryCache = require('../src/Adapters/Cache/InMemoryCache').default; - +const InMemoryCache = require('../lib/Adapters/Cache/InMemoryCache').default; describe('InMemoryCache', function() { - var BASE_TTL = { - ttl: 100 + const BASE_TTL = { + ttl: 100, }; - var NO_EXPIRE_TTL = { - ttl: NaN + const NO_EXPIRE_TTL = { + ttl: NaN, }; - var KEY = 'hello'; - var KEY_2 = KEY + '_2'; - - var VALUE = 'world'; + const KEY = 'hello'; + const KEY_2 = KEY + '_2'; + const VALUE = 'world'; function wait(sleep) { return new Promise(function(resolve) { setTimeout(resolve, sleep); - }) + }); } - it('should destroy a expire items in the cache', (done) => { - var cache = new InMemoryCache(BASE_TTL); + it('should destroy a expire items in the cache', done => { + const cache = new InMemoryCache(BASE_TTL); cache.put(KEY, VALUE); - var value = cache.get(KEY); + let value = cache.get(KEY); expect(value).toEqual(VALUE); - wait(BASE_TTL.ttl * 2).then(() => { - value = cache.get(KEY) + wait(BASE_TTL.ttl * 10).then(() => { + value = cache.get(KEY); expect(value).toEqual(null); done(); }); }); - it('should delete items', (done) => { - var cache = new InMemoryCache(NO_EXPIRE_TTL); + it('should delete items', done => { + const cache = new InMemoryCache(NO_EXPIRE_TTL); cache.put(KEY, VALUE); cache.put(KEY_2, VALUE); expect(cache.get(KEY)).toEqual(VALUE); @@ -52,8 +50,8 @@ describe('InMemoryCache', function() { done(); }); - it('should clear all items', (done) => { - var cache = new InMemoryCache(NO_EXPIRE_TTL); + it('should clear all items', done => { + const cache = new InMemoryCache(NO_EXPIRE_TTL); cache.put(KEY, VALUE); cache.put(KEY_2, VALUE); @@ -67,8 +65,7 @@ describe('InMemoryCache', function() { }); it('should deafult TTL to 5 seconds', () => { - var cache = new InMemoryCache({}); + const cache = new InMemoryCache({}); expect(cache.ttl).toEqual(5 * 1000); }); - }); diff --git a/spec/InMemoryCacheAdapter.spec.js b/spec/InMemoryCacheAdapter.spec.js index 444beed8e5..e03527822e 100644 --- a/spec/InMemoryCacheAdapter.spec.js +++ b/spec/InMemoryCacheAdapter.spec.js @@ -1,18 +1,19 @@ -var InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').default; +const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') + .default; describe('InMemoryCacheAdapter', function() { - var KEY = 'hello'; - var VALUE = 'world'; + const KEY = 'hello'; + const VALUE = 'world'; function wait(sleep) { return new Promise(function(resolve) { setTimeout(resolve, sleep); - }) + }); } - it('should expose promisifyed methods', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: NaN + it('should expose promisifyed methods', done => { + const cache = new InMemoryCacheAdapter({ + ttl: NaN, }); // Verify all methods return promises. @@ -20,38 +21,39 @@ describe('InMemoryCacheAdapter', function() { cache.put(KEY, VALUE), cache.del(KEY), cache.get(KEY), - cache.clear() + cache.clear(), ]).then(() => { done(); }); }); - it('should get/set/clear', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: NaN + it('should get/set/clear', done => { + const cache = new InMemoryCacheAdapter({ + ttl: NaN, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) .then(() => cache.clear()) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); }); - it('should expire after ttl', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: 10 + it('should expire after ttl', done => { + const cache = new InMemoryCacheAdapter({ + ttl: 10, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) .then(wait.bind(null, 50)) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); - }) - + }); }); diff --git a/spec/InstallationsRouter.spec.js b/spec/InstallationsRouter.spec.js index 50ee19643f..91935e645a 100644 --- a/spec/InstallationsRouter.spec.js +++ b/spec/InstallationsRouter.spec.js @@ -1,148 +1,204 @@ -var auth = require('../src/Auth'); -var Config = require('../src/Config'); -var rest = require('../src/rest'); -var InstallationsRouter = require('../src/Routers/InstallationsRouter').InstallationsRouter; +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const InstallationsRouter = require('../lib/Routers/InstallationsRouter') + .InstallationsRouter; describe('InstallationsRouter', () => { - it('uses find condition from request.body', (done) => { - var config = Config.get('test'); - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('uses find condition from request.body', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: { where: { - deviceType: 'android' - } + deviceType: 'android', + }, }, query: {}, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + const router = new InstallationsRouter(); + rest + .create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ) + .then(() => { + return rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ); + }) .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { return router.handleFind(request); - }).then((res) => { - var results = res.response.results; + }) + .then(res => { + const results = res.response.results; expect(results.length).toEqual(1); done(); - }).catch((err) => { + }) + .catch(err => { fail(JSON.stringify(err)); done(); }); }); - it('uses find condition from request.query', (done) => { - var config = Config.get('test'); - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('uses find condition from request.query', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { where: { - deviceType: 'android' - } + deviceType: 'android', + }, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + const router = new InstallationsRouter(); + rest + .create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ) + .then(() => { + return rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ); + }) .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { return router.handleFind(request); - }).then((res) => { - var results = res.response.results; + }) + .then(res => { + const results = res.response.results; expect(results.length).toEqual(1); done(); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); }); }); - it('query installations with limit = 0', (done) => { - var config = Config.get('test'); - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('query installations with limit = 0', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - limit: 0 + limit: 0, }, - info: {} + info: {}, }; Config.get('test'); - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + const router = new InstallationsRouter(); + rest + .create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ) + .then(() => { + return rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ); + }) .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { return router.handleFind(request); - }).then((res) => { - var response = res.response; + }) + .then(res => { + const response = res.response; expect(response.results.length).toEqual(0); done(); - }).catch((err) => { + }) + .catch(err => { fail(JSON.stringify(err)); done(); }); }); - it('query installations with count = 1', done => { - var config = Config.get('test'); - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it_exclude_dbs(['postgres'])('query installations with count = 1', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - count: 1 + count: 1, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest)) + const router = new InstallationsRouter(); + rest + .create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ) + .then(() => + rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ) + ) .then(() => router.handleFind(request)) - .then((res) => { - var response = res.response; + .then(res => { + const response = res.response; expect(response.results.length).toEqual(2); expect(response.count).toEqual(2); done(); @@ -150,44 +206,108 @@ describe('InstallationsRouter', () => { .catch(error => { fail(JSON.stringify(error)); done(); - }) + }); }); - it('query installations with limit = 0 and count = 1', (done) => { - var config = Config.get('test'); - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it_only_db('postgres')('query installations with count = 1', async () => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - limit: 0, - count: 1 + count: 1, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).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(); - }); + const router = new InstallationsRouter(); + await rest.create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ); + await rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ); + let res = await router.handleFind(request); + let response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(0); // estimate count is zero + + const pgAdapter = config.database.adapter; + await pgAdapter.updateEstimatedCount('_Installation'); + + res = await router.handleFind(request); + response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); }); + + it_exclude_dbs(['postgres'])( + 'query installations with limit = 0 and count = 1', + done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', + }; + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0, + count: 1, + }, + info: {}, + }; + + const router = new InstallationsRouter(); + rest + .create( + config, + auth.nobody(config), + '_Installation', + androidDeviceRequest + ) + .then(() => { + return rest.create( + config, + auth.nobody(config), + '_Installation', + iosDeviceRequest + ); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + } + ); }); diff --git a/spec/JobSchedule.spec.js b/spec/JobSchedule.spec.js index e283979477..feb37ec6a2 100644 --- a/spec/JobSchedule.spec.js +++ b/spec/JobSchedule.spec.js @@ -1,198 +1,286 @@ -const rp = require('request-promise'); +const request = require('../lib/request'); + const defaultHeaders = { 'X-Parse-Application-Id': 'test', - 'X-Parse-Rest-API-Key': 'rest' -} + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', +}; const masterKeyHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-Rest-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' -} + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; const defaultOptions = { headers: defaultHeaders, - json: true -} + json: true, +}; const masterKeyOptions = { headers: masterKeyHeaders, - json: true -} + json: true, +}; describe('JobSchedule', () => { - it('should create _JobSchedule with masterKey', (done) => { + it('should create _JobSchedule with masterKey', done => { const jobSchedule = new Parse.Object('_JobSchedule'); jobSchedule.set({ - 'jobName': 'MY Cool Job' + jobName: 'MY Cool Job', }); - jobSchedule.save(null, {useMasterKey: true}).then(() => { - done(); - }) + jobSchedule + .save(null, { useMasterKey: true }) + .then(() => { + done(); + }) .catch(done.fail); }); - it('should fail creating _JobSchedule without masterKey', (done) => { + it('should fail creating _JobSchedule without masterKey', done => { const jobSchedule = new Parse.Object('_JobSchedule'); jobSchedule.set({ - 'jobName': 'SomeJob' + jobName: 'SomeJob', }); - jobSchedule.save(null).then(done.fail) - .catch(done); + jobSchedule + .save(null) + .then(done.fail) + .catch(() => done()); }); - it('should reject access when not using masterKey (/jobs)', (done) => { - rp.get(Parse.serverURL + '/cloud_code/jobs', defaultOptions).then(done.fail, done); + it('should reject access when not using masterKey (/jobs)', done => { + request( + Object.assign( + { url: Parse.serverURL + '/cloud_code/jobs' }, + defaultOptions + ) + ).then(done.fail, () => done()); }); - it('should reject access when not using masterKey (/jobs/data)', (done) => { - rp.get(Parse.serverURL + '/cloud_code/jobs/data', defaultOptions).then(done.fail, done); + it('should reject access when not using masterKey (/jobs/data)', done => { + request( + Object.assign( + { url: Parse.serverURL + '/cloud_code/jobs/data' }, + defaultOptions + ) + ).then(done.fail, () => done()); }); - it('should reject access when not using masterKey (PUT /jobs/id)', (done) => { - rp.put(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done); + it('should reject access when not using masterKey (PUT /jobs/id)', done => { + request( + Object.assign( + { method: 'PUT', url: Parse.serverURL + '/cloud_code/jobs/jobId' }, + defaultOptions + ) + ).then(done.fail, () => done()); }); - it('should reject access when not using masterKey (PUT /jobs/id)', (done) => { - rp.del(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done); + it('should reject access when not using masterKey (DELETE /jobs/id)', done => { + request( + Object.assign( + { method: 'DELETE', url: Parse.serverURL + '/cloud_code/jobs/jobId' }, + defaultOptions + ) + ).then(done.fail, () => done()); }); - it('should allow access when using masterKey (/jobs)', (done) => { - rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions).then(done, done.fail); + it('should allow access when using masterKey (GET /jobs)', done => { + request( + Object.assign( + { url: Parse.serverURL + '/cloud_code/jobs' }, + masterKeyOptions + ) + ).then(done, done.fail); }); - it('should create a job schedule', (done) => { + it('should create a job schedule', done => { Parse.Cloud.job('job', () => {}); const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', body: { job_schedule: { - jobName: 'job' - } - } + jobName: 'job', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { - expect(res.objectId).not.toBeUndefined(); - }) + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + }) .then(() => { - return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + return request( + Object.assign( + { url: Parse.serverURL + '/cloud_code/jobs' }, + masterKeyOptions + ) + ); }) - .then((res) => { - expect(res.length).toBe(1); + .then(res => { + expect(res.data.length).toBe(1); }) .then(done) .catch(done.fail); }); - it('should fail creating a job with an invalid name', (done) => { + it('should fail creating a job with an invalid name', done => { const options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/cloud_code/jobs', + method: 'POST', body: { job_schedule: { - jobName: 'job' - } - } + jobName: 'job', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options) + request(options) .then(done.fail) - .catch(done); + .catch(() => done()); }); - it('should update a job', (done) => { + it('should update a job', done => { Parse.Cloud.job('job1', () => {}); Parse.Cloud.job('job2', () => {}); const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', body: { job_schedule: { - jobName: 'job1' - } - } + jobName: 'job1', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { - expect(res.objectId).not.toBeUndefined(); - return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, { - body: { - job_schedule: { - jobName: 'job2' - } - } - })); - }) + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign(options, { + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + method: 'PUT', + body: { + job_schedule: { + jobName: 'job2', + }, + }, + }) + ); + }) .then(() => { - return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + return request( + Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/cloud_code/jobs', + }) + ); }) - .then((res) => { - expect(res.length).toBe(1); - expect(res[0].jobName).toBe('job2'); + .then(res => { + expect(res.data.length).toBe(1); + expect(res.data[0].jobName).toBe('job2'); }) .then(done) .catch(done.fail); }); - it('should fail updating a job with an invalid name', (done) => { + it('should fail updating a job with an invalid name', done => { Parse.Cloud.job('job1', () => {}); const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', body: { job_schedule: { - jobName: 'job1' - } - } + jobName: 'job1', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { - expect(res.objectId).not.toBeUndefined(); - return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, { - body: { - job_schedule: { - jobName: 'job2' - } - } - })); - }) + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign(options, { + method: 'PUT', + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + body: { + job_schedule: { + jobName: 'job2', + }, + }, + }) + ); + }) .then(done.fail) - .catch(done); + .catch(() => done()); }); - it('should destroy a job', (done) => { + it('should destroy a job', done => { Parse.Cloud.job('job', () => {}); const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', body: { job_schedule: { - jobName: 'job' - } - } + jobName: 'job', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { - expect(res.objectId).not.toBeUndefined(); - return rp.del(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, masterKeyOptions); - }) + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign( + { + method: 'DELETE', + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + }, + masterKeyOptions + ) + ); + }) .then(() => { - return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions); + return request( + Object.assign( + { + url: Parse.serverURL + '/cloud_code/jobs', + }, + masterKeyOptions + ) + ); }) - .then((res) => { - expect(res.length).toBe(0); + .then(res => { + expect(res.data.length).toBe(0); }) .then(done) .catch(done.fail); }); - it('should properly return job data', (done) => { + it('should properly return job data', done => { Parse.Cloud.job('job1', () => {}); Parse.Cloud.job('job2', () => {}); const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', body: { job_schedule: { - jobName: 'job1' - } - } + jobName: 'job1', + }, + }, }); - rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => { - expect(res.objectId).not.toBeUndefined(); - }) + request(options) + .then(response => { + const res = response.data; + expect(res.objectId).not.toBeUndefined(); + }) .then(() => { - return rp.get(Parse.serverURL + '/cloud_code/jobs/data', masterKeyOptions); + return request( + Object.assign( + { url: Parse.serverURL + '/cloud_code/jobs/data' }, + masterKeyOptions + ) + ); }) - .then((res) => { + .then(response => { + const res = response.data; expect(res.in_use).toEqual(['job1']); expect(res.jobs).toContain('job1'); expect(res.jobs).toContain('job2'); expect(res.jobs.length).toBe(2); }) .then(done) - .catch(done.fail); + .catch(e => done.fail(e.data)); }); }); diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js new file mode 100644 index 0000000000..321d8de10f --- /dev/null +++ b/spec/LdapAuth.spec.js @@ -0,0 +1,138 @@ +const ldap = require('../lib/Adapters/Auth/ldap'); +const mockLdapServer = require('./MockLdapServer'); +const port = 12345; + +it('Should fail with missing options', done => { + ldap + .validateAuthData({ id: 'testuser', password: 'testpw' }) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP auth configuration missing'); + done(); + }); +}); + +it('Should return a resolved promise when validating the app id', done => { + ldap + .validateAppId() + .then(done) + .catch(done.fail); +}); + +it('Should succeed with right credentials', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done) + .catch(done.fail) + .finally(() => server.close()); + }); +}); + +it('Should fail with wrong credentials', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + ldap + .validateAuthData({ id: 'testuser', password: 'wrong!' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP: Wrong username or password'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should succeed if user is in given group', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done) + .catch(done.fail) + .finally(() => server.close()); + }); +}); + +it('Should fail if user is not in given group', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'groupTheUserIsNotIn', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP: User not in group'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should fail if the LDAP server does not allow searching inside the provided suffix', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=invalid', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP group search failed'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should fail if the LDAP server encounters an error while searching', done => { + mockLdapServer(port, 'uid=testuser, o=example', true).then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP group search failed'); + done(); + }) + .finally(() => server.close()); + }); +}); diff --git a/spec/Logger.spec.js b/spec/Logger.spec.js index d4e3124534..1fbfe54178 100644 --- a/spec/Logger.spec.js +++ b/spec/Logger.spec.js @@ -1,63 +1,95 @@ -var logging = require('../src/Adapters/Logger/WinstonLogger'); -var winston = require('winston'); +const logging = require('../lib/Adapters/Logger/WinstonLogger'); +const Transport = require('winston-transport'); -class TestTransport extends winston.Transport { - log(level, msg, meta, callback) { +class TestTransport extends Transport { + log(info, callback) { callback(null, true); } } -describe('Logger', () => { +describe('WinstonLogger', () => { it('should add transport', () => { - const testTransport = new (TestTransport)({ - name: 'test' - }); + const testTransport = new TestTransport(); spyOn(testTransport, 'log'); logging.addTransport(testTransport); - expect(Object.keys(logging.logger.transports).length).toBe(4); + expect(logging.logger.transports.length).toBe(4); logging.logger.info('hi'); expect(testTransport.log).toHaveBeenCalled(); + logging.logger.error('error'); + expect(testTransport.log).toHaveBeenCalled(); logging.removeTransport(testTransport); - expect(Object.keys(logging.logger.transports).length).toBe(3); + expect(logging.logger.transports.length).toBe(3); }); - it('should have files transports', (done) => { + it('should have files transports', done => { reconfigureServer().then(() => { const transports = logging.logger.transports; - const transportKeys = Object.keys(transports); - expect(transportKeys.length).toBe(3); + expect(transports.length).toBe(3); done(); }); }); - it('should disable files logs', (done) => { + it('should disable files logs', done => { reconfigureServer({ - logsFolder: null + logsFolder: null, }).then(() => { const transports = logging.logger.transports; - const transportKeys = Object.keys(transports); - expect(transportKeys.length).toBe(1); + expect(transports.length).toBe(1); done(); }); }); - it('should enable JSON logs', (done) => { + it('should have a timestamp', done => { + logging.logger.info('hi'); + logging.logger.query({ limit: 1 }, (err, results) => { + if (err) { + done.fail(err); + } + expect(results['parse-server'][0].timestamp).toBeDefined(); + done(); + }); + }); + + it('console should not be json', done => { + // Force console transport + reconfigureServer({ + logsFolder: null, + silent: false, + }) + .then(() => { + spyOn(process.stdout, 'write'); + logging.logger.info('hi', { key: 'value' }); + expect(process.stdout.write).toHaveBeenCalled(); + const firstLog = process.stdout.write.calls.first().args[0]; + expect(firstLog).toEqual('info: hi {"key":"value"}' + '\n'); + return reconfigureServer(); + }) + .then(() => { + done(); + }); + }); + + it('should enable JSON logs', done => { // Force console transport reconfigureServer({ logsFolder: null, jsonLogs: true, - silent: false - }).then(() => { - spyOn(process.stdout, 'write'); - logging.logger.info('hi', {key: 'value'}); - expect(process.stdout.write).toHaveBeenCalled(); - var firstLog = process.stdout.write.calls.first().args[0]; - expect(firstLog).toEqual(JSON.stringify({key: 'value', level: 'info', message: 'hi' }) + '\n'); - return reconfigureServer({ - jsonLogs: false + silent: false, + }) + .then(() => { + spyOn(process.stdout, 'write'); + logging.logger.info('hi', { key: 'value' }); + expect(process.stdout.write).toHaveBeenCalled(); + const firstLog = process.stdout.write.calls.first().args[0]; + expect(firstLog).toEqual( + JSON.stringify({ key: 'value', level: 'info', message: 'hi' }) + '\n' + ); + return reconfigureServer({ + jsonLogs: false, + }); + }) + .then(() => { + done(); }); - }).then(() => { - done(); - }); }); }); diff --git a/spec/LoggerController.spec.js b/spec/LoggerController.spec.js index 3ce654337e..a98e7d63a2 100644 --- a/spec/LoggerController.spec.js +++ b/spec/LoggerController.spec.js @@ -1,35 +1,42 @@ -var LoggerController = require('../src/Controllers/LoggerController').LoggerController; -var WinstonLoggerAdapter = require('../src/Adapters/Logger/WinstonLoggerAdapter').WinstonLoggerAdapter; +const LoggerController = require('../lib/Controllers/LoggerController') + .LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; describe('LoggerController', () => { - it('can check process a query without throwing', (done) => { + it('can process an empty query without throwing', done => { // Make mock request - var query = {}; + const query = {}; - var loggerController = new LoggerController(new WinstonLoggerAdapter()); + const loggerController = new LoggerController(new WinstonLoggerAdapter()); expect(() => { - loggerController.getLogs(query).then(function(res) { - expect(res.length).not.toBe(0); - done(); - }).catch((err) => { - jfail(err); - done(); - }) + loggerController + .getLogs(query) + .then(function(res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }).not.toThrow(); }); - it('properly validates dateTimes', (done) => { + it('properly validates dateTimes', done => { expect(LoggerController.validDateTime()).toBe(null); - expect(LoggerController.validDateTime("String")).toBe(null); + expect(LoggerController.validDateTime('String')).toBe(null); expect(LoggerController.validDateTime(123456).getTime()).toBe(123456); - expect(LoggerController.validDateTime("2016-01-01Z00:00:00").getTime()).toBe(1451606400000); + expect( + LoggerController.validDateTime('2016-01-01Z00:00:00').getTime() + ).toBe(1451606400000); done(); }); - it('can set the proper default values', (done) => { + it('can set the proper default values', done => { // Make mock request - var result = LoggerController.parseOptions(); + const result = LoggerController.parseOptions(); expect(result.size).toEqual(10); expect(result.order).toEqual('desc'); expect(result.level).toEqual('info'); @@ -37,17 +44,17 @@ describe('LoggerController', () => { done(); }); - it('can process a query without throwing', (done) => { + it('can parse an ascending query without throwing', done => { // Make mock request - var query = { - from: "2016-01-01Z00:00:00", - until: "2016-01-01Z00:00:00", + const query = { + from: '2016-01-01Z00:00:00', + until: '2016-01-01Z00:00:00', size: 5, order: 'asc', - level: 'error' + level: 'error', }; - var result = LoggerController.parseOptions(query); + const result = LoggerController.parseOptions(query); expect(result.from.getTime()).toEqual(1451606400000); expect(result.until.getTime()).toEqual(1451606400000); @@ -58,50 +65,95 @@ describe('LoggerController', () => { done(); }); - it('can check process a query without throwing', (done) => { + it('can process an ascending query without throwing', done => { + const query = { + size: 5, + order: 'asc', + level: 'error', + }; + + const loggerController = new LoggerController(new WinstonLoggerAdapter()); + + expect(() => { + loggerController + .getLogs(query) + .then(function(res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }).not.toThrow(); + }); + + it('can parse a descending query without throwing', done => { // Make mock request - var query = { - from: "2016-01-01", - until: "2016-01-30", + const query = { + from: '2016-01-01Z00:00:00', + until: '2016-01-01Z00:00:00', + size: 5, + order: 'desc', + level: 'error', + }; + + const result = LoggerController.parseOptions(query); + + expect(result.from.getTime()).toEqual(1451606400000); + expect(result.until.getTime()).toEqual(1451606400000); + expect(result.size).toEqual(5); + expect(result.order).toEqual('desc'); + expect(result.level).toEqual('error'); + + done(); + }); + + it('can process a descending query without throwing', done => { + const query = { size: 5, order: 'desc', - level: 'error' + level: 'error', }; - var loggerController = new LoggerController(new WinstonLoggerAdapter()); + const loggerController = new LoggerController(new WinstonLoggerAdapter()); expect(() => { - loggerController.getLogs(query).then(function(res) { - expect(res.length).toBe(0); - done(); - }).catch((err) => { - jfail(err); - fail("should not fail"); - done(); - }) + loggerController + .getLogs(query) + .then(function(res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); }).not.toThrow(); }); - it('should throw without an adapter', (done) => { + it('should throw without an adapter', done => { expect(() => { new LoggerController(); }).toThrow(); done(); }); - it('should replace implementations with verbose', (done) => { + it('should replace implementations with verbose', done => { const adapter = new WinstonLoggerAdapter(); - const logger = new LoggerController(adapter, null, {verbose: true }); - spyOn(adapter, "log"); + const logger = new LoggerController(adapter, null, { verbose: true }); + spyOn(adapter, 'log'); logger.silly('yo!'); expect(adapter.log).not.toHaveBeenCalled(); done(); }); - it('should replace implementations with logLevel', (done) => { + it('should replace implementations with logLevel', done => { const adapter = new WinstonLoggerAdapter(); const logger = new LoggerController(adapter, null, { logLevel: 'error' }); - spyOn(adapter, "log"); + spyOn(adapter, 'log'); logger.warn('yo!'); logger.info('yo!'); logger.debug('yo!'); diff --git a/spec/LogsRouter.spec.js b/spec/LogsRouter.spec.js index 36fab14998..014486d803 100644 --- a/spec/LogsRouter.spec.js +++ b/spec/LogsRouter.spec.js @@ -1,26 +1,28 @@ 'use strict'; -const request = require('request'); -var LogsRouter = require('../src/Routers/LogsRouter').LogsRouter; -var LoggerController = require('../src/Controllers/LoggerController').LoggerController; -var WinstonLoggerAdapter = require('../src/Adapters/Logger/WinstonLoggerAdapter').WinstonLoggerAdapter; +const request = require('../lib/request'); +const LogsRouter = require('../lib/Routers/LogsRouter').LogsRouter; +const LoggerController = require('../lib/Controllers/LoggerController') + .LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; const loggerController = new LoggerController(new WinstonLoggerAdapter()); describe('LogsRouter', () => { - it('can check valid master key of request', (done) => { + it('can check valid master key of request', done => { // Make mock request - var request = { + const request = { auth: { - isMaster: true + isMaster: true, }, query: {}, config: { - loggerController: loggerController - } + loggerController: loggerController, + }, }; - var router = new LogsRouter(); + const router = new LogsRouter(); expect(() => { router.validateRequest(request); @@ -28,19 +30,19 @@ describe('LogsRouter', () => { done(); }); - it('can check invalid construction of controller', (done) => { + it('can check invalid construction of controller', done => { // Make mock request - var request = { + const request = { auth: { - isMaster: true + isMaster: true, }, query: {}, config: { - loggerController: undefined // missing controller - } + loggerController: undefined, // missing controller + }, }; - var router = new LogsRouter(); + const router = new LogsRouter(); expect(() => { router.validateRequest(request); @@ -49,17 +51,127 @@ describe('LogsRouter', () => { }); it('can check invalid master key of request', done => { - request.get({ + request({ url: 'http://localhost:8378/1/scriptlog', - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); + 'X-Parse-REST-API-Key': 'rest', + }, + }).then(fail, response => { + const body = response.data; + expect(response.status).toEqual(403); expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); + + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + + /** + * Verifies simple passwords in GET login requests with special characters are scrubbed from the verbose log + */ + it('does scrub simple passwords on GET login', done => { + reconfigureServer({ + verbose: true, + }).then(function() { + request({ + headers: headers, + url: + 'http://localhost:8378/1/login?username=test&password=simplepass.com', + }) + .catch(() => {}) + .then(() => { + request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual( + '/1/login?username=test&password=********' + ); + expect(body[2].message).toEqual( + 'REQUEST for [GET] /1/login?username=test&password=********: {}' + ); + done(); + }); + }); + }); + }); + + /** + * Verifies complex passwords in GET login requests with special characters are scrubbed from the verbose log + */ + it('does scrub complex passwords on GET login', done => { + reconfigureServer({ + verbose: true, + }) + .then(function() { + return request({ + headers: headers, + // using urlencoded password, 'simple @,/?:&=+$#pass.com' + url: + 'http://localhost:8378/1/login?username=test&password=simple%20%40%2C%2F%3F%3A%26%3D%2B%24%23pass.com', + }) + .catch(() => {}) + .then(() => { + return request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual( + '/1/login?username=test&password=********' + ); + expect(body[2].message).toEqual( + 'REQUEST for [GET] /1/login?username=test&password=********: {}' + ); + done(); + }); + }); + }) + .catch(done.fail); + }); + + /** + * Verifies fields in POST login requests are NOT present in the verbose log + */ + it('does not have password field in POST login', done => { + reconfigureServer({ + verbose: true, + }).then(function() { + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/login', + body: { + username: 'test', + password: 'simplepass.com', + }, + }) + .catch(() => {}) + .then(() => { + request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual('/1/login'); + expect(body[2].message).toEqual( + 'REQUEST for [POST] /1/login: {\n "username": "test",\n "password": "********"\n}' + ); + done(); + }); + }); + }); + }); }); diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 7543f37052..cd5aea7dfb 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -1,21 +1,20 @@ -var middlewares = require('../src/middlewares'); -var AppCache = require('../src/cache').AppCache; +const middlewares = require('../lib/middlewares'); +const AppCache = require('../lib/cache').AppCache; describe('middlewares', () => { - - var fakeReq, fakeRes; + let fakeReq, fakeRes; beforeEach(() => { fakeReq = { originalUrl: 'http://example.com/parse/', url: 'http://example.com/', body: { - _ApplicationId: 'FakeAppId' + _ApplicationId: 'FakeAppId', }, headers: {}, - get: (key) => { - return fakeReq.headers[key.toLowerCase()] - } + get: key => { + return fakeReq.headers[key.toLowerCase()]; + }, }; fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status']); AppCache.put(fakeReq.body._ApplicationId, {}); @@ -25,21 +24,21 @@ describe('middlewares', () => { AppCache.del(fakeReq.body._ApplicationId); }); - it('should use _ContentType if provided', (done) => { + it('should use _ContentType if provided', done => { expect(fakeReq.headers['content-type']).toEqual(undefined); - var contentType = 'image/jpeg'; + const contentType = 'image/jpeg'; fakeReq.body._ContentType = contentType; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeReq.headers['content-type']).toEqual(contentType); expect(fakeReq.body._ContentType).toEqual(undefined); - done() + done(); }); }); it('should give invalid response when keys are configured but no key supplied', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - restAPIKey: 'restAPIKey' + restAPIKey: 'restAPIKey', }); middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); @@ -48,7 +47,7 @@ describe('middlewares', () => { it('should give invalid response when keys are configured but supplied key is incorrect', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - restAPIKey: 'restAPIKey' + restAPIKey: 'restAPIKey', }); fakeReq.headers['x-parse-rest-api-key'] = 'wrongKey'; middlewares.handleParseHeaders(fakeReq, fakeRes); @@ -58,19 +57,18 @@ describe('middlewares', () => { it('should give invalid response when keys are configured but different key is supplied', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - restAPIKey: 'restAPIKey' + restAPIKey: 'restAPIKey', }); fakeReq.headers['x-parse-client-key'] = 'clientKey'; middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); }); - - it('should succeed when any one of the configured keys supplied', (done) => { + it('should succeed when any one of the configured keys supplied', done => { AppCache.put(fakeReq.body._ApplicationId, { clientKey: 'clientKey', masterKey: 'masterKey', - restAPIKey: 'restAPIKey' + restAPIKey: 'restAPIKey', }); fakeReq.headers['x-parse-rest-api-key'] = 'restAPIKey'; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { @@ -79,11 +77,11 @@ describe('middlewares', () => { }); }); - it('should succeed when client key supplied but empty', (done) => { + it('should succeed when client key supplied but empty', done => { AppCache.put(fakeReq.body._ApplicationId, { clientKey: '', masterKey: 'masterKey', - restAPIKey: 'restAPIKey' + restAPIKey: 'restAPIKey', }); fakeReq.headers['x-parse-client-key'] = ''; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { @@ -92,9 +90,9 @@ describe('middlewares', () => { }); }); - it('should succeed when no keys are configured and none supplied', (done) => { + it('should succeed when no keys are configured and none supplied', done => { AppCache.put(fakeReq.body._ApplicationId, { - masterKey: 'masterKey' + masterKey: 'masterKey', }); middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); @@ -107,25 +105,27 @@ describe('middlewares', () => { installationId: '_InstallationId', sessionToken: '_SessionToken', masterKey: '_MasterKey', - javascriptKey: '_JavaScriptKey' + javascriptKey: '_JavaScriptKey', }; const BodyKeys = Object.keys(BodyParams); - BodyKeys.forEach((infoKey) => { + BodyKeys.forEach(infoKey => { const bodyKey = BodyParams[infoKey]; const keyValue = 'Fake' + bodyKey; // javascriptKey is the only one that gets defaulted, - const otherKeys = BodyKeys.filter((otherKey) => otherKey !== infoKey && otherKey !== 'javascriptKey'); + const otherKeys = BodyKeys.filter( + otherKey => otherKey !== infoKey && otherKey !== 'javascriptKey' + ); - it(`it should pull ${bodyKey} into req.info`, (done) => { + it(`it should pull ${bodyKey} into req.info`, done => { fakeReq.body[bodyKey] = keyValue; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeReq.body[bodyKey]).toEqual(undefined); expect(fakeReq.info[infoKey]).toEqual(keyValue); - otherKeys.forEach((otherKey) => { + otherKeys.forEach(otherKey => { expect(fakeReq.info[otherKey]).toEqual(undefined); }); @@ -137,7 +137,7 @@ describe('middlewares', () => { it('should not succeed if the ip does not belong to masterKeyIps list', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); fakeReq.ip = 'ip3'; fakeReq.headers['x-parse-master-key'] = 'masterKey'; @@ -145,14 +145,14 @@ describe('middlewares', () => { expect(fakeRes.status).toHaveBeenCalledWith(403); }); - it('should succeed if the ip does belong to masterKeyIps list', (done) => { + it('should succeed if the ip does belong to masterKeyIps list', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); fakeReq.ip = 'ip1'; fakeReq.headers['x-parse-master-key'] = 'masterKey'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); @@ -161,22 +161,22 @@ describe('middlewares', () => { it('should not succeed if the connection.remoteAddress does not belong to masterKeyIps list', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.connection = {remoteAddress : 'ip3'}; + fakeReq.connection = { remoteAddress: 'ip3' }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); }); - it('should succeed if the connection.remoteAddress does belong to masterKeyIps list', (done) => { + it('should succeed if the connection.remoteAddress does belong to masterKeyIps list', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.connection = {remoteAddress : 'ip1'}; + fakeReq.connection = { remoteAddress: 'ip1' }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); @@ -185,22 +185,22 @@ describe('middlewares', () => { it('should not succeed if the socket.remoteAddress does not belong to masterKeyIps list', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.socket = {remoteAddress : 'ip3'}; + fakeReq.socket = { remoteAddress: 'ip3' }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); }); - it('should succeed if the socket.remoteAddress does belong to masterKeyIps list', (done) => { + it('should succeed if the socket.remoteAddress does belong to masterKeyIps list', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.socket = {remoteAddress : 'ip1'}; + fakeReq.socket = { remoteAddress: 'ip1' }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); @@ -209,61 +209,61 @@ describe('middlewares', () => { it('should not succeed if the connection.socket.remoteAddress does not belong to masterKeyIps list', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.connection = { socket : {remoteAddress : 'ip3'}}; + fakeReq.connection = { socket: { remoteAddress: 'ip3' } }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); }); - it('should succeed if the connection.socket.remoteAddress does belong to masterKeyIps list', (done) => { + it('should succeed if the connection.socket.remoteAddress does belong to masterKeyIps list', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1','ip2'] + masterKeyIps: ['ip1', 'ip2'], }); - fakeReq.connection = { socket : {remoteAddress : 'ip1'}}; + fakeReq.connection = { socket: { remoteAddress: 'ip1' } }; fakeReq.headers['x-parse-master-key'] = 'masterKey'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); }); - it('should allow any ip to use masterKey if masterKeyIps is empty', (done) => { + it('should allow any ip to use masterKey if masterKeyIps is empty', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: [] + masterKeyIps: [], }); fakeReq.ip = 'ip1'; fakeReq.headers['x-parse-master-key'] = 'masterKey'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); }); - it('should succeed if xff header does belong to masterKeyIps', (done) => { + it('should succeed if xff header does belong to masterKeyIps', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1'] + masterKeyIps: ['ip1'], }); fakeReq.headers['x-parse-master-key'] = 'masterKey'; fakeReq.headers['x-forwarded-for'] = 'ip1, ip2, ip3'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); }); - it('should succeed if xff header with one ip does belong to masterKeyIps', (done) => { + it('should succeed if xff header with one ip does belong to masterKeyIps', done => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1'] + masterKeyIps: ['ip1'], }); fakeReq.headers['x-parse-master-key'] = 'masterKey'; fakeReq.headers['x-forwarded-for'] = 'ip1'; - middlewares.handleParseHeaders(fakeReq, fakeRes,() => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeRes.status).not.toHaveBeenCalled(); done(); }); @@ -272,7 +272,7 @@ describe('middlewares', () => { it('should not succeed if xff header does not belong to masterKeyIps', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip4'] + masterKeyIps: ['ip4'], }); fakeReq.headers['x-parse-master-key'] = 'masterKey'; fakeReq.headers['x-forwarded-for'] = 'ip1, ip2, ip3'; @@ -283,11 +283,77 @@ describe('middlewares', () => { it('should not succeed if xff header is empty and masterKeyIps is set', () => { AppCache.put(fakeReq.body._ApplicationId, { masterKey: 'masterKey', - masterKeyIps: ['ip1'] + masterKeyIps: ['ip1'], }); fakeReq.headers['x-parse-master-key'] = 'masterKey'; fakeReq.headers['x-forwarded-for'] = ''; middlewares.handleParseHeaders(fakeReq, fakeRes); expect(fakeRes.status).toHaveBeenCalledWith(403); }); + + it('should properly expose the headers', () => { + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); + expect(Object.keys(headers).length).toBe(4); + expect(headers['Access-Control-Expose-Headers']).toBe( + 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' + ); + }); + + it('should set default Access-Control-Allow-Headers if allowHeaders are empty', () => { + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: undefined, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: [], + }); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + }); + + it('should append custom headers to Access-Control-Allow-Headers if allowHeaders provided', () => { + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: ['Header-1', 'Header-2'], + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + 'Header-1, Header-2' + ); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + }); }); diff --git a/spec/MockAdapter.js b/spec/MockAdapter.js index c3f557849d..3deaaaae74 100644 --- a/spec/MockAdapter.js +++ b/spec/MockAdapter.js @@ -1,5 +1,5 @@ module.exports = function(options) { return { - options: options + options: options, }; }; diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js index b143e37e6e..295e6c6c91 100644 --- a/spec/MockEmailAdapter.js +++ b/spec/MockEmailAdapter.js @@ -1,5 +1,5 @@ module.exports = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() -} + sendMail: () => Promise.resolve(), +}; diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index 5de84d0521..71d23892ef 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -1,21 +1,21 @@ module.exports = options => { if (!options) { - throw "Options were not provided" + throw 'Options were not provided'; } const adapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() + sendMail: () => Promise.resolve(), }; if (options.sendMail) { - adapter.sendMail = options.sendMail + adapter.sendMail = options.sendMail; } if (options.sendPasswordResetEmail) { - adapter.sendPasswordResetEmail = options.sendPasswordResetEmail + adapter.sendPasswordResetEmail = options.sendPasswordResetEmail; } if (options.sendVerificationEmail) { adapter.sendVerificationEmail = options.sendVerificationEmail; } return adapter; -} +}; diff --git a/spec/MockLdapServer.js b/spec/MockLdapServer.js new file mode 100644 index 0000000000..767d56fe3b --- /dev/null +++ b/spec/MockLdapServer.js @@ -0,0 +1,48 @@ +const ldapjs = require('ldapjs'); + +function newServer(port, dn, provokeSearchError = false) { + const server = ldapjs.createServer(); + + server.bind('o=example', function(req, res, next) { + if (req.dn.toString() !== dn || req.credentials !== 'secret') + return next(new ldapjs.InvalidCredentialsError()); + res.end(); + return next(); + }); + + server.search('o=example', function(req, res, next) { + if (provokeSearchError) { + res.end(ldapjs.LDAP_SIZE_LIMIT_EXCEEDED); + return next(); + } + const obj = { + dn: req.dn.toString(), + attributes: { + objectclass: ['organization', 'top'], + o: 'example', + }, + }; + + const group = { + dn: req.dn.toString(), + attributes: { + objectClass: ['groupOfUniqueNames', 'top'], + uniqueMember: ['uid=testuser, o=example'], + cn: 'powerusers', + ou: 'powerusers', + }, + }; + + if (req.filter.matches(obj.attributes)) { + res.send(obj); + } + + if (req.filter.matches(group.attributes)) { + res.send(group); + } + res.end(); + }); + return new Promise(resolve => server.listen(port, () => resolve(server))); +} + +module.exports = newServer; diff --git a/spec/MockPushAdapter.js b/spec/MockPushAdapter.js index 3350e9df55..6c7c2e3da1 100644 --- a/spec/MockPushAdapter.js +++ b/spec/MockPushAdapter.js @@ -4,6 +4,6 @@ module.exports = function(options) { send: function() {}, getValidPushTypes: function() { return Object.keys(options.options); - } + }, }; }; diff --git a/spec/MongoSchemaCollectionAdapter.spec.js b/spec/MongoSchemaCollectionAdapter.spec.js index ba22c1fab3..242466a18d 100644 --- a/spec/MongoSchemaCollectionAdapter.spec.js +++ b/spec/MongoSchemaCollectionAdapter.spec.js @@ -1,43 +1,52 @@ 'use strict'; -const MongoSchemaCollection = require('../src/Adapters/Storage/Mongo/MongoSchemaCollection').default; +const MongoSchemaCollection = require('../lib/Adapters/Storage/Mongo/MongoSchemaCollection') + .default; describe('MongoSchemaCollection', () => { it('can transform legacy _client_permissions keys to parse format', done => { - expect(MongoSchemaCollection._TESTmongoSchemaToParseSchema({ - "_id":"_Installation", - "_client_permissions":{ - "get":true, - "find":true, - "update":true, - "create":true, - "delete":true, - }, - "_metadata":{ - "class_permissions":{ - "get":{"*":true}, - "find":{"*":true}, - "update":{"*":true}, - "create":{"*":true}, - "delete":{"*":true}, - "addField":{"*":true}, - } - }, - "installationId":"string", - "deviceToken":"string", - "deviceType":"string", - "channels":"array", - "user":"*_User", - "pushType":"string", - "GCMSenderId":"string", - "timeZone":"string", - "localeIdentifier":"string", - "badge":"number", - "appVersion":"string", - "appName":"string", - "appIdentifier":"string", - "parseVersion":"string", - })).toEqual({ + expect( + MongoSchemaCollection._TESTmongoSchemaToParseSchema({ + _id: '_Installation', + _client_permissions: { + get: true, + find: true, + count: true, + update: true, + create: true, + delete: true, + }, + _metadata: { + class_permissions: { + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + update: { '*': true }, + create: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + indexes: { + name1: { deviceToken: 1 }, + }, + }, + installationId: 'string', + deviceToken: 'string', + deviceType: 'string', + channels: 'array', + user: '*_User', + pushType: 'string', + GCMSenderId: 'string', + timeZone: 'string', + localeIdentifier: 'string', + badge: 'number', + appVersion: 'string', + appName: 'string', + appIdentifier: 'string', + parseVersion: 'string', + }) + ).toEqual({ className: '_Installation', fields: { installationId: { type: 'String' }, @@ -62,11 +71,16 @@ describe('MongoSchemaCollection', () => { classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, - } + protectedFields: { '*': [] }, + }, + indexes: { + name1: { deviceToken: 1 }, + }, }); done(); }); diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 78b2d0c1eb..45c7068341 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -1,8 +1,18 @@ 'use strict'; -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const MongoClient = require('mongodb').MongoClient; -const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const { MongoClient } = require('mongodb'); +const databaseURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const TestUtils = require('../lib/TestUtils'); + +const fakeClient = { + s: { options: { dbName: null } }, + db: () => null, +}; // These tests are specific to the mongo storage adapter + mongo storage format // and will eventually be moved into their own repo @@ -14,9 +24,10 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }); it('auto-escapes symbols in auth information', () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://user!with@+ symbols:password!with@+ symbols@localhost:1234/parse' + uri: + 'mongodb://user!with@+ symbols:password!with@+ symbols@localhost:1234/parse', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', @@ -25,9 +36,10 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }); it("doesn't double escape already URI-encoded information", () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse' + uri: + 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', @@ -37,9 +49,10 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { // https://github.com/parse-community/parse-server/pull/148#issuecomment-180407057 it('preserves replica sets', () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415' + uri: + 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415', @@ -49,68 +62,82 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { it('stores objectId in _id', done => { const adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) .then(() => adapter._rawFind('Foo', {})) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj._id).toEqual('abcde'); expect(obj.objectId).toBeUndefined(); done(); }); }); - it('find succeeds when query is within maxTimeMS', (done) => { + it('find succeeds when query is within maxTimeMS', done => { const maxTimeMS = 250; const adapter = new MongoStorageAdapter({ uri: databaseURI, mongoOptions: { maxTimeMS }, }); - adapter.createObject('Foo', { fields: {} }, { objectId: 'abcde' }) - .then(() => adapter._rawFind('Foo', { '$where': `sleep(${maxTimeMS / 2})` })) + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + .then(() => + adapter._rawFind('Foo', { $where: `sleep(${maxTimeMS / 2})` }) + ) .then( () => done(), - (err) => { + err => { done.fail(`maxTimeMS should not affect fast queries ${err}`); } ); - }) + }); - it('find fails when query exceeds maxTimeMS', (done) => { + it('find fails when query exceeds maxTimeMS', done => { const maxTimeMS = 250; const adapter = new MongoStorageAdapter({ uri: databaseURI, mongoOptions: { maxTimeMS }, }); - adapter.createObject('Foo', { fields: {} }, { objectId: 'abcde' }) - .then(() => adapter._rawFind('Foo', { '$where': `sleep(${maxTimeMS * 2})` })) + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + .then(() => + adapter._rawFind('Foo', { $where: `sleep(${maxTimeMS * 2})` }) + ) .then( () => { done.fail('Find succeeded despite taking too long!'); }, - (err) => { + err => { expect(err.name).toEqual('MongoError'); expect(err.code).toEqual(50); - expect(err.message).toEqual('operation exceeded time limit'); + expect(err.message).toMatch('operation exceeded time limit'); done(); } ); }); - it('stores pointers with a _p_ prefix', (done) => { + it('stores pointers with a _p_ prefix', done => { const obj = { objectId: 'bar', aPointer: { __type: 'Pointer', className: 'JustThePointer', - objectId: 'qwerty' - } + objectId: 'qwerty', + }, }; const adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('APointerDarkly', { fields: { - objectId: { type: 'String' }, - aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}, obj) + adapter + .createObject( + 'APointerDarkly', + { + fields: { + objectId: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, + }, + }, + obj + ) .then(() => adapter._rawFind('APointerDarkly', {})) .then(results => { expect(results.length).toEqual(1); @@ -125,9 +152,10 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { it('handles object and subdocument', done => { const adapter = new MongoStorageAdapter({ uri: databaseURI }); - const schema = { fields : { subdoc: { type: 'Object' } } }; - const obj = { subdoc: {foo: 'bar', wu: 'tan'} }; - adapter.createObject('MyClass', schema, obj) + const schema = { fields: { subdoc: { type: 'Object' } } }; + const obj = { subdoc: { foo: 'bar', wu: 'tan' } }; + adapter + .createObject('MyClass', schema, obj) .then(() => adapter._rawFind('MyClass', {})) .then(results => { expect(results.length).toEqual(1); @@ -149,22 +177,25 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }); }); - it('handles creating an array, object, date', (done) => { + it('handles creating an array, object, date', done => { const adapter = new MongoStorageAdapter({ uri: databaseURI }); const obj = { array: [1, 2, 3], - object: {foo: 'bar'}, + object: { foo: 'bar' }, date: { __type: 'Date', iso: '2016-05-26T20:55:01.154Z', }, }; - const schema = { fields: { - array: { type: 'Array' }, - object: { type: 'Object' }, - date: { type: 'Date' }, - } }; - adapter.createObject('MyClass', schema, obj) + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + adapter + .createObject('MyClass', schema, obj) .then(() => adapter._rawFind('MyClass', {})) .then(results => { expect(results.length).toEqual(1); @@ -190,30 +221,32 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }); }); - it("handles updating a single object with array, object date", (done) => { + it('handles updating a single object with array, object date', done => { const adapter = new MongoStorageAdapter({ uri: databaseURI }); - const schema = { fields: { - array: { type: 'Array' }, - object: { type: 'Object' }, - date: { type: 'Date' }, - } }; - + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; - adapter.createObject('MyClass', schema, {}) + adapter + .createObject('MyClass', schema, {}) .then(() => adapter._rawFind('MyClass', {})) .then(results => { expect(results.length).toEqual(1); const update = { array: [1, 2, 3], - object: {foo: 'bar'}, + object: { foo: 'bar' }, date: { __type: 'Date', iso: '2016-05-26T20:55:01.154Z', }, }; const query = {}; - return adapter.findOneAndUpdate('MyClass', schema, query, update) + return adapter.findOneAndUpdate('MyClass', schema, query, update); }) .then(results => { const mob = results; @@ -237,4 +270,308 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { done(); }); }); + + it('handleShutdown, close connection', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + await adapter.createObject('MyClass', schema, {}); + const status = await adapter.database.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + + await adapter.handleShutdown(); + try { + await adapter.database.admin().serverStatus(); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toEqual('topology was destroyed'); + } + }); + + it('getClass if exists', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + await adapter.createClass('MyClass', schema); + const myClassSchema = await adapter.getClass('MyClass'); + expect(myClassSchema).toBeDefined(); + }); + + it('getClass if not exists', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith( + undefined + ); + }); + + it('should use index for caseInsensitive query', async () => { + const user = new Parse.User(); + user.set('username', 'Bugs'); + user.set('password', 'Bunny'); + await user.signUp(); + + const database = Config.get(Parse.applicationId).database; + const preIndexPlan = await database.find( + '_User', + { username: 'bugs' }, + { caseInsensitive: true, explain: true } + ); + + const schema = await new Parse.Schema('_User').get(); + + await database.adapter.ensureIndex( + '_User', + schema, + ['username'], + 'case_insensitive_username', + true + ); + + const postIndexPlan = await database.find( + '_User', + { username: 'bugs' }, + { caseInsensitive: true, explain: true } + ); + expect(preIndexPlan.executionStats.executionStages.stage).toBe('COLLSCAN'); + expect(postIndexPlan.executionStats.executionStages.stage).toBe('FETCH'); + }); + + if ( + process.env.MONGODB_VERSION === '4.0.4' && + process.env.MONGODB_TOPOLOGY === 'replicaset' && + process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' + ) { + describe('transactions', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeAll(async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase?replicaSet=replicaset', + }); + }); + + beforeEach(async () => { + await TestUtils.destroyAllDataPermanently(true); + }); + + it('should use transaction in a batch with transaction = true', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'command' + ).and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + transaction: true, + }), + }); + + let found = false; + databaseAdapter.database.serverConfig.command.calls + .all() + .forEach(call => { + found = true; + expect(call.args[2].session.transaction.state).not.toBe( + 'NO_TRANSACTION' + ); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a batch with transaction = false', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'command' + ).and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + transaction: false, + }), + }); + + let found = false; + databaseAdapter.database.serverConfig.command.calls + .all() + .forEach(call => { + found = true; + expect(call.args[2].session).toBe(undefined); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a batch with no transaction option sent', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'command' + ).and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + }), + }); + + let found = false; + databaseAdapter.database.serverConfig.command.calls + .all() + .forEach(call => { + found = true; + expect(call.args[2].session).toBe(undefined); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a put request', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'command' + ).and.callThrough(); + + await request({ + method: 'PUT', + headers: headers, + url: 'http://localhost:8378/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }); + + let found = false; + databaseAdapter.database.serverConfig.command.calls + .all() + .forEach(call => { + found = true; + expect(call.args[2].session).toBe(undefined); + }); + expect(found).toBe(true); + }); + + it('should not use transactions when using SDK insert', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'insert' + ).and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const calls = databaseAdapter.database.serverConfig.insert.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[2].session.transaction.state).toBe('NO_TRANSACTION'); + }); + }); + + it('should not use transactions when using SDK update', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'update' + ).and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + myObject.set('myAttribute', 'myValue'); + await myObject.save(); + + const calls = databaseAdapter.database.serverConfig.update.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[2].session.transaction.state).toBe('NO_TRANSACTION'); + }); + }); + + it('should not use transactions when using SDK delete', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database + .adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'remove' + ).and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + await myObject.destroy(); + + const calls = databaseAdapter.database.serverConfig.remove.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[2].session.transaction.state).toBe('NO_TRANSACTION'); + }); + }); + }); + } }); diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 73c179d79f..c62a2a989f 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -1,245 +1,339 @@ // These tests are unit tests designed to only test transform.js. -"use strict"; +'use strict'; -const transform = require('../src/Adapters/Storage/Mongo/MongoTransform'); +const transform = require('../lib/Adapters/Storage/Mongo/MongoTransform'); const dd = require('deep-diff'); const mongodb = require('mongodb'); describe('parseObjectToMongoObjectForCreate', () => { - it('a basic number', (done) => { - var input = {five: 5}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {five: {type: 'Number'}} + it('a basic number', done => { + const input = { five: 5 }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { five: { type: 'Number' } }, }); jequal(input, output); done(); }); - it('an object with null values', (done) => { - var input = {objectWithNullValues: {isNull: null, notNull: 3}}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {objectWithNullValues: {type: 'object'}} + it('an object with null values', done => { + const input = { objectWithNullValues: { isNull: null, notNull: 3 } }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { objectWithNullValues: { type: 'object' } }, }); jequal(input, output); done(); }); - it('built-in timestamps', (done) => { - var input = { - createdAt: "2015-10-06T21:24:50.332Z", - updatedAt: "2015-10-06T21:24:50.332Z" + it('built-in timestamps with date', done => { + const input = { + createdAt: '2015-10-06T21:24:50.332Z', + updatedAt: '2015-10-06T21:24:50.332Z', }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); expect(output._created_at instanceof Date).toBe(true); expect(output._updated_at instanceof Date).toBe(true); done(); }); - it('array of pointers', (done) => { - var pointer = { + it('array of pointers', done => { + const pointer = { __type: 'Pointer', objectId: 'myId', className: 'Blah', }; - var out = transform.parseObjectToMongoObjectForCreate(null, {pointers: [pointer]},{ - fields: {pointers: {type: 'Array'}} - }); + const out = transform.parseObjectToMongoObjectForCreate( + null, + { pointers: [pointer] }, + { + fields: { pointers: { type: 'Array' } }, + } + ); jequal([pointer], out.pointers); done(); }); //TODO: object creation requests shouldn't be seeing __op delete, it makes no sense to //have __op delete in a new object. Figure out what this should actually be testing. - xit('a delete op', (done) => { - var input = {deleteMe: {__op: 'Delete'}}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); + xit('a delete op', done => { + const input = { deleteMe: { __op: 'Delete' } }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); jequal(output, {}); done(); }); it('Doesnt allow ACL, as Parse Server should tranform ACL to _wperm + _rperm', done => { - var input = {ACL: {'0123': {'read': true, 'write': true}}}; - expect(() => transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} })).toThrow(); + const input = { ACL: { '0123': { read: true, write: true } } }; + expect(() => + transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }) + ).toThrow(); done(); }); - it('plain', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, {location: geoPoint},{ - fields: {location: {type: 'GeoPoint'}} - }); - expect(out.location).toEqual([180, -180]); + it('parse geopoint to mongo', done => { + const lat = -45; + const lng = 45; + const geoPoint = { __type: 'GeoPoint', latitude: lat, longitude: lng }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { location: geoPoint }, + { + fields: { location: { type: 'GeoPoint' } }, + } + ); + expect(out.location).toEqual([lng, lat]); done(); }); - it('in array', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, {locations: [geoPoint, geoPoint]},{ - fields: {locations: {type: 'Array'}} - }); + it('parse polygon to mongo', done => { + const lat1 = -45; + const lng1 = 45; + const lat2 = -55; + const lng2 = 55; + const lat3 = -65; + const lng3 = 65; + const polygon = { + __type: 'Polygon', + coordinates: [ + [lat1, lng1], + [lat2, lng2], + [lat3, lng3], + ], + }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { location: polygon }, + { + fields: { location: { type: 'Polygon' } }, + } + ); + expect(out.location.coordinates).toEqual([ + [ + [lng1, lat1], + [lng2, lat2], + [lng3, lat3], + [lng1, lat1], + ], + ]); + done(); + }); + + it('in array', done => { + const geoPoint = { __type: 'GeoPoint', longitude: 180, latitude: -180 }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { locations: [geoPoint, geoPoint] }, + { + fields: { locations: { type: 'Array' } }, + } + ); expect(out.locations).toEqual([geoPoint, geoPoint]); done(); }); - it('in sub-object', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, { locations: { start: geoPoint }},{ - fields: {locations: {type: 'Object'}} - }); + it('in sub-object', done => { + const geoPoint = { __type: 'GeoPoint', longitude: 180, latitude: -180 }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { locations: { start: geoPoint } }, + { + fields: { locations: { type: 'Object' } }, + } + ); expect(out).toEqual({ locations: { start: geoPoint } }); done(); }); - it('objectId', (done) => { - var out = transform.transformWhere(null, {objectId: 'foo'}); + it('objectId', done => { + const out = transform.transformWhere(null, { objectId: 'foo' }); expect(out._id).toEqual('foo'); done(); }); - it('objectId in a list', (done) => { - var input = { - objectId: {'$in': ['one', 'two', 'three']}, + it('objectId in a list', done => { + const input = { + objectId: { $in: ['one', 'two', 'three'] }, }; - var output = transform.transformWhere(null, input); + const output = transform.transformWhere(null, input); jequal(input.objectId, output._id); done(); }); - it('built-in timestamps', (done) => { - var input = {createdAt: new Date(), updatedAt: new Date()}; - var output = transform.mongoObjectToParseObject(null, input, { fields: {} }); + it('built-in timestamps', done => { + const input = { createdAt: new Date(), updatedAt: new Date() }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: {}, + }); expect(typeof output.createdAt).toEqual('string'); expect(typeof output.updatedAt).toEqual('string'); done(); }); - it('pointer', (done) => { - var input = {_p_userPointer: '_User$123'}; - var output = transform.mongoObjectToParseObject(null, input, { + it('pointer', done => { + const input = { _p_userPointer: '_User$123' }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { userPointer: { type: 'Pointer', targetClass: '_User' } }, }); expect(typeof output.userPointer).toEqual('object'); - expect(output.userPointer).toEqual( - {__type: 'Pointer', className: '_User', objectId: '123'} - ); + expect(output.userPointer).toEqual({ + __type: 'Pointer', + className: '_User', + objectId: '123', + }); done(); }); - it('null pointer', (done) => { - var input = {_p_userPointer: null}; - var output = transform.mongoObjectToParseObject(null, input, { + it('null pointer', done => { + const input = { _p_userPointer: null }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { userPointer: { type: 'Pointer', targetClass: '_User' } }, }); expect(output.userPointer).toBeUndefined(); done(); }); - it('file', (done) => { - var input = {picture: 'pic.jpg'}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { picture: { type: 'File' }}, + it('file', done => { + const input = { picture: 'pic.jpg' }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { picture: { type: 'File' } }, }); expect(typeof output.picture).toEqual('object'); - expect(output.picture).toEqual({__type: 'File', name: 'pic.jpg'}); + expect(output.picture).toEqual({ __type: 'File', name: 'pic.jpg' }); done(); }); - it('geopoint', (done) => { - var input = {location: [45, -45]}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { location: { type: 'GeoPoint' }}, + it('mongo geopoint to parse', done => { + const lat = -45; + const lng = 45; + const input = { location: [lng, lat] }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { location: { type: 'GeoPoint' } }, }); expect(typeof output.location).toEqual('object'); - expect(output.location).toEqual( - {__type: 'GeoPoint', longitude: 45, latitude: -45} - ); + expect(output.location).toEqual({ + __type: 'GeoPoint', + latitude: lat, + longitude: lng, + }); done(); }); - it('polygon', (done) => { - var input = {location: { type: 'Polygon', coordinates: [[[45, -45],[45, -45]]]}}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { location: { type: 'Polygon' }}, + it('mongo polygon to parse', done => { + const lat = -45; + const lng = 45; + // Mongo stores polygon in WGS84 lng/lat + const input = { + location: { + type: 'Polygon', + coordinates: [ + [ + [lat, lng], + [lat, lng], + ], + ], + }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { location: { type: 'Polygon' } }, }); expect(typeof output.location).toEqual('object'); - expect(output.location).toEqual( - {__type: 'Polygon', coordinates: [[45, -45],[45, -45]]} - ); + expect(output.location).toEqual({ + __type: 'Polygon', + coordinates: [ + [lng, lat], + [lng, lat], + ], + }); done(); }); - it('bytes', (done) => { - var input = {binaryData: "aGVsbG8gd29ybGQ="}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { binaryData: { type: 'Bytes' }}, + it('bytes', done => { + const input = { binaryData: 'aGVsbG8gd29ybGQ=' }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { binaryData: { type: 'Bytes' } }, }); expect(typeof output.binaryData).toEqual('object'); - expect(output.binaryData).toEqual( - {__type: 'Bytes', base64: "aGVsbG8gd29ybGQ="} - ); + expect(output.binaryData).toEqual({ + __type: 'Bytes', + base64: 'aGVsbG8gd29ybGQ=', + }); done(); }); - it('nested array', (done) => { - var input = {arr: [{_testKey: 'testValue' }]}; - var output = transform.mongoObjectToParseObject(null, input, { + it('nested array', done => { + const input = { arr: [{ _testKey: 'testValue' }] }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { arr: { type: 'Array' } }, }); expect(Array.isArray(output.arr)).toEqual(true); - expect(output.arr).toEqual([{ _testKey: 'testValue'}]); + expect(output.arr).toEqual([{ _testKey: 'testValue' }]); done(); }); it('untransforms objects containing nested special keys', done => { - const input = {array: [{ - _id: "Test ID", - _hashed_password: "I Don't know why you would name a key this, but if you do it should work", - _tombstone: { - _updated_at: "I'm sure people will nest keys like this", - _acl: 7, - _id: { someString: "str", someNumber: 7}, - regularKey: { moreContents: [1, 2, 3] }, - }, - regularKey: "some data", - }]} + const input = { + array: [ + { + _id: 'Test ID', + _hashed_password: + "I Don't know why you would name a key this, but if you do it should work", + _tombstone: { + _updated_at: "I'm sure people will nest keys like this", + _acl: 7, + _id: { someString: 'str', someNumber: 7 }, + regularKey: { moreContents: [1, 2, 3] }, + }, + regularKey: 'some data', + }, + ], + }; const output = transform.mongoObjectToParseObject(null, input, { - fields: { array: { type: 'Array' }}, + fields: { array: { type: 'Array' } }, }); expect(dd(output, input)).toEqual(undefined); done(); }); - it('changes new pointer key', (done) => { - var input = { - somePointer: {__type: 'Pointer', className: 'Micro', objectId: 'oft'} + it('changes new pointer key', done => { + const input = { + somePointer: { __type: 'Pointer', className: 'Micro', objectId: 'oft' }, }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {somePointer: {type: 'Pointer'}} + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { somePointer: { type: 'Pointer' } }, }); expect(typeof output._p_somePointer).toEqual('string'); expect(output._p_somePointer).toEqual('Micro$oft'); done(); }); - it('changes existing pointer keys', (done) => { - var input = { - userPointer: {__type: 'Pointer', className: '_User', objectId: 'qwerty'} + it('changes existing pointer keys', done => { + const input = { + userPointer: { + __type: 'Pointer', + className: '_User', + objectId: 'qwerty', + }, }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {userPointer: {type: 'Pointer'}} + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { userPointer: { type: 'Pointer' } }, }); expect(typeof output._p_userPointer).toEqual('string'); expect(output._p_userPointer).toEqual('_User$qwerty'); done(); }); - it('writes the old ACL format in addition to rperm and wperm on create', (done) => { - var input = { + it('writes the old ACL format in addition to rperm and wperm on create', done => { + const input = { _rperm: ['*'], _wperm: ['Kevin'], }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); expect(typeof output._acl).toEqual('object'); expect(output._acl['Kevin'].w).toBeTruthy(); expect(output._acl['Kevin'].r).toBeUndefined(); @@ -248,11 +342,11 @@ describe('parseObjectToMongoObjectForCreate', () => { done(); }); - it('removes Relation types', (done) => { - var input = { + it('removes Relation types', done => { + const input = { aRelation: { __type: 'Relation', className: 'Stuff' }, }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { + const output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: { aRelation: { __type: 'Relation', className: 'Stuff' }, }, @@ -261,14 +355,14 @@ describe('parseObjectToMongoObjectForCreate', () => { done(); }); - it('writes the old ACL format in addition to rperm and wperm on update', (done) => { - var input = { + it('writes the old ACL format in addition to rperm and wperm on update', done => { + const input = { _rperm: ['*'], - _wperm: ['Kevin'] + _wperm: ['Kevin'], }; - var output = transform.transformUpdate(null, input, { fields: {} }); - var set = output.$set; + const output = transform.transformUpdate(null, input, { fields: {} }); + const set = output.$set; expect(typeof set).toEqual('object'); expect(typeof set._acl).toEqual('object'); expect(set._acl['Kevin'].w).toBeTruthy(); @@ -278,24 +372,26 @@ describe('parseObjectToMongoObjectForCreate', () => { done(); }); - it('untransforms from _rperm and _wperm to ACL', (done) => { - var input = { - _rperm: ["*"], - _wperm: ["Kevin"] + it('untransforms from _rperm and _wperm to ACL', done => { + const input = { + _rperm: ['*'], + _wperm: ['Kevin'], }; - var output = transform.mongoObjectToParseObject(null, input, { fields: {} }); + const output = transform.mongoObjectToParseObject(null, input, { + fields: {}, + }); expect(output._rperm).toEqual(['*']); expect(output._wperm).toEqual(['Kevin']); - expect(output.ACL).toBeUndefined() + expect(output.ACL).toBeUndefined(); done(); }); - it('untransforms mongodb number types', (done) => { - var input = { + it('untransforms mongodb number types', done => { + const input = { long: mongodb.Long.fromNumber(Number.MAX_SAFE_INTEGER), - double: new mongodb.Double(Number.MAX_VALUE) - } - var output = transform.mongoObjectToParseObject(null, input, { + double: new mongodb.Double(Number.MAX_VALUE), + }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { long: { type: 'Number' }, double: { type: 'Number' }, @@ -306,39 +402,151 @@ describe('parseObjectToMongoObjectForCreate', () => { done(); }); - it('Date object where iso attribute is of type Date', (done) => { - var input = { - ts : { __type: 'Date', iso: new Date('2017-01-18T00:00:00.000Z') } - } - var output = transform.mongoObjectToParseObject(null, input, { - fields : { - ts : { type : 'Date' } - } + it('Date object where iso attribute is of type Date', done => { + const input = { + ts: { __type: 'Date', iso: new Date('2017-01-18T00:00:00.000Z') }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + ts: { type: 'Date' }, + }, }); expect(output.ts.iso).toEqual('2017-01-18T00:00:00.000Z'); done(); }); - it('Date object where iso attribute is of type String', (done) => { - var input = { - ts : { __type: 'Date', iso: '2017-01-18T00:00:00.000Z' } - } - var output = transform.mongoObjectToParseObject(null, input, { - fields : { - ts : { type : 'Date' } - } + it('Date object where iso attribute is of type String', done => { + const input = { + ts: { __type: 'Date', iso: '2017-01-18T00:00:00.000Z' }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + ts: { type: 'Date' }, + }, }); expect(output.ts.iso).toEqual('2017-01-18T00:00:00.000Z'); done(); }); + + it('object with undefined nested values', () => { + const input = { + _id: 'vQHyinCW1l', + urls: { firstUrl: 'https://', secondUrl: undefined }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + urls: { type: 'Object' }, + }, + }); + expect(output.urls).toEqual({ + firstUrl: 'https://', + secondUrl: undefined, + }); + }); + + it('undefined objects', () => { + const input = { + _id: 'vQHyinCW1l', + urls: undefined, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + urls: { type: 'Object' }, + }, + }); + expect(output.urls).toBeUndefined(); + }); + + it('$regex in $all list', done => { + const input = { + arrayField: { + $all: [ + { $regex: '^\\Qone\\E' }, + { $regex: '^\\Qtwo\\E' }, + { $regex: '^\\Qthree\\E' }, + ], + }, + }; + const outputValue = { + arrayField: { $all: [/^\Qone\E/, /^\Qtwo\E/, /^\Qthree\E/] }, + }; + + const output = transform.transformWhere(null, input); + jequal(outputValue.arrayField, output.arrayField); + done(); + }); + + it('$regex in $all list must be { $regex: "string" }', done => { + const input = { + arrayField: { $all: [{ $regex: 1 }] }, + }; + + expect(() => { + transform.transformWhere(null, input); + }).toThrow(); + done(); + }); + + it('all values in $all must be $regex (start with string) or non $regex (start with string)', done => { + const input = { + arrayField: { + $all: [{ $regex: '^\\Qone\\E' }, { $unknown: '^\\Qtwo\\E' }], + }, + }; + + expect(() => { + transform.transformWhere(null, input); + }).toThrow(); + done(); + }); + + it('ignores User authData field in DB so it can be synthesized in code', done => { + const input = { + _id: '123', + _auth_data_acme: { id: 'abc' }, + authData: null, + }; + const output = transform.mongoObjectToParseObject('_User', input, { + fields: {}, + }); + expect(output.authData.acme.id).toBe('abc'); + done(); + }); + + it('can set authData when not User class', done => { + const input = { + _id: '123', + authData: 'random', + }; + const output = transform.mongoObjectToParseObject('TestObject', input, { + fields: {}, + }); + expect(output.authData).toBe('random'); + done(); + }); +}); + +it('cannot have a custom field name beginning with underscore', done => { + const input = { + _id: '123', + _thisFieldNameIs: 'invalid', + }; + try { + transform.mongoObjectToParseObject('TestObject', input, { + fields: {}, + }); + } catch (e) { + expect(e).toBeDefined(); + } + done(); }); describe('transformUpdate', () => { - it('removes Relation types', (done) => { - var input = { + it('removes Relation types', done => { + const input = { aRelation: { __type: 'Relation', className: 'Stuff' }, }; - var output = transform.transformUpdate(null, input, { + const output = transform.transformUpdate(null, input, { fields: { aRelation: { __type: 'Relation', className: 'Stuff' }, }, @@ -356,8 +564,8 @@ describe('transformConstraint', () => { $eq: { ttl: { $relativeTime: '12 days ago', - } - } + }, + }, }); }).toThrow(); @@ -366,8 +574,8 @@ describe('transformConstraint', () => { $ne: { ttl: { $relativeTime: '12 days ago', - } - } + }, + }, }); }).toThrow(); @@ -375,11 +583,11 @@ describe('transformConstraint', () => { transform.transformConstraint({ $exists: { $relativeTime: '12 days ago', - } + }, }); }).toThrow(); }); - }) + }); }); describe('relativeTimeToDate', () => { @@ -387,9 +595,9 @@ describe('relativeTimeToDate', () => { describe('In the future', () => { it('should parse valid natural time', () => { - const text = 'in 12 days 10 hours 24 minutes 30 seconds'; + const text = 'in 1 year 2 weeks 12 days 10 hours 24 minutes 30 seconds'; const { result, status, info } = transform.relativeTimeToDate(text, now); - expect(result.toISOString()).toBe('2017-10-08T23:52:46.617Z'); + expect(result.toISOString()).toBe('2018-10-22T23:52:46.617Z'); expect(status).toBe('success'); expect(info).toBe('future'); }); @@ -405,9 +613,21 @@ describe('relativeTimeToDate', () => { }); }); + describe('From now', () => { + it('should equal current time', () => { + const text = 'now'; + const { result, status, info } = transform.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2017-09-26T13:28:16.617Z'); + expect(status).toBe('success'); + expect(info).toBe('present'); + }); + }); + describe('Error cases', () => { it('should error if string is completely gibberish', () => { - expect(transform.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123')).toEqual({ + expect( + transform.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123') + ).toEqual({ status: 'error', info: "Time should either start with 'in' or end with 'ago'", }); @@ -461,4 +681,3 @@ describe('relativeTimeToDate', () => { }); }); }); - diff --git a/spec/NullCacheAdapter.spec.js b/spec/NullCacheAdapter.spec.js index a1943559f5..8c240eef9b 100644 --- a/spec/NullCacheAdapter.spec.js +++ b/spec/NullCacheAdapter.spec.js @@ -1,12 +1,13 @@ -var NullCacheAdapter = require('../src/Adapters/Cache/NullCacheAdapter').default; +const NullCacheAdapter = require('../lib/Adapters/Cache/NullCacheAdapter') + .default; describe('NullCacheAdapter', function() { - var KEY = 'hello'; - var VALUE = 'world'; + const KEY = 'hello'; + const VALUE = 'world'; - it('should expose promisifyed methods', (done) => { - var cache = new NullCacheAdapter({ - ttl: NaN + it('should expose promisifyed methods', done => { + const cache = new NullCacheAdapter({ + ttl: NaN, }); // Verify all methods return promises. @@ -14,24 +15,24 @@ describe('NullCacheAdapter', function() { cache.put(KEY, VALUE), cache.del(KEY), cache.get(KEY), - cache.clear() + cache.clear(), ]).then(() => { done(); }); }); - it('should get/set/clear', (done) => { - var cache = new NullCacheAdapter({ - ttl: NaN + it('should get/set/clear', done => { + const cache = new NullCacheAdapter({ + ttl: NaN, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(() => cache.clear()) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); }); - }); diff --git a/spec/OAuth1.spec.js b/spec/OAuth1.spec.js index 5c64f7e368..8d256f755f 100644 --- a/spec/OAuth1.spec.js +++ b/spec/OAuth1.spec.js @@ -1,141 +1,154 @@ -var OAuth = require("../src/Adapters/Auth/OAuth1Client"); +const OAuth = require('../lib/Adapters/Auth/OAuth1Client'); describe('OAuth', function() { - it("Nonce should have right length", (done) => { + it('Nonce should have right length', done => { jequal(OAuth.nonce().length, 30); done(); }); - it("Should properly build parameter string", (done) => { - var string = OAuth.buildParameterString({c:1, a:2, b:3}) - jequal(string, "a=2&b=3&c=1"); + it('Should properly build parameter string', done => { + const string = OAuth.buildParameterString({ c: 1, a: 2, b: 3 }); + jequal(string, 'a=2&b=3&c=1'); done(); }); - it("Should properly build empty parameter string", (done) => { - var string = OAuth.buildParameterString() - jequal(string, ""); + it('Should properly build empty parameter string', done => { + const string = OAuth.buildParameterString(); + jequal(string, ''); done(); }); - it("Should properly build signature string", (done) => { - var string = OAuth.buildSignatureString("get", "http://dummy.com", ""); - jequal(string, "GET&http%3A%2F%2Fdummy.com&"); + it('Should properly build signature string', done => { + const string = OAuth.buildSignatureString('get', 'http://dummy.com', ''); + jequal(string, 'GET&http%3A%2F%2Fdummy.com&'); done(); }); - it("Should properly generate request signature", (done) => { - var request = { - host: "dummy.com", - path: "path" + it('Should properly generate request signature', done => { + let request = { + host: 'dummy.com', + path: 'path', }; - var oauth_params = { + const oauth_params = { oauth_timestamp: 123450000, - oauth_nonce: "AAAAAAAAAAAAAAAAA", - oauth_consumer_key: "hello", - oauth_token: "token" + oauth_nonce: 'AAAAAAAAAAAAAAAAA', + oauth_consumer_key: 'hello', + oauth_token: 'token', }; - var consumer_secret = "world"; - var auth_token_secret = "secret"; - request = OAuth.signRequest(request, oauth_params, consumer_secret, auth_token_secret); - jequal(request.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"'); + const consumer_secret = 'world'; + const auth_token_secret = 'secret'; + request = OAuth.signRequest( + request, + oauth_params, + consumer_secret, + auth_token_secret + ); + jequal( + request.headers['Authorization'], + 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"' + ); done(); }); - it("Should properly build request", (done) => { - var options = { - host: "dummy.com", - consumer_key: "hello", - consumer_secret: "world", - auth_token: "token", - auth_token_secret: "secret", + it('Should properly build request', done => { + const options = { + host: 'dummy.com', + consumer_key: 'hello', + consumer_secret: 'world', + auth_token: 'token', + auth_token_secret: 'secret', // Custom oauth params for tests oauth_params: { oauth_timestamp: 123450000, - oauth_nonce: "AAAAAAAAAAAAAAAAA" - } + oauth_nonce: 'AAAAAAAAAAAAAAAAA', + }, }; - var path = "path"; - var method = "get"; + const path = 'path'; + const method = 'get'; - var oauthClient = new OAuth(options); - var req = oauthClient.buildRequest(method, path, {"query": "param"}); + const oauthClient = new OAuth(options); + const req = oauthClient.buildRequest(method, path, { query: 'param' }); jequal(req.host, options.host); - jequal(req.path, "/" + path + "?query=param"); - jequal(req.method, "GET"); + jequal(req.path, '/' + path + '?query=param'); + jequal(req.method, 'GET'); jequal(req.headers['Content-Type'], 'application/x-www-form-urlencoded'); - jequal(req.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"') + jequal( + req.headers['Authorization'], + 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"' + ); done(); }); - function validateCannotAuthenticateError(data, done) { - jequal(typeof data, "object"); - jequal(typeof data.errors, "object"); - var errors = data.errors; - jequal(typeof errors[0], "object"); + jequal(typeof data, 'object'); + jequal(typeof data.errors, 'object'); + const errors = data.errors; + jequal(typeof errors[0], 'object'); // Cannot authenticate error jequal(errors[0].code, 32); done(); } - it("Should fail a GET request", (done) => { - var options = { - host: "api.twitter.com", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + it('Should fail a GET request', done => { + const options = { + host: 'api.twitter.com', + consumer_key: 'XXXXXXXXXXXXXXXXXXXXXXXXX', + consumer_secret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', }; - var path = "/1.1/help/configuration.json"; - var params = {"lang": "en"}; - var oauthClient = new OAuth(options); - oauthClient.get(path, params).then(function(data){ + const path = '/1.1/help/configuration.json'; + const params = { lang: 'en' }; + const oauthClient = new OAuth(options); + oauthClient.get(path, params).then(function(data) { validateCannotAuthenticateError(data, done); - }) + }); }); - it("Should fail a POST request", (done) => { - var options = { - host: "api.twitter.com", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + it('Should fail a POST request', done => { + const options = { + host: 'api.twitter.com', + consumer_key: 'XXXXXXXXXXXXXXXXXXXXXXXXX', + consumer_secret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', }; - var body = { - lang: "en" + const body = { + lang: 'en', }; - var path = "/1.1/account/settings.json"; + const path = '/1.1/account/settings.json'; - var oauthClient = new OAuth(options); - oauthClient.post(path, null, body).then(function(data){ + const oauthClient = new OAuth(options); + oauthClient.post(path, null, body).then(function(data) { validateCannotAuthenticateError(data, done); - }) + }); }); - it("Should fail a request", (done) => { - var options = { - host: "localhost", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + it('Should fail a request', done => { + const options = { + host: 'localhost', + consumer_key: 'XXXXXXXXXXXXXXXXXXXXXXXXX', + consumer_secret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', }; - var body = { - lang: "en" + const body = { + lang: 'en', }; - var path = "/"; - - var oauthClient = new OAuth(options); - oauthClient.post(path, null, body).then(function(){ - jequal(false, true); - done(); - }).catch(function(){ - jequal(true, true); - done(); - }) + const path = '/'; + + const oauthClient = new OAuth(options); + oauthClient + .post(path, null, body) + .then(function() { + jequal(false, true); + done(); + }) + .catch(function() { + jequal(true, true); + done(); + }); }); - it("Should fail with missing options", (done) => { - var options = undefined; + it('Should fail with missing options', done => { + const options = undefined; try { new OAuth(options); } catch (error) { diff --git a/spec/Parse.Push.spec.js b/spec/Parse.Push.spec.js index a5771aef88..6a424935b9 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -1,24 +1,24 @@ 'use strict'; -const request = require('request'); +const request = require('../lib/request'); -const delayPromise = (delay) => { - return new Promise((resolve) => { +const delayPromise = delay => { + return new Promise(resolve => { setTimeout(resolve, delay); }); -} +}; describe('Parse.Push', () => { - var setup = function() { - var sendToInstallationSpy = jasmine.createSpy(); + const setup = function() { + const sendToInstallationSpy = jasmine.createSpy(); - var pushAdapter = { + const pushAdapter = { send: function(body, installations) { - var badge = body.data.badge; - const promises = installations.map((installation) => { + const badge = body.data.badge; + const promises = installations.map(installation => { sendToInstallationSpy(installation); - if (installation.deviceType == "ios") { + if (installation.deviceType == 'ios') { expect(installation.badge).toEqual(badge); expect(installation.originalBadge + 1).toEqual(installation.badge); } else { @@ -27,33 +27,39 @@ describe('Parse.Push', () => { return Promise.resolve({ err: null, device: installation, - transmitted: true - }) + transmitted: true, + }); }); return Promise.all(promises); }, getValidPushTypes: function() { - return ["ios", "android"]; - } - } + return ['ios', 'android']; + }, + }; return reconfigureServer({ appId: Parse.applicationId, masterKey: Parse.masterKey, serverURL: Parse.serverURL, push: { - adapter: pushAdapter - } + adapter: pushAdapter, + }, }) .then(() => { - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } return Parse.Object.saveAll(installations); @@ -63,83 +69,102 @@ describe('Parse.Push', () => { sendToInstallationSpy, }; }) - .catch((err) => { + .catch(err => { console.error(err); throw err; - }) - } + }); + }; - it('should properly send push', (done) => { - return setup().then(({ sendToInstallationSpy }) => { - return Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'Increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - .then(() => { - return delayPromise(500); - }) - .then(() => { - expect(sendToInstallationSpy.calls.count()).toEqual(10); - }) - }).then(() => { - done(); - }).catch((err) => { - jfail(err); - done(); - }); + it('should properly send push', done => { + return setup() + .then(({ sendToInstallationSpy }) => { + return Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + badge: 'Increment', + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ) + .then(() => { + return delayPromise(500); + }) + .then(() => { + expect(sendToInstallationSpy.calls.count()).toEqual(10); + }); + }) + .then(() => { + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('should properly send push with lowercaseIncrement', (done) => { - return setup().then(() => { - return Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - }).then(() => { - return delayPromise(500); - }).then(() => { - done(); - }).catch((err) => { - jfail(err); - done(); - }); + it('should properly send push with lowercaseIncrement', done => { + return setup() + .then(() => { + return Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + return delayPromise(500); + }) + .then(() => { + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); it('should not allow clients to query _PushStatus', done => { setup() - .then(() => Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true})) + .then(() => + Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ) + ) .then(() => delayPromise(500)) .then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/classes/_PushStatus', json: true, headers: { 'X-Parse-Application-Id': 'test', }, - }, (error, response, body) => { - expect(body.error).toEqual('unauthorized'); + }).then(fail, response => { + expect(response.data.error).toEqual('unauthorized'); done(); }); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); }); @@ -147,60 +172,366 @@ describe('Parse.Push', () => { it('should allow master key to query _PushStatus', done => { setup() - .then(() => Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true})) + .then(() => + Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ) + ) .then(() => delayPromise(500)) // put a delay as we keep writing .then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/classes/_PushStatus', json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, - }, (error, response, body) => { + }).then(response => { + const body = response.data; try { expect(body.results.length).toEqual(1); expect(body.results[0].query).toEqual('{"deviceType":"ios"}'); - expect(body.results[0].payload).toEqual('{"badge":"increment","alert":"Hello world!"}'); - } catch(e) { + expect(body.results[0].payload).toEqual( + '{"badge":"increment","alert":"Hello world!"}' + ); + } catch (e) { jfail(e); } done(); }); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); }); }); it('should throw error if missing push configuration', done => { - reconfigureServer({push: null}) + reconfigureServer({ push: null }) .then(() => { - return Parse.Push.send({ - where: { - deviceType: 'ios' + return Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, }, - data: { - badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - }).then(() => { - fail('should not succeed'); - }, (err) => { - expect(err.code).toEqual(Parse.Error.PUSH_MISCONFIGURED); - done(); - }).catch((err) => { + { useMasterKey: true } + ); + }) + .then( + () => { + fail('should not succeed'); + }, + err => { + expect(err.code).toEqual(Parse.Error.PUSH_MISCONFIGURED); + done(); + } + ) + .catch(err => { jfail(err); done(); }); }); + + const successfulAny = function(body, installations) { + const promises = installations.map(device => { + return Promise.resolve({ + transmitted: true, + device: device, + }); + }); + + return Promise.all(promises); + }; + + const provideInstallations = function(num) { + if (!num) { + num = 2; + } + + const installations = []; + while (installations.length !== num) { + // add Android installations + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('deviceType', 'android'); + installations.push(installation); + } + + return installations; + }; + + const losingAdapter = { + send: function(body, installations) { + // simulate having lost an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.pop(); + + return successfulAny(body, installations); + }, + getValidPushTypes: function() { + return ['android']; + }, + }; + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates a simple push where 1 installation is removed between _PushStatus + * count being set and the pushes being sent + */ + it("does not get stuck with _PushStatus 'running' on 1 installation lost", done => { + reconfigureServer({ + push: { adapter: losingAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(provideInstallations()); + }) + .then(() => { + return Parse.Push.send( + { + data: { alert: 'We fixed our status!' }, + where: { deviceType: 'android' }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + // it is enqueued so it can take time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // query for push status + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // verify status is NOT broken + expect(results.length).toBe(1); + const result = results[0]; + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(1); + expect(result.get('count')).toEqual(undefined); + done(); + }); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates a simple push where 1 installation is added between _PushStatus + * count being set and the pushes being sent + */ + it("does not get stuck with _PushStatus 'running' on 1 installation added", done => { + const installations = provideInstallations(); + + // add 1 iOS installation which we will omit & add later on + const iOSInstallation = new Parse.Object('_Installation'); + iOSInstallation.set( + 'installationId', + 'installation_' + installations.length + ); + iOSInstallation.set('deviceToken', 'device_token_' + installations.length); + iOSInstallation.set('deviceType', 'ios'); + installations.push(iOSInstallation); + + reconfigureServer({ + push: { + adapter: { + send: function(body, installations) { + // simulate having added an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.push(iOSInstallation); + + return successfulAny(body, installations); + }, + getValidPushTypes: function() { + return ['android']; + }, + }, + }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return Parse.Push.send( + { + data: { alert: 'We fixed our status!' }, + where: { deviceType: { $ne: 'random' } }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + // it is enqueued so it can take time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // query for push status + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // verify status is NOT broken + expect(results.length).toBe(1); + const result = results[0]; + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(3); + expect(result.get('count')).toEqual(undefined); + done(); + }); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates an extended push, where some installations may be removed, + * resulting in a non-zero count + */ + it("does not get stuck with _PushStatus 'running' on many installations removed", done => { + const devices = 1000; + const installations = provideInstallations(devices); + + reconfigureServer({ + push: { adapter: losingAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return Parse.Push.send( + { + data: { alert: 'We fixed our status!' }, + where: { deviceType: 'android' }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + // it is enqueued so it can take time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // query for push status + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // verify status is NOT broken + expect(results.length).toBe(1); + const result = results[0]; + expect(result.get('status')).toEqual('succeeded'); + // expect # less than # of batches used, assuming each batch is 100 pushes + expect(result.get('numSent')).toEqual(devices - devices / 100); + expect(result.get('count')).toEqual(undefined); + done(); + }); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates an extended push, where some installations may be added, + * resulting in a non-zero count + */ + it("does not get stuck with _PushStatus 'running' on many installations added", done => { + const devices = 1000; + const installations = provideInstallations(devices); + + // add 1 iOS installation which we will omit & add later on + const iOSInstallations = []; + + while (iOSInstallations.length !== devices / 100) { + const iOSInstallation = new Parse.Object('_Installation'); + iOSInstallation.set( + 'installationId', + 'installation_' + installations.length + ); + iOSInstallation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + iOSInstallation.set('deviceType', 'ios'); + installations.push(iOSInstallation); + iOSInstallations.push(iOSInstallation); + } + + reconfigureServer({ + push: { + adapter: { + send: function(body, installations) { + // simulate having added an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.push(iOSInstallations.pop()); + + return successfulAny(body, installations); + }, + getValidPushTypes: function() { + return ['android']; + }, + }, + }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return Parse.Push.send( + { + data: { alert: 'We fixed our status!' }, + where: { deviceType: { $ne: 'random' } }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + // it is enqueued so it can take time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // query for push status + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // verify status is NOT broken + expect(results.length).toBe(1); + const result = results[0]; + expect(result.get('status')).toEqual('succeeded'); + // expect # less than # of batches used, assuming each batch is 100 pushes + expect(result.get('numSent')).toEqual(devices + devices / 100); + expect(result.get('count')).toEqual(undefined); + done(); + }); + }); }); diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index b5bd723b2a..80e5a40081 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -1,1265 +1,934 @@ // This is a port of the test suite: // hungry/js/test/parse_acl_test.js -var rest = require('../src/rest'); -var Config = require('../src/Config'); -var auth = require('../src/Auth'); +const rest = require('../lib/rest'); +const Config = require('../lib/Config'); +const auth = require('../lib/Auth'); describe('Parse.ACL', () => { - it("acl must be valid", (done) => { - var user = new Parse.User(); - ok(!user.setACL("Ceci n'est pas un ACL.", { - error: function(user, error) { - equal(error.code, -1); - done(); - } - }), "setACL should have returned false."); + it('acl must be valid', done => { + const user = new Parse.User(); + ok( + !user.setACL("Ceci n'est pas un ACL.", { + error: function(user, error) { + equal(error.code, -1); + done(); + }, + }), + 'setACL should have returned false.' + ); }); - it("refresh object with acl", (done) => { + it('refresh object with acl', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - // Refreshing the object should succeed. - object.fetch({ - success: function() { - done(); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(null); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + await object.fetch(); + done(); }); - it("acl an object owned by one user and public get", (done) => { + it('acl an object owned by one user and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function() { - fail('Should not have retrieved the object.'); - done(); - }, - error: function(model, error) { - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + const query = new Parse.Query(TestObject); + try { + await query.get(object.id); + done.fail('Should not have retrieved the object.'); + } catch (error) { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("acl an object owned by one user and public find", (done) => { + it('acl an object owned by one user and public find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 0); + done(); }); - it("acl an object owned by one user and public update", (done) => { + it('acl an object owned by one user and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - fail('Should not have been able to update the object.'); - done(); - }, error: function(model, err) { - equal(err.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + // Update + object.set('foo', 'bar'); + try { + await object.save(); + done.fail('Should not have been able to update the object.'); + } catch (err) { + equal(err.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("acl an object owned by one user and public delete", (done) => { + it('acl an object owned by one user and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('destroy should fail'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + try { + await object.destroy(); + done.fail('destroy should fail'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("acl an object owned by one user and logged in get", (done) => { + it('acl an object owned by one user and logged in get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - equal(result.getACL().getReadAccess(user), true); - equal(result.getACL().getWriteAccess(user), true); - equal(result.getACL().getPublicReadAccess(), false); - equal(result.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Get + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + ok(result); + equal(result.id, object.id); + equal(result.getACL().getReadAccess(user), true); + equal(result.getACL().getWriteAccess(user), true); + equal(result.getACL().getPublicReadAccess(), false); + equal(result.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + done(); }); - it("acl an object owned by one user and logged in find", (done) => { + it('acl an object owned by one user and logged in find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - if (!result) { - return fail(); - } - equal(result.id, object.id); - equal(result.getACL().getReadAccess(user), true); - equal(result.getACL().getWriteAccess(user), true); - equal(result.getACL().getPublicReadAccess(), false); - equal(result.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const result = results[0]; + ok(result); + if (!result) { + return fail(); + } + equal(result.id, object.id); + equal(result.getACL().getReadAccess(user), true); + equal(result.getACL().getWriteAccess(user), true); + equal(result.getACL().getPublicReadAccess(), false); + equal(result.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + done(); }); - it("acl an object owned by one user and logged in update", (done) => { + it('acl an object owned by one user and logged in update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Update + object.set('foo', 'bar'); + await object.save(); + done(); }); - it("acl an object owned by one user and logged in delete", (done) => { + it('acl an object owned by one user and logged in delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Delete - object.destroy({ - success: function() { - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Delete + await object.destroy(); + done(); }); - it("acl making an object publicly readable and public get", (done) => { + it('acl making an object publicly readable and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - done(); - } - }); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Get + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + ok(result); + equal(result.id, object.id); + done(); }); - it("acl making an object publicly readable and public find", (done) => { + it('acl making an object publicly readable and public find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - equal(result.id, object.id); - done(); - } - }); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const result = results[0]; + ok(result); + equal(result.id, object.id); + done(); }); - it("acl making an object publicly readable and public update", (done) => { + it('acl making an object publicly readable and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save().then(() => { - fail('the save should fail'); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + object.set('foo', 'bar'); + object.save().then( + () => { + fail('the save should fail'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); } - }); + ); }); - it("acl making an object publicly readable and public delete", (done) => { + it('acl making an object publicly readable and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('expected failure'); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + Parse.User.logOut() + .then(() => object.destroy()) + .then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - it("acl making an object publicly writable and public get", (done) => { + it('acl making an object publicly writable and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - error: function(model, error) { - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Get + const query = new Parse.Query(TestObject); + query + .get(object.id) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + }); }); - it("acl making an object publicly writable and public find", (done) => { + it('acl making an object publicly writable and public find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + query.find().then(function(results) { + equal(results.length, 0); + done(); }); }); - it("acl making an object publicly writable and public update", (done) => { + it('acl making an object publicly writable and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + Parse.User.logOut().then(() => { + // Update + object.set('foo', 'bar'); + object.save().then(done); }); }); - it("acl making an object publicly writable and public delete", (done) => { + it('acl making an object publicly writable and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Delete - object.destroy({ - success: function() { - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + Parse.User.logOut().then(() => { + // Delete + object.destroy().then(done); }); }); - it("acl making an object privately writable (#3194)", (done) => { + it('acl making an object privately writable (#3194)', done => { // Create an object owned by Alice. - var object; - var user2; - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp().then(() => { - object = new TestObject(); - var acl = new Parse.ACL(user); - acl.setPublicWriteAccess(false); - acl.setPublicReadAccess(true); - object.setACL(acl); - return object.save().then(() => { - return Parse.User.logOut(); + let object; + let user2; + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + user + .signUp() + .then(() => { + object = new TestObject(); + const acl = new Parse.ACL(user); + acl.setPublicWriteAccess(false); + acl.setPublicReadAccess(true); + object.setACL(acl); + return object.save().then(() => { + return Parse.User.logOut(); + }); }) - }).then(() => { - user2 = new Parse.User(); - user2.set("username", "bob"); - user2.set("password", "burger"); - return user2.signUp(); - }).then(() => { - return object.destroy({sessionToken: user2.getSessionToken() }); - }).then(() => { - fail('should not be able to destroy the object'); - done(); - }, (err) => { - expect(err).not.toBeUndefined(); - done(); - }); + .then(() => { + user2 = new Parse.User(); + user2.set('username', 'bob'); + user2.set('password', 'burger'); + return user2.signUp(); + }) + .then(() => { + return object.destroy({ sessionToken: user2.getSessionToken() }); + }) + .then( + () => { + fail('should not be able to destroy the object'); + done(); + }, + err => { + expect(err).not.toBeUndefined(); + done(); + } + ); }); - it("acl sharing with another user and get", (done) => { + it('acl sharing with another user and get', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - done(); - } - }); - } - }); - } - }); - } - }); - }); - } + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + const query = new Parse.Query(TestObject); + query.get(object.id).then(result => { + ok(result); + equal(result.id, object.id); + done(); }); }); - it("acl sharing with another user and find", (done) => { + it('acl sharing with another user and find', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - if (!result) { - fail("should have result"); - } else { - equal(result.id, object.id); - } - done(); - } - }); - } - }); - } - }); - } - }); - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + const query = new Parse.Query(TestObject); + query.find().then(results => { + equal(results.length, 1); + const result = results[0]; + ok(result); + if (!result) { + fail('should have result'); + } else { + equal(result.id, object.id); } + done(); }); }); - it("acl sharing with another user and update", (done) => { + it('acl sharing with another user and update', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - } - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + object.set('foo', 'bar'); + object.save().then(done); }); - it("acl sharing with another user and delete", (done) => { + it('acl sharing with another user and delete', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - object.set("foo", "bar"); - object.destroy({ - success: function() { - done(); - } - }); - } - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + object.set('foo', 'bar'); + object.destroy().then(done); }); - it("acl sharing with another user and public get", (done) => { - // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - var query = new Parse.Query(TestObject); - query.get(object.id).then((result) => { - fail(result); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); - }); + it('acl sharing with another user and public get', async done => { + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + // Start making requests by the public. + await Parse.User.logOut(); + const query = new Parse.Query(TestObject); + query.get(object.id).then( + result => { + fail(result); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); } - }); + ); }); - it("acl sharing with another user and public find", (done) => { - // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - } - }); - } - }); - }); - } + it('acl sharing with another user and public find', async done => { + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut().then(() => { + const query = new Parse.Query(TestObject); + query.find().then(function(results) { + equal(results.length, 0); + done(); + }); }); }); - it("acl sharing with another user and public update", (done) => { + it('acl sharing with another user and public update', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - object.set("foo", "bar"); - object.save().then(() => { - fail('expected failure'); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); - }); - } + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut().then(() => { + object.set('foo', 'bar'); + object.save().then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); }); - it("acl sharing with another user and public delete", (done) => { + it('acl sharing with another user and public delete', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('expected failure'); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut() + .then(() => object.destroy()) + .then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - it("acl saveAll with permissions", (done) => { - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - var acl = new Parse.ACL(alice); - - var object1 = new TestObject(); - var object2 = new TestObject(); - object1.setACL(acl); - object2.setACL(acl); - Parse.Object.saveAll([object1, object2], { - success: function() { - equal(object1.getACL().getReadAccess(alice), true); - equal(object1.getACL().getWriteAccess(alice), true); - equal(object1.getACL().getPublicReadAccess(), false); - equal(object1.getACL().getPublicWriteAccess(), false); - equal(object2.getACL().getReadAccess(alice), true); - equal(object2.getACL().getWriteAccess(alice), true); - equal(object2.getACL().getPublicReadAccess(), false); - equal(object2.getACL().getPublicWriteAccess(), false); - - // Save all the objects after updating them. - object1.set("foo", "bar"); - object2.set("foo", "bar"); - Parse.Object.saveAll([object1, object2], { - success: function() { - var query = new Parse.Query(TestObject); - query.equalTo("foo", "bar"); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } - }); - } - }); - } - }); - } + it('acl saveAll with permissions', async done => { + const alice = await Parse.User.signUp('alice', 'wonderland'); + const acl = new Parse.ACL(alice); + const object1 = new TestObject(); + const object2 = new TestObject(); + object1.setACL(acl); + object2.setACL(acl); + await Parse.Object.saveAll([object1, object2]); + equal(object1.getACL().getReadAccess(alice), true); + equal(object1.getACL().getWriteAccess(alice), true); + equal(object1.getACL().getPublicReadAccess(), false); + equal(object1.getACL().getPublicWriteAccess(), false); + equal(object2.getACL().getReadAccess(alice), true); + equal(object2.getACL().getWriteAccess(alice), true); + equal(object2.getACL().getPublicReadAccess(), false); + equal(object2.getACL().getPublicWriteAccess(), false); + + // Save all the objects after updating them. + object1.set('foo', 'bar'); + object2.set('foo', 'bar'); + await Parse.Object.saveAll([object1, object2]); + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.find().then(function(results) { + equal(results.length, 2); + done(); }); }); - it("empty acl works", (done) => { - Parse.User.signUp("tdurden", "mayhem", { + it('empty acl works', async done => { + await Parse.User.signUp('tdurden', 'mayhem', { ACL: new Parse.ACL(), - foo: "bar" - }, { - success: function() { - Parse.User.logOut() - .then(() => { - Parse.User.logIn("tdurden", "mayhem", { - success: function(user) { - equal(user.get("foo"), "bar"); - done(); - }, - error: function(user, error) { - ok(null, "Error " + error.id + ": " + error.message); - done(); - } - }); - }); - }, - error: function(user, error) { - ok(null, "Error " + error.id + ": " + error.message); - done(); - } + foo: 'bar', }); + + await Parse.User.logOut(); + const user = await Parse.User.logIn('tdurden', 'mayhem'); + equal(user.get('foo'), 'bar'); + done(); }); - it("query for included object with ACL works", (done) => { - var obj1 = new Parse.Object("TestClass1"); - var obj2 = new Parse.Object("TestClass2"); - var acl = new Parse.ACL(); + it('query for included object with ACL works', async done => { + const obj1 = new Parse.Object('TestClass1'); + const obj2 = new Parse.Object('TestClass2'); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); - obj2.set("ACL", acl); - obj1.set("other", obj2); - obj1.save(null, expectSuccess({ - success: function() { - obj2._clearServerData(); - var query = new Parse.Query("TestClass1"); - query.first(expectSuccess({ - success: function(obj1Again) { - ok(!obj1Again.get("other").get("ACL")); - - query.include("other"); - query.first(expectSuccess({ - success: function(obj1AgainWithInclude) { - ok(obj1AgainWithInclude.get("other").get("ACL")); - done(); - } - })); - } - })); - } - })); + obj2.set('ACL', acl); + obj1.set('other', obj2); + await obj1.save(); + obj2._clearServerData(); + const query = new Parse.Query('TestClass1'); + const obj1Again = await query.first(); + ok(!obj1Again.get('other').get('ACL')); + + query.include('other'); + const obj1AgainWithInclude = await query.first(); + ok(obj1AgainWithInclude.get('other').get('ACL')); + done(); }); - it('restricted ACL does not have public access', (done) => { - var obj = new Parse.Object("TestClassMasterACL"); - var acl = new Parse.ACL(); + it('restricted ACL does not have public access', done => { + const obj = new Parse.Object('TestClassMasterACL'); + const acl = new Parse.ACL(); obj.set('ACL', acl); - obj.save().then(() => { - var query = new Parse.Query("TestClassMasterACL"); - return query.find(); - }).then((results) => { - ok(!results.length, 'Should not have returned object with secure ACL.'); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestClassMasterACL'); + return query.find(); + }) + .then(results => { + ok(!results.length, 'Should not have returned object with secure ACL.'); + done(); + }); }); it('regression test #701', done => { const config = Config.get('test'); - var anonUser = { + const anonUser = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } + id: '00000000-0000-0000-0000-000000000001', + }, + }, }; Parse.Cloud.afterSave(Parse.User, req => { if (!req.object.existed()) { - var user = req.object; - var acl = new Parse.ACL(user); + const user = req.object; + const acl = new Parse.ACL(user); user.setACL(acl); - user.save(null, {useMasterKey: true}).then(user => { - new Parse.Query('_User').get(user.objectId).then(() => { - fail('should not have fetched user without public read enabled'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); + user.save(null, { useMasterKey: true }).then(user => { + new Parse.Query('_User').get(user.objectId).then( + () => { + fail('should not have fetched user without public read enabled'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }, done.fail); } }); - rest.create(config, auth.nobody(config), '_User', anonUser) - }) + rest.create(config, auth.nobody(config), '_User', anonUser); + }); }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index ffc5b7ba19..8e0af10ac6 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -2,162 +2,208 @@ // It would probably be better to refactor them into different files. 'use strict'; -var request = require('request'); -const rp = require('request-promise'); -const Parse = require("parse/node"); -const Config = require('../src/Config'); -const SchemaController = require('../src/Controllers/SchemaController'); -var TestUtils = require('../src/TestUtils'); - -const userSchema = SchemaController.convertSchemaToAdapterSchema({ className: '_User', fields: Object.assign({}, SchemaController.defaultColumns._Default, SchemaController.defaultColumns._User) }); +const request = require('../lib/request'); +const Parse = require('parse/node'); +const Config = require('../lib/Config'); +const SchemaController = require('../lib/Controllers/SchemaController'); +const TestUtils = require('../lib/TestUtils'); + +const userSchema = SchemaController.convertSchemaToAdapterSchema({ + className: '_User', + fields: Object.assign( + {}, + SchemaController.defaultColumns._Default, + SchemaController.defaultColumns._User + ), +}); +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', +}; describe_only_db('mongo')('miscellaneous', () => { it('test rest_create_app', function(done) { - var appId; - Parse._request('POST', 'rest_create_app').then((res) => { - expect(typeof res.application_id).toEqual('string'); - expect(res.master_key).toEqual('master'); - appId = res.application_id; - Parse.initialize(appId, 'unused'); - var obj = new Parse.Object('TestObject'); - obj.set('foo', 'bar'); - return obj.save(); - }).then(() => { - const config = Config.get(appId); - return config.database.adapter.find('TestObject', { fields: {} }, {}, {}); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0]['foo']).toEqual('bar'); - done(); - }).fail(error => { - fail(JSON.stringify(error)); - done(); - }) + let appId; + Parse._request('POST', 'rest_create_app') + .then(res => { + expect(typeof res.application_id).toEqual('string'); + expect(res.master_key).toEqual('master'); + appId = res.application_id; + Parse.initialize(appId, 'unused'); + const obj = new Parse.Object('TestObject'); + obj.set('foo', 'bar'); + return obj.save(); + }) + .then(() => { + const config = Config.get(appId); + return config.database.adapter.find( + 'TestObject', + { fields: {} }, + {}, + {} + ); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0]['foo']).toEqual('bar'); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); }); -}) +}); describe('miscellaneous', function() { it('create a GameScore object', function(done) { - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('score', 1337); obj.save().then(function(obj) { expect(typeof obj.id).toBe('string'); expect(typeof obj.createdAt.toGMTString()).toBe('string'); done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + }, done.fail); }); it('get a TestObject', function(done) { - create({ 'bloop' : 'blarg' }, function(obj) { - var t2 = new TestObject({ objectId: obj.id }); - t2.fetch({ - success: function(obj2) { - expect(obj2.get('bloop')).toEqual('blarg'); - expect(obj2.id).toBeTruthy(); - expect(obj2.id).toEqual(obj.id); - done(); - }, - error: error => { - fail(JSON.stringify(error)); - 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'); + expect(obj2.id).toBeTruthy(); + expect(obj2.id).toEqual(obj.id); + done(); }); }); it('create a valid parse user', function(done) { - createTestUser(function(data) { + createTestUser().then(function(data) { expect(data.id).not.toBeUndefined(); expect(data.getSessionToken()).not.toBeUndefined(); expect(data.get('password')).toBeUndefined(); done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + }, done.fail); }); - it('fail to create a duplicate username', done => { - let numCreated = 0; + it('fail to create a duplicate username', async () => { let numFailed = 0; - const p1 = createTestUser(); - p1.then(() => { - numCreated++; - expect(numCreated).toEqual(1); - }) - .catch(error => { + let numCreated = 0; + const p1 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'asdf', + username: 'u1', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + response => { numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - }); - const p2 = createTestUser(); - p2.then(() => { - numCreated++; - expect(numCreated).toEqual(1); - }) - .catch(error => { + expect(response.data.code).toEqual(Parse.Error.USERNAME_TAKEN); + } + ); + + const p2 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'otherpassword', + username: 'u1', + email: 'email@other.email', + }, + headers, + }).then( + () => { + numCreated++; + }, + ({ data }) => { numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - }); - Parse.Promise.when([p1, p2]) - .then(() => { - fail('one of the users should not have been created'); - done(); - }) - .catch(done); + expect(data.code).toEqual(Parse.Error.USERNAME_TAKEN); + } + ); + + await Promise.all([p1, p2]); + expect(numFailed).toEqual(1); + expect(numCreated).toBe(1); }); - it('ensure that email is uniquely indexed', done => { + it('ensure that email is uniquely indexed', async () => { let numFailed = 0; let numCreated = 0; - const user1 = new Parse.User(); - user1.setPassword('asdf'); - user1.setUsername('u1'); - user1.setEmail('dupe@dupe.dupe'); - const p1 = user1.signUp(); - p1.then(() => { - numCreated++; - expect(numCreated).toEqual(1); - }, error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - }); + const p1 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'asdf', + username: 'u1', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + ({ data }) => { + numFailed++; + expect(data.code).toEqual(Parse.Error.EMAIL_TAKEN); + } + ); - const user2 = new Parse.User(); - user2.setPassword('asdf'); - user2.setUsername('u2'); - user2.setEmail('dupe@dupe.dupe'); - const p2 = user2.signUp(); - p2.then(() => { - numCreated++; - expect(numCreated).toEqual(1); - }, error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - }); + const p2 = request({ + url: Parse.serverURL + '/users', + method: 'POST', + body: { + password: 'asdf', + username: 'u2', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + ({ data }) => { + numFailed++; + expect(data.code).toEqual(Parse.Error.EMAIL_TAKEN); + } + ); - Parse.Promise.when([p1, p2]) - .then(() => { - fail('one of the users should not have been created'); - done(); - }) - .catch(done); + await Promise.all([p1, p2]); + expect(numFailed).toEqual(1); + expect(numCreated).toBe(1); }); - it('ensure that if people already have duplicate users, they can still sign up new users', done => { + it('ensure that if people already have duplicate users, they can still sign up new users', async done => { + try { + await Parse.User.logOut(); + } catch (e) { + /* ignore */ + } const config = Config.get('test'); // Remove existing data to clear out unique index TestUtils.destroyAllDataPermanently() .then(() => config.database.adapter.createClass('_User', userSchema)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'x', username: 'u' }).catch(fail)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'y', username: 'u' }).catch(fail)) - // Create a new server to try to recreate the unique indexes + .then(() => + config.database.adapter + .createObject('_User', userSchema, { objectId: 'x', username: 'u' }) + .catch(fail) + ) + .then(() => + config.database.adapter + .createObject('_User', userSchema, { objectId: 'y', username: 'u' }) + .catch(fail) + ) + // Create a new server to try to recreate the unique indexes .then(reconfigureServer) .catch(error => { expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); @@ -170,7 +216,7 @@ describe('miscellaneous', function() { const user = new Parse.User(); user.setPassword('asdf'); user.setUsername('u'); - return user.signUp() + return user.signUp(); }) .then(() => { fail('should not have been able to sign up'); @@ -179,7 +225,7 @@ describe('miscellaneous', function() { .catch(error => { expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); done(); - }) + }); }); it('ensure that if people already have duplicate emails, they can still sign up new users', done => { @@ -187,8 +233,18 @@ describe('miscellaneous', function() { // Remove existing data to clear out unique index TestUtils.destroyAllDataPermanently() .then(() => config.database.adapter.createClass('_User', userSchema)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'x', email: 'a@b.c' })) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'y', email: 'a@b.c' })) + .then(() => + config.database.adapter.createObject('_User', userSchema, { + objectId: 'x', + email: 'a@b.c', + }) + ) + .then(() => + config.database.adapter.createObject('_User', userSchema, { + objectId: 'y', + email: 'a@b.c', + }) + ) .then(reconfigureServer) .catch(() => { const user = new Parse.User(); @@ -202,7 +258,7 @@ describe('miscellaneous', function() { user.setPassword('asdf'); user.setUsername('www'); user.setEmail('a@b.c'); - return user.signUp() + return user.signUp(); }) .catch(error => { expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); @@ -212,15 +268,20 @@ describe('miscellaneous', function() { it('ensure that if you try to sign up a user with a unique username and email, but duplicates in some other field that has a uniqueness constraint, you get a regular duplicate value error', done => { const config = Config.get('test'); - config.database.adapter.addFieldIfNotExists('_User', 'randomField', { type: 'String' }) - .then(() => config.database.adapter.ensureUniqueness('_User', userSchema, ['randomField'])) + config.database.adapter + .addFieldIfNotExists('_User', 'randomField', { type: 'String' }) + .then(() => + config.database.adapter.ensureUniqueness('_User', userSchema, [ + 'randomField', + ]) + ) .then(() => { const user = new Parse.User(); user.setPassword('asdf'); user.setUsername('1'); user.setEmail('1@b.c'); user.set('randomField', 'a'); - return user.signUp() + return user.signUp(); }) .then(() => { const user = new Parse.User(); @@ -228,7 +289,7 @@ describe('miscellaneous', function() { user.setUsername('2'); user.setEmail('2@b.c'); user.set('randomField', 'a'); - return user.signUp() + return user.signUp(); }) .catch(error => { expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); @@ -237,143 +298,169 @@ describe('miscellaneous', function() { }); it('succeed in logging in', function(done) { - createTestUser(function(u) { + createTestUser().then(async function(u) { expect(typeof u.id).toEqual('string'); - Parse.User.logIn('test', 'moon-y', { - success: function(user) { - expect(typeof user.id).toEqual('string'); - expect(user.get('password')).toBeUndefined(); - expect(user.getSessionToken()).not.toBeUndefined(); - Parse.User.logOut().then(done); - }, error: error => { - fail(JSON.stringify(error)); - done(); - } - }); + const user = await Parse.User.logIn('test', 'moon-y'); + expect(typeof user.id).toEqual('string'); + expect(user.get('password')).toBeUndefined(); + expect(user.getSessionToken()).not.toBeUndefined(); + await Parse.User.logOut(); + done(); }, fail); }); it('increment with a user object', function(done) { - createTestUser().then((user) => { - user.increment('foo'); - return user.save(); - }).then(() => { - return Parse.User.logIn('test', 'moon-y'); - }).then((user) => { - expect(user.get('foo')).toEqual(1); - user.increment('foo'); - return user.save(); - }).then(() => Parse.User.logOut()) + createTestUser() + .then(user => { + user.increment('foo'); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('test', 'moon-y'); + }) + .then(user => { + expect(user.get('foo')).toEqual(1); + user.increment('foo'); + return user.save(); + }) + .then(() => Parse.User.logOut()) .then(() => Parse.User.logIn('test', 'moon-y')) - .then((user) => { - expect(user.get('foo')).toEqual(2); - Parse.User.logOut() - .then(done); - }, (error) => { - fail(JSON.stringify(error)); - done(); - }); + .then( + user => { + expect(user.get('foo')).toEqual(2); + Parse.User.logOut().then(done); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); }); it('save various data types', function(done) { - var obj = new TestObject(); + const obj = new TestObject(); obj.set('date', new Date()); obj.set('array', [1, 2, 3]); - obj.set('object', {one: 1, two: 2}); - obj.save().then(() => { - var obj2 = new TestObject({objectId: obj.id}); - return obj2.fetch(); - }).then((obj2) => { - expect(obj2.get('date') instanceof Date).toBe(true); - expect(obj2.get('array') instanceof Array).toBe(true); - expect(obj2.get('object') instanceof Array).toBe(false); - expect(obj2.get('object') instanceof Object).toBe(true); - done(); - }); + obj.set('object', { one: 1, two: 2 }); + obj + .save() + .then(() => { + const obj2 = new TestObject({ objectId: obj.id }); + return obj2.fetch(); + }) + .then(obj2 => { + expect(obj2.get('date') instanceof Date).toBe(true); + expect(obj2.get('array') instanceof Array).toBe(true); + expect(obj2.get('object') instanceof Array).toBe(false); + expect(obj2.get('object') instanceof Object).toBe(true); + done(); + }); }); it('query with limit', function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - baz.save().then(() => { - return qux.save(); - }).then(() => { - var query = new Parse.Query(TestObject); - query.limit(1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - fail(JSON.stringify(error)); - done(); - }); + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + baz + .save() + .then(() => { + return qux.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + query.limit(1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); }); it('query without limit get default 100 records', function(done) { - var objects = []; - for (var i = 0; i < 150; i++) { - objects.push(new TestObject({name: 'name' + i})); + const objects = []; + for (let i = 0; i < 150; i++) { + objects.push(new TestObject({ name: 'name' + i })); } - Parse.Object.saveAll(objects).then(() => { - return new Parse.Query(TestObject).find(); - }).then((results) => { - expect(results.length).toEqual(100); - done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + Parse.Object.saveAll(objects) + .then(() => { + return new Parse.Query(TestObject).find(); + }) + .then( + results => { + expect(results.length).toEqual(100); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); }); it('basic saveAll', function(done) { - var alpha = new TestObject({ letter: 'alpha' }); - var beta = new TestObject({ letter: 'beta' }); - Parse.Object.saveAll([alpha, beta]).then(() => { - expect(alpha.id).toBeTruthy(); - expect(beta.id).toBeTruthy(); - return new Parse.Query(TestObject).find(); - }).then((results) => { - expect(results.length).toEqual(2); - done(); - }, (error) => { - fail(error); - done(); - }); + const alpha = new TestObject({ letter: 'alpha' }); + const beta = new TestObject({ letter: 'beta' }); + Parse.Object.saveAll([alpha, beta]) + .then(() => { + expect(alpha.id).toBeTruthy(); + expect(beta.id).toBeTruthy(); + return new Parse.Query(TestObject).find(); + }) + .then( + results => { + expect(results.length).toEqual(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('test beforeSave set object acl success', function(done) { - var acl = new Parse.ACL({ - '*': { read: true, write: false } + const acl = new Parse.ACL({ + '*': { read: true, write: false }, }); - Parse.Cloud.beforeSave('BeforeSaveAddACL', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveAddACL', function(req) { req.object.setACL(acl); - res.success(); }); - var obj = new Parse.Object('BeforeSaveAddACL'); + const obj = new Parse.Object('BeforeSaveAddACL'); obj.set('lol', true); - obj.save().then(function() { - var query = new Parse.Query('BeforeSaveAddACL'); - query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('lol')).toBeTruthy(); - expect(objAgain.getACL().equals(acl)); - done(); - }, function(error) { - fail(error); + obj.save().then( + function() { + const query = new Parse.Query('BeforeSaveAddACL'); + query.get(obj.id).then( + function(objAgain) { + expect(objAgain.get('lol')).toBeTruthy(); + expect(objAgain.getACL().equals(acl)); + done(); + }, + function(error) { + fail(error); + done(); + } + ); + }, + error => { + fail(JSON.stringify(error)); done(); - }); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + } + ); }); it('object is set on create and update', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { + Parse.Cloud.beforeSave('GameScore', req => { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); @@ -391,54 +478,62 @@ describe('miscellaneous', function() { expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - fail(error); - done(); - }); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('works when object is passed to success', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { + Parse.Cloud.beforeSave('GameScore', req => { const object = req.object; object.set('foo', 'bar'); triggerTime++; - res.success(object); + return object; }); const obj = new Parse.Object('GameScore'); obj.set('foo', 'baz'); - obj.save().then(() => { - expect(triggerTime).toBe(1); - expect(obj.get('foo')).toEqual('bar'); - done(); - }, error => { - fail(error); - done(); - }); + obj.save().then( + () => { + expect(triggerTime).toBe(1); + expect(obj.get('foo')).toEqual('bar'); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('original object is set on update', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { + Parse.Cloud.beforeSave('GameScore', req => { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); @@ -466,106 +561,117 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - fail(error); - done(); - }); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('pointer mutation properly saves object', done => { const className = 'GameScore'; - Parse.Cloud.beforeSave(className, (req, res) => { + Parse.Cloud.beforeSave(className, req => { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); const child = object.get('child'); expect(child instanceof Parse.Object).toBeTruthy(); child.set('a', 'b'); - child.save().then(() => { - res.success(); - }); + return child.save(); }); const obj = new Parse.Object(className); obj.set('foo', 'bar'); const child = new Parse.Object('Child'); - child.save().then(() => { - obj.set('child', child); - return obj.save(); - }).then(() => { - const query = new Parse.Query(className); - query.include('child'); - return query.get(obj.id).then(objAgain => { - expect(objAgain.get('foo')).toEqual('bar'); + child + .save() + .then(() => { + obj.set('child', child); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(className); + query.include('child'); + return query.get(obj.id).then(objAgain => { + expect(objAgain.get('foo')).toEqual('bar'); - const childAgain = objAgain.get('child'); - expect(childAgain instanceof Parse.Object).toBeTruthy(); - expect(childAgain.get('a')).toEqual('b'); + const childAgain = objAgain.get('child'); + expect(childAgain instanceof Parse.Object).toBeTruthy(); + expect(childAgain.get('a')).toEqual('b'); - return Promise.resolve(); - }); - }).then(() => { - done(); - }, error => { - fail(error); - done(); - }); + return Promise.resolve(); + }); + }) + .then( + () => { + done(); + }, + error => { + fail(error); + done(); + } + ); }); - it('pointer reassign is working properly (#1288)', (done) => { - Parse.Cloud.beforeSave('GameScore', (req, res) => { - - var obj = req.object; + it('pointer reassign is working properly (#1288)', done => { + Parse.Cloud.beforeSave('GameScore', req => { + const obj = req.object; if (obj.get('point')) { - return res.success(); + return; } - var TestObject1 = Parse.Object.extend('TestObject1'); - var newObj = new TestObject1({'key1': 1}); + const TestObject1 = Parse.Object.extend('TestObject1'); + const newObj = new TestObject1({ key1: 1 }); - return newObj.save().then((newObj) => { - obj.set('point' , newObj); - res.success(); + return newObj.save().then(newObj => { + obj.set('point', newObj); }); }); - var pointId; - var obj = new Parse.Object('GameScore'); + let pointId; + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); - obj.save().then(() => { - expect(obj.get('point')).not.toBeUndefined(); - pointId = obj.get('point').id; - expect(pointId).not.toBeUndefined(); - obj.set('foo', 'baz'); - return obj.save(); - }).then((obj) => { - expect(obj.get('point').id).toEqual(pointId); - done(); - }) + obj + .save() + .then(() => { + expect(obj.get('point')).not.toBeUndefined(); + pointId = obj.get('point').id; + expect(pointId).not.toBeUndefined(); + obj.set('foo', 'baz'); + return obj.save(); + }) + .then(obj => { + expect(obj.get('point').id).toEqual(pointId); + done(); + }); }); it('test afterSave get full object on create and update', function(done) { - var triggerTime = 0; + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - var object = req.object; + Parse.Cloud.afterSave('GameScore', function(req) { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.id).not.toBeUndefined(); expect(object.createdAt).not.toBeUndefined(); @@ -578,41 +684,46 @@ describe('miscellaneous', function() { // Update expect(object.get('foo')).toEqual('baz'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - fail(error); - done(); - }); + obj + .save() + .then(function() { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function() { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function(error) { + fail(error); + done(); + } + ); }); it('test afterSave get original object on update', function(done) { - var triggerTime = 0; + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - var object = req.object; + Parse.Cloud.afterSave('GameScore', function(req) { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); expect(object.id).not.toBeUndefined(); expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); - var originalObject = req.original; + const originalObject = req.original; if (triggerTime == 0) { // Create expect(object.get('foo')).toEqual('bar'); @@ -629,35 +740,40 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - jfail(error); - done(); - }); + obj + .save() + .then(function() { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function() { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function(error) { + jfail(error); + done(); + } + ); }); - it('test afterSave get full original object even req auth can not query it', (done) => { - var triggerTime = 0; + 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, res) { - var object = req.object; - var originalObject = req.original; + Parse.Cloud.afterSave('GameScore', function(req) { + const object = req.object; + const originalObject = req.original; if (triggerTime == 0) { // Create } else if (triggerTime == 1) { @@ -671,38 +787,43 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - var acl = new Parse.ACL(); + const acl = new Parse.ACL(); // Make sure our update request can not query the object acl.setPublicReadAccess(false); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - jfail(error); - done(); - }); + obj + .save() + .then(function() { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function() { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function(error) { + jfail(error); + done(); + } + ); }); it('afterSave flattens custom operations', done => { - var triggerTime = 0; + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { + Parse.Cloud.afterSave('GameScore', function(req) { const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); const originalObject = req.original; @@ -715,31 +836,36 @@ describe('miscellaneous', function() { // Check the originalObject expect(originalObject.get('yolo')).toEqual(1); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.increment('yolo', 1); - obj.save().then(() => { - obj.increment('yolo', 1); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - jfail(error); - done(); - }); + obj + .save() + .then(() => { + obj.increment('yolo', 1); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); it('beforeSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', function(req, res) { + Parse.Cloud.beforeSave('GameScore', function(req) { const object = req.object; if (triggerTime == 0) { const acl = object.getACL(); @@ -750,10 +876,9 @@ describe('miscellaneous', function() { expect(acl.getPublicReadAccess()).toBeFalsy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); const obj = new Parse.Object('GameScore'); @@ -761,24 +886,30 @@ describe('miscellaneous', function() { acl.setPublicReadAccess(true); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(() => { - acl.setPublicReadAccess(false); - obj.setACL(acl); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - jfail(error); - done(); - }); + obj + .save() + .then(() => { + acl.setPublicReadAccess(false); + obj.setACL(acl); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); it('afterSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { + Parse.Cloud.afterSave('GameScore', function(req) { const object = req.object; if (triggerTime == 0) { const acl = object.getACL(); @@ -789,10 +920,9 @@ describe('miscellaneous', function() { expect(acl.getPublicReadAccess()).toBeFalsy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); const obj = new Parse.Object('GameScore'); @@ -800,120 +930,139 @@ describe('miscellaneous', function() { acl.setPublicReadAccess(true); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(() => { - acl.setPublicReadAccess(false); - obj.setACL(acl); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - jfail(error); - done(); - }); + obj + .save() + .then(() => { + acl.setPublicReadAccess(false); + obj.setACL(acl); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); it('should return the updated fields on PUT', done => { const obj = new Parse.Object('GameScore'); - obj.save({a:'hello', c: 1, d: ['1'], e:['1'], f:['1','2']}).then(() => { - var headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' - }; - request.put({ - headers: headers, - url: 'http://localhost:8378/1/classes/GameScore/' + obj.id, - body: JSON.stringify({ - a: 'b', - c: {"__op":"Increment","amount":2}, - d: {"__op":"Add", objects: ['2']}, - e: {"__op":"AddUnique", objects: ['1', '2']}, - f: {"__op":"Remove", objects: ['2']}, - selfThing: {"__type":"Pointer","className":"GameScore","objectId":obj.id}, - }) - }, (error, response, body) => { - try { - body = JSON.parse(body); - expect(body.a).toBeUndefined(); - expect(body.c).toEqual(3); // 2+1 - expect(body.d.length).toBe(2); - expect(body.d.indexOf('1') > -1).toBe(true); - expect(body.d.indexOf('2') > -1).toBe(true); - expect(body.e.length).toBe(2); - expect(body.e.indexOf('1') > -1).toBe(true); - expect(body.e.indexOf('2') > -1).toBe(true); - expect(body.f.length).toBe(1); - expect(body.f.indexOf('1') > -1).toBe(true); - // return nothing on other self - expect(body.selfThing).toBeUndefined(); - // updatedAt is always set - expect(body.updatedAt).not.toBeUndefined(); - }catch(e) { - jfail(e); - } - done(); - }); - }).fail(() => { - fail('Should not fail'); - done(); - }) - }) + obj + .save({ a: 'hello', c: 1, d: ['1'], e: ['1'], f: ['1', '2'] }) + .then(() => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', + }; + request({ + method: 'PUT', + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore/' + obj.id, + body: JSON.stringify({ + a: 'b', + c: { __op: 'Increment', amount: 2 }, + d: { __op: 'Add', objects: ['2'] }, + e: { __op: 'AddUnique', objects: ['1', '2'] }, + f: { __op: 'Remove', objects: ['2'] }, + selfThing: { + __type: 'Pointer', + className: 'GameScore', + objectId: obj.id, + }, + }), + }).then(response => { + try { + const body = response.data; + expect(body.a).toBeUndefined(); + expect(body.c).toEqual(3); // 2+1 + expect(body.d.length).toBe(2); + expect(body.d.indexOf('1') > -1).toBe(true); + expect(body.d.indexOf('2') > -1).toBe(true); + expect(body.e.length).toBe(2); + expect(body.e.indexOf('1') > -1).toBe(true); + expect(body.e.indexOf('2') > -1).toBe(true); + expect(body.f.length).toBe(1); + expect(body.f.indexOf('1') > -1).toBe(true); + // return nothing on other self + expect(body.selfThing).toBeUndefined(); + // updatedAt is always set + expect(body.updatedAt).not.toBeUndefined(); + } catch (e) { + fail(e); + } + done(); + }); + }) + .catch(done.fail); + }); - it('test cloud function error handling', (done) => { + it('test cloud function error handling', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error('noway'); - }); - Parse.Cloud.run('willFail').then(() => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(141); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Error('noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(141); + expect(e.message).toEqual('noway'); + done(); + } + ); }); - it('test cloud function error handling with custom error code', (done) => { + it('test cloud function error handling with custom error code', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error(999, 'noway'); - }); - Parse.Cloud.run('willFail').then(() => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(999); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Parse.Error(999, 'noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(999); + expect(e.message).toEqual('noway'); + done(); + } + ); }); - it('test cloud function error handling with standard error code', (done) => { + it('test cloud function error handling with standard error code', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error('noway'); - }); - Parse.Cloud.run('willFail').then(() => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Error('noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual('noway'); + done(); + } + ); }); it('test beforeSave/afterSave get installationId', function(done) { let triggerTime = 0; - Parse.Cloud.beforeSave('GameScore', function(req, res) { + Parse.Cloud.beforeSave('GameScore', function(req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); - res.success(); }); Parse.Cloud.afterSave('GameScore', function(req) { triggerTime++; @@ -921,18 +1070,18 @@ describe('miscellaneous', function() { expect(req.installationId).toEqual('yolo'); }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' + 'X-Parse-Installation-Id': 'yolo', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/classes/GameScore', - body: JSON.stringify({ a: 'b' }) - }, (error) => { - expect(error).toBe(null); + body: JSON.stringify({ a: 'b' }), + }).then(() => { expect(triggerTime).toEqual(2); done(); }); @@ -940,11 +1089,10 @@ describe('miscellaneous', function() { it('test beforeDelete/afterDelete get installationId', function(done) { let triggerTime = 0; - Parse.Cloud.beforeDelete('GameScore', function(req, res) { + Parse.Cloud.beforeDelete('GameScore', function(req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); - res.success(); }); Parse.Cloud.afterDelete('GameScore', function(req) { triggerTime++; @@ -952,325 +1100,358 @@ describe('miscellaneous', function() { expect(req.installationId).toEqual('yolo'); }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' + 'X-Parse-Installation-Id': 'yolo', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/classes/GameScore', - body: JSON.stringify({ a: 'b' }) - }, (error, response, body) => { - expect(error).toBe(null); - request.del({ + body: JSON.stringify({ a: 'b' }), + }).then(response => { + request({ + method: 'DELETE', headers: headers, - url: 'http://localhost:8378/1/classes/GameScore/' + JSON.parse(body).objectId - }, (error) => { - expect(error).toBe(null); + url: + 'http://localhost:8378/1/classes/GameScore/' + response.data.objectId, + }).then(() => { expect(triggerTime).toEqual(2); done(); }); }); }); - it('test cloud function query parameters', (done) => { - Parse.Cloud.define('echoParams', (req, res) => { - res.success(req.params); + it('test beforeDelete with locked down ACL', async () => { + let called = false; + Parse.Cloud.beforeDelete('GameScore', () => { + called = true; + }); + const object = new Parse.Object('GameScore'); + object.setACL(new Parse.ACL()); + await object.save(); + const objects = await new Parse.Query('GameScore').find(); + expect(objects.length).toBe(0); + try { + await object.destroy(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(called).toBe(false); + }); + + it('test cloud function query parameters', done => { + Parse.Cloud.define('echoParams', req => { + return req.params; }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' + 'X-Parse-Javascript-Key': 'test', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/functions/echoParams', //?option=1&other=2 qs: { option: 1, - other: 2 + other: 2, }, - body: '{"foo":"bar", "other": 1}' - }, (error, response, body) => { - expect(error).toBe(null); - var res = JSON.parse(body).result; + body: '{"foo":"bar", "other": 1}', + }).then(response => { + const res = response.data.result; expect(res.option).toEqual('1'); // Make sure query string params override body params expect(res.other).toEqual('2'); - expect(res.foo).toEqual("bar"); + expect(res.foo).toEqual('bar'); done(); }); }); - it('test cloud function parameter validation', (done) => { + it('test cloud function parameter validation', done => { // Register a function with validation - Parse.Cloud.define('functionWithParameterValidationFailure', (req, res) => { - res.success('noway'); - }, (request) => { - return request.params.success === 100; - }); + 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(); - }); + 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, response) => { + Parse.Cloud.define('func', request => { expect(request.params.nullParam).toEqual(null); - response.success('yay'); + return 'yay'; }); - Parse.Cloud.run('func', {nullParam: null}) - .then(() => { - done() - }, () => { + Parse.Cloud.run('func', { nullParam: null }).then( + () => { + done(); + }, + () => { fail('cloud code call failed'); done(); - }); + } + ); }); it('can handle date params in cloud functions (#2214)', done => { const date = new Date(); - Parse.Cloud.define('dateFunc', (request, response) => { + Parse.Cloud.define('dateFunc', request => { expect(request.params.date.__type).toEqual('Date'); expect(request.params.date.iso).toEqual(date.toISOString()); - response.success('yay'); + return 'yay'; }); - Parse.Cloud.run('dateFunc', {date: date}) - .then(() => { - done() - }, () => { + Parse.Cloud.run('dateFunc', { date: date }).then( + () => { + done(); + }, + () => { fail('cloud code call failed'); done(); - }); + } + ); }); it('fails on invalid client key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Client-Key': 'notclient' + 'X-Parse-Client-Key': 'notclient', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid windows key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Windows-Key': 'notwindows' + 'X-Parse-Windows-Key': 'notwindows', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid javascript key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'notjavascript' + 'X-Parse-Javascript-Key': 'notjavascript', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid rest api key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'notrest' + 'X-Parse-REST-API-Key': 'notrest', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid function', done => { - Parse.Cloud.run('somethingThatDoesDefinitelyNotExist').then(() => { - fail('This should have never suceeded'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(e.message).toEqual('Invalid function: "somethingThatDoesDefinitelyNotExist"'); - done(); - }); + Parse.Cloud.run('somethingThatDoesDefinitelyNotExist').then( + () => { + fail('This should have never suceeded'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual( + 'Invalid function: "somethingThatDoesDefinitelyNotExist"' + ); + done(); + } + ); }); - it('dedupes an installation properly and returns updatedAt', (done) => { + it('dedupes an installation properly and returns updatedAt', done => { const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; const data = { - 'installationId': 'lkjsahdfkjhsdfkjhsdfkjhsdf', - 'deviceType': 'embedded' + installationId: 'lkjsahdfkjhsdfkjhsdfkjhsdf', + deviceType: 'embedded', }; const requestOptions = { headers: headers, + method: 'POST', url: 'http://localhost:8378/1/installations', - body: JSON.stringify(data) + body: JSON.stringify(data), }; - request.post(requestOptions, (error, response, body) => { - expect(error).toBe(null); - const b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b.objectId).toEqual('string'); - request.post(requestOptions, (error, response, body) => { - expect(error).toBe(null); - const b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b.updatedAt).toEqual('string'); done(); }); }); }); - it('android login providing empty authData block works', (done) => { + it('android login providing empty authData block works', done => { const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; const data = { username: 'pulse1989', password: 'password1234', - authData: {} + authData: {}, }; const requestOptions = { + method: 'POST', headers: headers, url: 'http://localhost:8378/1/users', - body: JSON.stringify(data) + body: JSON.stringify(data), }; - request.post(requestOptions, (error) => { - expect(error).toBe(null); + request(requestOptions).then(() => { requestOptions.url = 'http://localhost:8378/1/login'; - request.get(requestOptions, (error, response, body) => { - expect(error).toBe(null); - const b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b['sessionToken']).toEqual('string'); done(); }); }); }); - it('gets relation fields', (done) => { + it('gets relation fields', done => { const object = new Parse.Object('AnObject'); const relatedObject = new Parse.Object('RelatedObject'); - Parse.Object.saveAll([object, relatedObject]).then(() => { - object.relation('related').add(relatedObject); - return object.save(); - }).then(() => { - const headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - const requestOptions = { - headers: headers, - url: 'http://localhost:8378/1/classes/AnObject', - json: true - }; - request.get(requestOptions, (err, res, body) => { - expect(body.results.length).toBe(1); - const result = body.results[0]; - expect(result.related).toEqual({ - __type: "Relation", - className: 'RelatedObject' - }) + Parse.Object.saveAll([object, relatedObject]) + .then(() => { + object.relation('related').add(relatedObject); + return object.save(); + }) + .then(() => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/classes/AnObject', + json: true, + }; + request(requestOptions).then(res => { + const body = res.data; + expect(body.results.length).toBe(1); + const result = body.results[0]; + expect(result.related).toEqual({ + __type: 'Relation', + className: 'RelatedObject', + }); + done(); + }); + }) + .catch(err => { + jfail(err); done(); }); - }).catch((err) => { - jfail(err); - done(); - }) }); - it('properly returns incremented values (#1554)', (done) => { + it('properly returns incremented values (#1554)', done => { const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; const requestOptions = { headers: headers, url: 'http://localhost:8378/1/classes/AnObject', - json: true + json: true, }; const object = new Parse.Object('AnObject'); function runIncrement(amount) { const options = Object.assign({}, requestOptions, { body: { - "key": { + key: { __op: 'Increment', - amount: amount - } + amount: amount, + }, }, - url: 'http://localhost:8378/1/classes/AnObject/' + object.id - }) - return new Promise((resolve, reject) => { - request.put(options, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }); - }) + url: 'http://localhost:8378/1/classes/AnObject/' + object.id, + method: 'PUT', + }); + return request(options).then(res => res.data); } - object.save().then(() => { - return runIncrement(1); - }).then((res) => { - expect(res.key).toBe(1); - return runIncrement(-1); - }).then((res) => { - expect(res.key).toBe(0); - done(); - }) - }) + object + .save() + .then(() => { + return runIncrement(1); + }) + .then(res => { + expect(res.key).toBe(1); + return runIncrement(-1); + }) + .then(res => { + expect(res.key).toBe(0); + done(); + }); + }); - it('ignores _RevocableSession "header" send by JS SDK', (done) => { + it('ignores _RevocableSession "header" send by JS SDK', done => { const object = new Parse.Object('AnObject'); object.set('a', 'b'); object.save().then(() => { - request.post({ - headers: {'Content-Type': 'application/json'}, + request({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, url: 'http://localhost:8378/1/classes/AnObject', body: { _method: 'GET', @@ -1278,24 +1459,25 @@ describe('miscellaneous', function() { _JavaScriptKey: 'test', _ClientVersion: 'js1.8.3', _InstallationId: 'iid', - _RevocableSession: "1", + _RevocableSession: '1', }, - json: true - }, (err, res, body) => { + }).then(res => { + const body = res.data; expect(body.error).toBeUndefined(); expect(body.results).not.toBeUndefined(); expect(body.results.length).toBe(1); const result = body.results[0]; expect(result.a).toBe('b'); done(); - }) + }); }); }); it('doesnt convert interior keys of objects that use special names', done => { const obj = new Parse.Object('Obj'); obj.set('val', { createdAt: 'a', updatedAt: 1 }); - obj.save() + obj + .save() .then(obj => new Parse.Query('Obj').get(obj.id)) .then(obj => { expect(obj.get('val').createdAt).toEqual('a'); @@ -1305,88 +1487,112 @@ describe('miscellaneous', function() { }); it('bans interior keys containing . or $', done => { - new Parse.Object('Obj').save({innerObj: {'key with a $': 'fails'}}) - .then(() => { - fail('should not succeed') - }, error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {'key with a .': 'fails'}}); - }) - .then(() => { - fail('should not succeed') - }, error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with $': 'fails'}}}); - }) - .then(() => { - fail('should not succeed') - }, error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with .': 'fails'}}}); - }) - .then(() => { - fail('should not succeed') - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - done(); - }); + new Parse.Object('Obj') + .save({ innerObj: { 'key with a $': 'fails' } }) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { 'key with a .': 'fails' }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { innerInnerObj: { 'key with $': 'fails' } }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { innerInnerObj: { 'key with .': 'fails' } }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + done(); + } + ); }); it('does not change inner object keys named _auth_data_something', done => { - new Parse.Object('O').save({ innerObj: {_auth_data_facebook: 7}}) + new Parse.Object('O') + .save({ innerObj: { _auth_data_facebook: 7 } }) .then(object => new Parse.Query('O').get(object.id)) .then(object => { - expect(object.get('innerObj')).toEqual({_auth_data_facebook: 7}); + expect(object.get('innerObj')).toEqual({ _auth_data_facebook: 7 }); done(); }); }); it('does not change inner object key names _p_somethign', done => { - new Parse.Object('O').save({ innerObj: {_p_data: 7}}) + new Parse.Object('O') + .save({ innerObj: { _p_data: 7 } }) .then(object => new Parse.Query('O').get(object.id)) .then(object => { - expect(object.get('innerObj')).toEqual({_p_data: 7}); + expect(object.get('innerObj')).toEqual({ _p_data: 7 }); done(); }); }); it('does not change inner object key names _rperm, _wperm', done => { - new Parse.Object('O').save({ innerObj: {_rperm: 7, _wperm: 8}}) + new Parse.Object('O') + .save({ innerObj: { _rperm: 7, _wperm: 8 } }) .then(object => new Parse.Query('O').get(object.id)) .then(object => { - expect(object.get('innerObj')).toEqual({_rperm: 7, _wperm: 8}); + expect(object.get('innerObj')).toEqual({ _rperm: 7, _wperm: 8 }); done(); }); }); it('does not change inner objects if the key has the same name as a geopoint field on the class, and the value is an array of length 2, or if the key has the same name as a file field on the class, and the value is a string', done => { const file = new Parse.File('myfile.txt', { base64: 'eAo=' }); - file.save() + file + .save() .then(f => { const obj = new Parse.Object('O'); obj.set('fileField', f); obj.set('geoField', new Parse.GeoPoint(0, 0)); obj.set('innerObj', { - fileField: "data", - geoField: [1,2], + fileField: 'data', + geoField: [1, 2], }); return obj.save(); }) .then(object => object.fetch()) .then(object => { expect(object.get('innerObj')).toEqual({ - fileField: "data", - geoField: [1,2], + fileField: 'data', + geoField: [1, 2], }); done(); - }).catch((e) => { + }) + .catch(e => { jfail(e); done(); }); }); - it('purge all objects in class', (done) => { + it('purge all objects in class', done => { const object = new Parse.Object('TestObject'); object.set('foo', 'bar'); const object2 = new Parse.Object('TestObject'); @@ -1394,22 +1600,22 @@ describe('miscellaneous', function() { Parse.Object.saveAll([object, object2]) .then(() => { const query = new Parse.Query(TestObject); - return query.count() - }).then((count) => { + return query.count(); + }) + .then(count => { expect(count).toBe(2); const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' + 'X-Parse-Master-Key': 'test', }; - request.del({ + request({ + method: 'DELETE', headers: headers, url: 'http://localhost:8378/1/purge/TestObject', - json: true - }, (err) => { - expect(err).toBe(null); + }).then(() => { const query = new Parse.Query(TestObject); - return query.count().then((count) => { + return query.count().then(count => { expect(count).toBe(0); done(); }); @@ -1417,150 +1623,185 @@ describe('miscellaneous', function() { }); }); - it('fail on purge all objects in class without master key', (done) => { + it('fail on purge all objects in class without master key', done => { const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - rp({ + request({ method: 'DELETE', headers: headers, - uri: 'http://localhost:8378/1/purge/TestObject', - json: true - }).then(() => { - fail('Should not succeed'); - }).catch(err => { - expect(err.error.error).toEqual('unauthorized: master key is required'); - done(); - }); + url: 'http://localhost:8378/1/purge/TestObject', + }) + .then(() => { + fail('Should not succeed'); + }) + .catch(response => { + expect(response.data.error).toEqual( + 'unauthorized: master key is required' + ); + done(); + }); }); - it('purge all objects in _Role also purge cache', (done) => { + it('purge all objects in _Role also purge cache', done => { const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' + 'X-Parse-Master-Key': 'test', }; - var user, object; - createTestUser().then((x) => { - user = x; - const acl = new Parse.ACL(); - acl.setPublicReadAccess(true); - acl.setPublicWriteAccess(false); - const role = new Parse.Object('_Role'); - role.set('name', 'TestRole'); - role.setACL(acl); - const users = role.relation('users'); - users.add(user); - return role.save({}, { useMasterKey: true }); - }).then(() => { - const query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(1); - const relation = x[0].relation('users').query(); - return relation.first({ useMasterKey: true }); - }).then((x) => { - expect(x.id).toEqual(user.id); - object = new Parse.Object('TestObject'); - const acl = new Parse.ACL(); - acl.setPublicReadAccess(false); - acl.setPublicWriteAccess(false); - acl.setRoleReadAccess('TestRole', true); - acl.setRoleWriteAccess('TestRole', true); - object.setACL(acl); - return object.save(); - }).then(() => { - const query = new Parse.Query('TestObject'); - return query.find({ sessionToken: user.getSessionToken() }); - }).then((x) => { - expect(x.length).toEqual(1); - return rp({ - method: 'DELETE', - headers: headers, - uri: 'http://localhost:8378/1/purge/_Role', - json: true - }); - }).then(() => { - const query = new Parse.Query('TestObject'); - return query.get(object.id, { sessionToken: user.getSessionToken() }); - }).then(() => { - fail('Should not succeed'); - }, (e) => { - expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); + let user, object; + createTestUser() + .then(x => { + user = x; + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + const role = new Parse.Object('_Role'); + role.set('name', 'TestRole'); + role.setACL(acl); + const users = role.relation('users'); + users.add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(1); + const relation = x[0].relation('users').query(); + return relation.first({ useMasterKey: true }); + }) + .then(x => { + expect(x.id).toEqual(user.id); + object = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setRoleReadAccess('TestRole', true); + acl.setRoleWriteAccess('TestRole', true); + object.setACL(acl); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then(x => { + expect(x.length).toEqual(1); + return request({ + method: 'DELETE', + headers: headers, + url: 'http://localhost:8378/1/purge/_Role', + json: true, + }); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.get(object.id, { sessionToken: user.getSessionToken() }); + }) + .then( + () => { + fail('Should not succeed'); + }, + e => { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - it('should not update schema beforeSave #2672', (done) => { - Parse.Cloud.beforeSave('MyObject', (request, response) => { + it('purge empty class', done => { + const testSchema = new Parse.Schema('UnknownClass'); + testSchema + .purge() + .then(done) + .catch(done.fail); + }); + + it('should not update schema beforeSave #2672', done => { + Parse.Cloud.beforeSave('MyObject', request => { if (request.object.get('secret')) { - response.error('cannot set secret here'); - return; + throw 'cannot set secret here'; } - response.success(); }); const object = new Parse.Object('MyObject'); object.set('key', 'value'); - object.save().then(() => { - return object.save({'secret': 'should not update schema'}); - }).then(() => { - fail(); - done(); - }, () => { - return rp({ - method: 'GET', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' + object + .save() + .then(() => { + return object.save({ secret: 'should not update schema' }); + }) + .then( + () => { + fail(); + done(); }, - uri: 'http://localhost:8378/1/schemas/MyObject', - json: true - }); - }).then((res) => { - const fields = res.fields; - expect(fields.secret).toBeUndefined(); - done(); - }, (err) => { - jfail(err); - done(); - }); + () => { + return request({ + method: 'GET', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + url: 'http://localhost:8378/1/schemas/MyObject', + json: true, + }); + } + ) + .then( + res => { + const fields = res.data.fields; + expect(fields.secret).toBeUndefined(); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); }); describe_only_db('mongo')('legacy _acl', () => { - it('should have _acl when locking down (regression for #2465)', (done) => { + it('should have _acl when locking down (regression for #2465)', done => { const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - } - rp({ + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + request({ method: 'POST', headers: headers, - uri: 'http://localhost:8378/1/classes/Report', + url: 'http://localhost:8378/1/classes/Report', body: { ACL: {}, - name: 'My Report' + name: 'My Report', }, - json: true - }).then(() => { - const config = Config.get('test'); - const adapter = config.database.adapter; - return adapter._adaptiveCollection("Report") - .then(collection => collection.find({})) - }).then((results) => { - expect(results.length).toBe(1); - const result = results[0]; - expect(result.name).toEqual('My Report'); - expect(result._wperm).toEqual([]); - expect(result._rperm).toEqual([]); - expect(result._acl).toEqual({}); - done(); - }).catch((err) => { - fail(JSON.stringify(err)); - done(); - }); + json: true, + }) + .then(() => { + const config = Config.get('test'); + const adapter = config.database.adapter; + return adapter + ._adaptiveCollection('Report') + .then(collection => collection.find({})); + }) + .then(results => { + expect(results.length).toBe(1); + const result = results[0]; + expect(result.name).toEqual('My Report'); + expect(result._wperm).toEqual([]); + expect(result._rperm).toEqual([]); + expect(result._acl).toEqual({}); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); }); diff --git a/spec/ParseCloudCodePublisher.spec.js b/spec/ParseCloudCodePublisher.spec.js index 5fba0b2521..905b0cbbeb 100644 --- a/spec/ParseCloudCodePublisher.spec.js +++ b/spec/ParseCloudCodePublisher.spec.js @@ -1,69 +1,80 @@ -var ParseCloudCodePublisher = require('../src/LiveQuery/ParseCloudCodePublisher').ParseCloudCodePublisher; -var Parse = require('parse/node'); +const ParseCloudCodePublisher = require('../lib/LiveQuery/ParseCloudCodePublisher') + .ParseCloudCodePublisher; +const Parse = require('parse/node'); describe('ParseCloudCodePublisher', function() { beforeEach(function(done) { // Mock ParsePubSub - var mockParsePubSub = { + const mockParsePubSub = { createPublisher: jasmine.createSpy('publish').and.returnValue({ publish: jasmine.createSpy('publish'), - on: jasmine.createSpy('on') + on: jasmine.createSpy('on'), }), createSubscriber: jasmine.createSpy('publish').and.returnValue({ subscribe: jasmine.createSpy('subscribe'), - on: jasmine.createSpy('on') - }) + on: jasmine.createSpy('on'), + }), }; - jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); + jasmine.mockLibrary( + '../lib/LiveQuery/ParsePubSub', + 'ParsePubSub', + mockParsePubSub + ); done(); }); it('can initialize', function() { - var config = {} + const config = {}; new ParseCloudCodePublisher(config); - var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; + const ParsePubSub = require('../lib/LiveQuery/ParsePubSub').ParsePubSub; expect(ParsePubSub.createPublisher).toHaveBeenCalledWith(config); }); it('can handle cloud code afterSave request', function() { - var publisher = new ParseCloudCodePublisher({}); + const publisher = new ParseCloudCodePublisher({}); publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); - var request = {}; + const request = {}; publisher.onCloudCodeAfterSave(request); - expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith(Parse.applicationId + 'afterSave', request); + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith( + Parse.applicationId + 'afterSave', + request + ); }); it('can handle cloud code afterDelete request', function() { - var publisher = new ParseCloudCodePublisher({}); + const publisher = new ParseCloudCodePublisher({}); publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); - var request = {}; + const request = {}; publisher.onCloudCodeAfterDelete(request); - expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith(Parse.applicationId + 'afterDelete', request); + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith( + Parse.applicationId + 'afterDelete', + request + ); }); it('can handle cloud code request', function() { - var publisher = new ParseCloudCodePublisher({}); - var currentParseObject = new Parse.Object('Test'); + const publisher = new ParseCloudCodePublisher({}); + const currentParseObject = new Parse.Object('Test'); currentParseObject.set('key', 'value'); - var originalParseObject = new Parse.Object('Test'); + const originalParseObject = new Parse.Object('Test'); originalParseObject.set('key', 'originalValue'); - var request = { + const request = { object: currentParseObject, - original: originalParseObject + original: originalParseObject, }; publisher._onCloudCodeMessage('afterSave', request); - var args = publisher.parsePublisher.publish.calls.mostRecent().args; + const args = publisher.parsePublisher.publish.calls.mostRecent().args; expect(args[0]).toBe('afterSave'); - var message = JSON.parse(args[1]); + const message = JSON.parse(args[1]); expect(message.currentParseObject).toEqual(request.object._toFullJSON()); expect(message.originalParseObject).toEqual(request.original._toFullJSON()); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); + afterEach(function() { + jasmine.restoreLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub'); }); }); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 9ec2c680f3..075adcbd86 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1,63 +1,65 @@ // This is a port of the test suite: // hungry/js/test/parse_file_test.js -"use strict"; +'use strict'; -var request = require('request'); +const request = require('../lib/request'); -var str = "Hello World!"; -var data = []; -for (var i = 0; i < str.length; i++) { +const str = 'Hello World!'; +const data = []; +for (let i = 0; i < str.length; i++) { data.push(str.charCodeAt(i)); } describe('Parse.File testing', () => { describe('creating files', () => { it('works with Content-Type', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); + expect(b.url).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/ + ); + request({ url: b.url }).then(response => { + const body = response.text; expect(body).toEqual('argle bargle'); done(); }); }); }); - it('works with _ContentType', done => { - - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/files/file', body: JSON.stringify({ _ApplicationId: 'test', _JavaScriptKey: 'test', _ContentType: 'text/html', - base64: 'PGh0bWw+PC9odG1sPgo=' - }) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + base64: 'PGh0bWw+PC9odG1sPgo=', + }), + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.html/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/); - request.get(b.url, (error, response, body) => { + expect(b.url).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/ + ); + request({ url: b.url }).then(response => { + const body = response.text; try { expect(response.headers['content-type']).toMatch('^text/html'); - expect(error).toBe(null); expect(body).toEqual('\n'); - } catch(e) { + } catch (e) { jfail(e); } done(); @@ -66,22 +68,23 @@ describe('Parse.File testing', () => { }); it('works without Content-Type', done => { - var headers = { + const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); + expect(b.url).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/ + ); + request({ url: b.url }).then(response => { + expect(response.text).toEqual('argle bargle'); done(); }); }); @@ -89,46 +92,43 @@ describe('Parse.File testing', () => { }); it('supports REST end-to-end file create, read, delete, read', done => { - var headers = { + const headers = { 'Content-Type': 'image/jpeg', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/testfile.txt', body: 'check one two', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_testfile.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); + expect(b.url).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/ + ); + request({ url: b.url }).then(response => { + const body = response.text; expect(body).toEqual('check one two'); - request.del({ + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' + 'X-Parse-Master-Key': 'test', }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(200); - request.get({ + url: 'http://localhost:8378/1/files/' + b.name, + }).then(response => { + expect(response.status).toEqual(200); + request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: b.url - }, (error, response) => { - expect(error).toBe(null); - try { - expect(response.statusCode).toEqual(404); - } catch(e) { - jfail(e); - } + url: b.url, + }).then(fail, response => { + expect(response.status).toEqual(404); done(); }); }); @@ -137,43 +137,45 @@ describe('Parse.File testing', () => { }); it('blocks file deletions with missing or incorrect master-key header', done => { - var headers = { + const headers = { 'Content-Type': 'image/jpeg', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/thefile.jpg', - body: 'the file body' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + body: 'the file body', + }).then(response => { + const b = response.data; + expect(b.url).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/ + ); // missing X-Parse-Master-Key header - request.del({ + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(403); + url: 'http://localhost:8378/1/files/' + b.name, + }).then(fail, response => { + const del_b = response.data; + expect(response.status).toEqual(403); expect(del_b.error).toMatch(/unauthorized/); // incorrect X-Parse-Master-Key header - request.del({ + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'tryagain' + 'X-Parse-Master-Key': 'tryagain', }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(403); + url: 'http://localhost:8378/1/files/' + b.name, + }).then(fail, response => { + const del_b2 = response.data; + expect(response.status).toEqual(403); expect(del_b2.error).toMatch(/unauthorized/); done(); }); @@ -182,513 +184,477 @@ describe('Parse.File testing', () => { }); it('handles other filetypes', done => { - var headers = { + const headers = { 'Content-Type': 'image/jpeg', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.jpg', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.jpg$/); expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); + request({ url: b.url }).then(response => { + const body = response.text; expect(body).toEqual('argle bargle'); done(); }); }); }); - it("save file", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('save file', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - }, done)); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); }); - it("save file in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('save file in object', async done => { + const file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - ok(objectAgain.get("file") instanceof Parse.File); - done(); - } - })); - } - }, done)); - } - }, done)); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + + const object = new Parse.Object('TestObject'); + await object.save({ file: file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + ok(objectAgain.get('file') instanceof Parse.File); + done(); }); - it("save file in object with escaped characters in filename", done => { - var file = new Parse.File("hello . txt", data, "text/plain"); + it('save file in object with escaped characters in filename', async () => { + const file = new Parse.File('hello . txt', data, 'text/plain'); ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello . txt"); - - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - ok(objectAgain.get("file") instanceof Parse.File); - - done(); - } - })); - } - }, done)); - } - }, done)); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello . txt'); + + const object = new Parse.Object('TestObject'); + await object.save({ file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + ok(objectAgain.get('file') instanceof Parse.File); }); - it("autosave file in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('autosave file in object', async done => { + let file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - file = objectAgain.get("file"); - ok(file instanceof Parse.File); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - }, done)); - } - }, done)); + const object = new Parse.Object('TestObject'); + await object.save({ file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + file = objectAgain.get('file'); + ok(file instanceof Parse.File); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + done(); }); - it("autosave file in object in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('autosave file in object in object', async done => { + let file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); - var child = new Parse.Object("Child"); - child.set("file", file); - - var parent = new Parse.Object("Parent"); - parent.set("child", child); - - parent.save(expectSuccess({ - success: function(parent) { - var query = new Parse.Query("Parent"); - query.include("child"); - query.get(parent.id, expectSuccess({ - success: function(parentAgain) { - var childAgain = parentAgain.get("child"); - file = childAgain.get("file"); - ok(file instanceof Parse.File); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - }, done)); - } - }, done)); + const child = new Parse.Object('Child'); + child.set('file', file); + + const parent = new Parse.Object('Parent'); + parent.set('child', child); + + await parent.save(); + const query = new Parse.Query('Parent'); + query.include('child'); + const parentAgain = await query.get(parent.id); + const childAgain = parentAgain.get('child'); + file = childAgain.get('file'); + ok(file instanceof Parse.File); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + done(); }); - it("saving an already saved file", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('saving an already saved file', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - var previousName = file.name(); - - file.save(expectSuccess({ - success: function() { - equal(file.name(), previousName); - done(); - } - }, done)); - } - }, done)); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + const previousName = file.name(); + + await file.save(); + equal(file.name(), previousName); }); - it("two saves at the same time", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - - var firstName; - var secondName; + it('two saves at the same time', done => { + const file = new Parse.File('hello.txt', data, 'text/plain'); - var firstSave = file.save().then(function() { firstName = file.name(); }); - var secondSave = file.save().then(function() { secondName = file.name(); }); + let firstName; + let secondName; - Parse.Promise.when(firstSave, secondSave).then(function() { - equal(firstName, secondName); - done(); - }, function(error) { - ok(false, error); - done(); + const firstSave = file.save().then(function() { + firstName = file.name(); + }); + const secondSave = file.save().then(function() { + secondName = file.name(); }); - }); - it("file toJSON testing", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function() { - ok(object.toJSON().file.url); + Promise.all([firstSave, secondSave]).then( + function() { + equal(firstName, secondName); + done(); + }, + function(error) { + ok(false, error); done(); } - }, done)); + ); + }); + + it('file toJSON testing', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const object = new Parse.Object('TestObject'); + await object.save({ + file: file, + }); + ok(object.toJSON().file.url); }); - it("content-type used with no extension", done => { - var headers = { + it('content-type used with no extension', done => { + const headers = { 'Content-Type': 'text/html', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file', body: 'fee fi fo', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/\.html$/); - request.get(b.url, (error, response) => { - if (!response) { - fail('response should be set'); - return done(); - } + request({ url: b.url }).then(response => { expect(response.headers['content-type']).toMatch(/^text\/html/); done(); }); }); }); - it("filename is url encoded", done => { - var headers = { + it('filename is url encoded', done => { + const headers = { 'Content-Type': 'text/html', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/hello world.txt', body: 'oh emm gee', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.url).toMatch(/hello%20world/); done(); - }) + }); }); it('supports array of files', done => { - var file = { + const file = { __type: 'File', url: 'http://meep.meep', - name: 'meep' + name: 'meep', }; - var files = [file, file]; - var obj = new Parse.Object('FilesArrayTest'); + const files = [file, file]; + const obj = new Parse.Object('FilesArrayTest'); obj.set('files', files); - obj.save().then(() => { - var query = new Parse.Query('FilesArrayTest'); - return query.first(); - }).then((result) => { - var filesAgain = result.get('files'); - expect(filesAgain.length).toEqual(2); - expect(filesAgain[0].name()).toEqual('meep'); - expect(filesAgain[0].url()).toEqual('http://meep.meep'); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('FilesArrayTest'); + return query.first(); + }) + .then(result => { + const filesAgain = result.get('files'); + expect(filesAgain.length).toEqual(2); + expect(filesAgain[0].name()).toEqual('meep'); + expect(filesAgain[0].url()).toEqual('http://meep.meep'); + done(); + }); }); it('validates filename characters', done => { - var headers = { + const headers = { 'Content-Type': 'text/plain', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/di$avowed.txt', body: 'will fail', - }, (error, response, body) => { - var b = JSON.parse(body); + }).then(fail, response => { + const b = response.data; expect(b.code).toEqual(122); done(); }); }); it('validates filename length', done => { - var headers = { + const headers = { 'Content-Type': 'text/plain', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - var fileName = 'Onceuponamidnightdrearywhileiponderedweak' + - 'andwearyOveramanyquaintandcuriousvolumeof' + - 'forgottenloreWhileinoddednearlynappingsud' + - 'denlytherecameatapping'; - request.post({ + const fileName = + 'Onceuponamidnightdrearywhileiponderedweak' + + 'andwearyOveramanyquaintandcuriousvolumeof' + + 'forgottenloreWhileinoddednearlynappingsud' + + 'denlytherecameatapping'; + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/' + fileName, body: 'will fail', - }, (error, response, body) => { - var b = JSON.parse(body); + }).then(fail, response => { + const b = response.data; expect(b.code).toEqual(122); done(); }); }); it('supports a dictionary with file', done => { - var file = { + const file = { __type: 'File', url: 'http://meep.meep', - name: 'meep' + name: 'meep', }; - var dict = { - file: file + const dict = { + file: file, }; - var obj = new Parse.Object('FileObjTest'); + const obj = new Parse.Object('FileObjTest'); obj.set('obj', dict); - obj.save().then(() => { - var query = new Parse.Query('FileObjTest'); - return query.first(); - }).then((result) => { - var dictAgain = result.get('obj'); - expect(typeof dictAgain).toEqual('object'); - var fileAgain = dictAgain['file']; - expect(fileAgain.name()).toEqual('meep'); - expect(fileAgain.url()).toEqual('http://meep.meep'); - done(); - }).catch((e) => { - jfail(e); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('FileObjTest'); + return query.first(); + }) + .then(result => { + const dictAgain = result.get('obj'); + expect(typeof dictAgain).toEqual('object'); + const fileAgain = dictAgain['file']; + expect(fileAgain.name()).toEqual('meep'); + expect(fileAgain.url()).toEqual('http://meep.meep'); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); it('creates correct url for old files hosted on files.parsetfss.com', done => { - var file = { + const file = { __type: 'File', url: 'http://irrelevant.elephant/', - name: 'tfss-123.txt' + name: 'tfss-123.txt', }; - var obj = new Parse.Object('OldFileTest'); + const obj = new Parse.Object('OldFileTest'); obj.set('oldfile', file); - obj.save().then(() => { - var query = new Parse.Query('OldFileTest'); - return query.first(); - }).then((result) => { - var fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual( - 'http://files.parsetfss.com/test/tfss-123.txt' - ); - done(); - }).catch((e) => { - jfail(e); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('OldFileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('oldfile'); + expect(fileAgain.url()).toEqual( + 'http://files.parsetfss.com/test/tfss-123.txt' + ); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); it('creates correct url for old files hosted on files.parse.com', done => { - var file = { + const file = { __type: 'File', url: 'http://irrelevant.elephant/', - name: 'd6e80979-a128-4c57-a167-302f874700dc-123.txt' + name: 'd6e80979-a128-4c57-a167-302f874700dc-123.txt', }; - var obj = new Parse.Object('OldFileTest'); + const obj = new Parse.Object('OldFileTest'); obj.set('oldfile', file); - obj.save().then(() => { - var query = new Parse.Query('OldFileTest'); - return query.first(); - }).then((result) => { - var fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual( - 'http://files.parse.com/test/d6e80979-a128-4c57-a167-302f874700dc-123.txt' - ); - done(); - }).catch((e) => { - jfail(e); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('OldFileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('oldfile'); + expect(fileAgain.url()).toEqual( + 'http://files.parse.com/test/d6e80979-a128-4c57-a167-302f874700dc-123.txt' + ); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); it('supports files in objects without urls', done => { - var file = { + const file = { __type: 'File', - name: '123.txt' + name: '123.txt', }; - var obj = new Parse.Object('FileTest'); + const obj = new Parse.Object('FileTest'); obj.set('file', file); - obj.save().then(() => { - var query = new Parse.Query('FileTest'); - return query.first(); - }).then(result => { - const fileAgain = result.get('file'); - expect(fileAgain.url()).toMatch(/123.txt$/); - done(); - }).catch((e) => { - jfail(e); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('FileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('file'); + expect(fileAgain.url()).toMatch(/123.txt$/); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); it('return with publicServerURL when provided', done => { reconfigureServer({ - publicServerURL: 'https://mydomain/parse' - }).then(() => { - var file = { - __type: 'File', - name: '123.txt' - }; - var obj = new Parse.Object('FileTest'); - obj.set('file', file); - return obj.save() - }).then(() => { - var query = new Parse.Query('FileTest'); - return query.first(); - }).then(result => { - const fileAgain = result.get('file'); - expect(fileAgain.url().indexOf('https://mydomain/parse')).toBe(0); - done(); - }).catch((e) => { - jfail(e); - done(); - }); + publicServerURL: 'https://mydomain/parse', + }) + .then(() => { + const file = { + __type: 'File', + name: '123.txt', + }; + const obj = new Parse.Object('FileTest'); + obj.set('file', file); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('FileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('file'); + expect(fileAgain.url().indexOf('https://mydomain/parse')).toBe(0); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); it('fails to upload an empty file', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: '', - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toBe(400); + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; expect(body).toEqual('{"code":130,"error":"Invalid file upload."}'); done(); }); }); it('fails to upload without a file name', done => { - var headers = { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/', - body: 'yolo', - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toBe(400); - expect(body).toEqual('{"code":122,"error":"Filename not provided."}'); - done(); - }); - }); - - it('fails to upload without a file name', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/', body: 'yolo', - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toBe(400); + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; expect(body).toEqual('{"code":122,"error":"Filename not provided."}'); done(); }); }); it('fails to delete an unkown file', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' + 'X-Parse-Master-Key': 'test', }; - request.delete({ + request({ + method: 'DELETE', headers: headers, url: 'http://localhost:8378/1/files/file.txt', - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toBe(400); + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; expect(body).toEqual('{"code":153,"error":"Could not delete file."}'); done(); }); }); - describe_only_db('mongo')('Gridstore Range tests', () => { + xdescribe('Gridstore Range tests', () => { it('supports range requests', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=0-5' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-5', + }, + }).then(response => { + const body = response.text; expect(body).toEqual('argle '); done(); }); @@ -696,25 +662,28 @@ describe('Parse.File testing', () => { }); it('supports small range requests', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=0-2' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-2', + }, + }).then(response => { + const body = response.text; expect(body).toEqual('arg'); done(); }); @@ -723,51 +692,57 @@ describe('Parse.File testing', () => { // See specs https://www.greenbytes.de/tech/webdav/draft-ietf-httpbis-p5-range-latest.html#byte.ranges it('supports getting one byte', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=2-2' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=2-2', + }, + }).then(response => { + const body = response.text; expect(body).toEqual('g'); done(); }); }); }); - it('supports getting last n bytes', done => { - var headers = { + xit('supports getting last n bytes', done => { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'something different', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=-4' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=-4', + }, + }).then(response => { + const body = response.text; expect(body.length).toBe(4); expect(body).toEqual('rent'); done(); @@ -776,25 +751,28 @@ describe('Parse.File testing', () => { }); it('supports getting first n bytes', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'something different', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=10-' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=10-', + }, + }).then(response => { + const body = response.text; expect(body).toEqual('different'); done(); }); @@ -802,7 +780,7 @@ describe('Parse.File testing', () => { }); function repeat(string, count) { - var s = string; + let s = string; while (count > 0) { s += string; count--; @@ -811,25 +789,28 @@ describe('Parse.File testing', () => { } it('supports large range requests', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', - body: repeat('argle bargle', 100) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=13-240' - } }, (error, response, body) => { - expect(error).toBe(null); + body: repeat('argle bargle', 100), + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=13-240', + }, + }).then(response => { + const body = response.text; expect(body.length).toEqual(228); expect(body.indexOf('rgle barglea')).toBe(0); done(); @@ -838,14 +819,17 @@ describe('Parse.File testing', () => { }); it('fails to stream unknown file', done => { - request.get({ url: 'http://localhost:8378/1/files/test/file.txt', headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=13-240' - } }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toBe(404); + request({ + url: 'http://localhost:8378/1/files/test/file.txt', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=13-240', + }, + }).then(response => { + expect(response.status).toBe(404); + const body = response.text; expect(body).toEqual('File not found.'); done(); }); @@ -856,25 +840,28 @@ describe('Parse.File testing', () => { // for fallback tests describe_only_db('postgres')('Default Range tests', () => { it('fallback to regular request', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.get({ url: b.url, headers: { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'Range': 'bytes=0-5' - } }, (error, response, body) => { - expect(error).toBe(null); + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-5', + }, + }).then(response => { + const body = response.text; expect(body).toEqual('argle bargle'); done(); }); diff --git a/spec/ParseGeoPoint.spec.js b/spec/ParseGeoPoint.spec.js index b7f38fc5b7..3b57412fb2 100644 --- a/spec/ParseGeoPoint.spec.js +++ b/spec/ParseGeoPoint.spec.js @@ -1,648 +1,794 @@ // This is a port of the test suite: // hungry/js/test/parse_geo_point_test.js -const rp = require('request-promise'); -var TestObject = Parse.Object.extend('TestObject'); +const request = require('../lib/request'); +const TestObject = Parse.Object.extend('TestObject'); describe('Parse.GeoPoint testing', () => { - - it('geo point roundtrip', (done) => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); + it('geo point roundtrip', async () => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); obj.set('location', point); obj.set('name', 'Ferndale'); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var pointAgain = results[0].get('location'); - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - done(); - } - }); - } - }); + await obj.save(); + const result = await new Parse.Query(TestObject).get(obj.id); + const pointAgain = result.get('location'); + ok(pointAgain); + equal(pointAgain.latitude, 44.0); + equal(pointAgain.longitude, -11.0); }); - it('update geopoint', (done) => { + it('update geopoint', done => { const oldPoint = new Parse.GeoPoint(44.0, -11.0); const newPoint = new Parse.GeoPoint(24.0, 19.0); const obj = new TestObject(); obj.set('location', oldPoint); - obj.save().then(() => { - obj.set('location', newPoint); - return obj.save(); - }).then(() => { - var query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then((result) => { - const point = result.get('location'); - equal(point.latitude, newPoint.latitude); - equal(point.longitude, newPoint.longitude); - done(); - }); + obj + .save() + .then(() => { + obj.set('location', newPoint); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const point = result.get('location'); + equal(point.latitude, newPoint.latitude); + equal(point.longitude, newPoint.longitude); + done(); + }); }); - it('has the correct __type field in the json response', done => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); + it('has the correct __type field in the json response', async done => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); obj.set('location', point); - obj.set('name', 'Zhoul') - obj.save(null, { - success: (obj) => { - Parse.Cloud.httpRequest({ - url: 'http://localhost:8378/1/classes/TestObject/' + obj.id, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' - } - }).then(response => { - equal(response.data.location.__type, 'GeoPoint'); - done(); - }) - } - }) + obj.set('name', 'Zhoul'); + await obj.save(); + Parse.Cloud.httpRequest({ + url: 'http://localhost:8378/1/classes/TestObject/' + obj.id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(response => { + equal(response.data.location.__type, 'GeoPoint'); + done(); + }); }); - it('creating geo point exception two fields', (done) => { - var point = new Parse.GeoPoint(20, 20); - var obj = new TestObject(); + it('creating geo point exception two fields', done => { + const point = new Parse.GeoPoint(20, 20); + const obj = new TestObject(); obj.set('locationOne', point); obj.set('locationTwo', point); - obj.save().then(() => { - fail('expected error'); - }, (err) => { - equal(err.code, Parse.Error.INCORRECT_TYPE); - done(); - }); + obj.save().then( + () => { + fail('expected error'); + }, + err => { + equal(err.code, Parse.Error.INCORRECT_TYPE); + done(); + } + ); }); // TODO: This should also have support in postgres, or higher level database agnostic support. - it_exclude_dbs(['postgres'])('updating geo point exception two fields', (done) => { - var point = new Parse.GeoPoint(20, 20); - var obj = new TestObject(); - obj.set('locationOne', point); - obj.save(null, { - success: (obj) => { - obj.set('locationTwo', point); - obj.save().then(() => { + it_exclude_dbs(['postgres'])( + 'updating geo point exception two fields', + async done => { + const point = new Parse.GeoPoint(20, 20); + const obj = new TestObject(); + obj.set('locationOne', point); + await obj.save(); + obj.set('locationTwo', point); + obj.save().then( + () => { fail('expected error'); - }, (err) => { + }, + err => { equal(err.code, Parse.Error.INCORRECT_TYPE); done(); - }) - } - }); - }); + } + ); + } + ); - it('geo line', (done) => { - var line = []; - for (var i = 0; i < 10; ++i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0); + it('geo line', async done => { + const line = []; + for (let i = 0; i < 10; ++i) { + const obj = new TestObject(); + const point = new Parse.GeoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0); obj.set('location', point); obj.set('construct', 'line'); obj.set('seq', i); line.push(obj); } - Parse.Object.saveAll(line, { - success: function() { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(24, 19); - query.equalTo('construct', 'line'); - query.withinMiles('location', point, 10000); - query.find({ - success: function(results) { - equal(results.length, 10); - equal(results[0].get('seq'), 9); - equal(results[3].get('seq'), 6); - done(); - } - }); - } - }); + await Parse.Object.saveAll(line); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(24, 19); + query.equalTo('construct', 'line'); + query.withinMiles('location', point, 10000); + const results = await query.find(); + equal(results.length, 10); + equal(results[0].get('seq'), 9); + equal(results[3].get('seq'), 6); + done(); }); - it('geo max distance large', (done) => { - var objects = []; + it('geo max distance large', done => { + const objects = []; [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects).then(() => { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14); - return query.find(); - }).then((results) => { - equal(results.length, 3); - done(); - }, (err) => { - fail("Couldn't query GeoPoint"); - jfail(err) - }); + Parse.Object.saveAll(objects) + .then(() => { + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14); + return query.find(); + }) + .then( + results => { + equal(results.length, 3); + done(); + }, + err => { + fail("Couldn't query GeoPoint"); + jfail(err); + } + ); }); - it('geo max distance medium', (done) => { - var objects = []; + it('geo max distance medium', async () => { + const objects = []; [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.5); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('index'), 0); - equal(results[1].get('index'), 1); - done(); - } - }); - }); + await Parse.Object.saveAll(objects); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14 * 0.5); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('index'), 0); + equal(results[1].get('index'), 1); }); - it('geo max distance small', (done) => { - var objects = []; + it('geo max distance small', async () => { + const objects = []; [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.25); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('index'), 0); - done(); - } - }); - }); + await Parse.Object.saveAll(objects); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14 * 0.25); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('index'), 0); }); - var makeSomeGeoPoints = function(callback) { - var sacramento = new TestObject(); - sacramento.set('location', new Parse.GeoPoint(38.52, -121.50)); + const makeSomeGeoPoints = function() { + const sacramento = new TestObject(); + sacramento.set('location', new Parse.GeoPoint(38.52, -121.5)); sacramento.set('name', 'Sacramento'); - var honolulu = new TestObject(); + const honolulu = new TestObject(); honolulu.set('location', new Parse.GeoPoint(21.35, -157.93)); honolulu.set('name', 'Honolulu'); - var sf = new TestObject(); + const sf = new TestObject(); sf.set('location', new Parse.GeoPoint(37.75, -122.68)); sf.set('name', 'San Francisco'); - Parse.Object.saveAll([sacramento, sf, honolulu], callback); + return Parse.Object.saveAll([sacramento, sf, honolulu]); }; - it('geo max distance in km everywhere', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - // Honolulu is 4300 km away from SFO on a sphere ;) - query.withinKilometers('location', sfo, 4800.0); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } - }); - }); + it('geo max distance in km everywhere', async done => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + // Honolulu is 4300 km away from SFO on a sphere ;) + query.withinKilometers('location', sfo, 4800.0); + const results = await query.find(); + equal(results.length, 3); + done(); }); - it('geo max distance in km california', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 3700.0); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - done(); - } - }); - }); + it('geo max distance in km california', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 3700.0); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); }); - it('geo max distance in km bay area', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 100.0); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - done(); - } - }); - }); + it('geo max distance in km bay area', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 100.0); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('name'), 'San Francisco'); }); - it('geo max distance in km mid peninsula', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 10.0); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); + it('geo max distance in km mid peninsula', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 10.0); + const results = await query.find(); + equal(results.length, 0); }); - it('geo max distance in miles everywhere', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2600.0); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } - }); - }); + it('geo max distance in miles everywhere', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 2600.0); + const results = await query.find(); + equal(results.length, 3); }); - it('geo max distance in miles california', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2200.0); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - done(); - } - }); - }); + it('geo max distance in miles california', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 2200.0); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); }); - it('geo max distance in miles bay area', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - // 100km is 62 miles... - query.withinMiles('location', sfo, 62.0); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - done(); - } - }); - }); + it('geo max distance in miles bay area', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 62.0); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('name'), 'San Francisco'); }); - it('geo max distance in miles mid peninsula', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 10.0); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); + it('geo max distance in miles mid peninsula', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 10.0); + const results = await query.find(); + equal(results.length, 0); }); - it('returns nearest location', (done) => { - makeSomeGeoPoints(function() { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.near('location', sfo); - query.find({ - success: function(results) { - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - done(); - } - }); - }); + it('returns nearest location', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.near('location', sfo); + const results = await query.find(); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); }); - it('works with geobox queries', (done) => { + it('works with geobox queries', done => { const inbound = new Parse.GeoPoint(1.5, 1.5); const onbound = new Parse.GeoPoint(10, 10); const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('TestObject', {location: inbound}); - const obj2 = new Parse.Object('TestObject', {location: onbound}); - const obj3 = new Parse.Object('TestObject', {location: outbound}); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const sw = new Parse.GeoPoint(0, 0); - const ne = new Parse.GeoPoint(10, 10); - const query = new Parse.Query(TestObject); - query.withinGeoBox('location', sw, ne); - return query.find(); - }).then((results) => { - equal(results.length, 2); - done(); - }); + const obj1 = new Parse.Object('TestObject', { location: inbound }); + const obj2 = new Parse.Object('TestObject', { location: onbound }); + const obj3 = new Parse.Object('TestObject', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const sw = new Parse.GeoPoint(0, 0); + const ne = new Parse.GeoPoint(10, 10); + const query = new Parse.Query(TestObject); + query.withinGeoBox('location', sw, ne); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }); }); - it('supports a sub-object with a geo point', done => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); + it('supports a sub-object with a geo point', async () => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); obj.set('subobject', { location: point }); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var pointAgain = results[0].get('subobject')['location']; - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - done(); - } - }); - } - }); + await obj.save(); + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const pointAgain = results[0].get('subobject')['location']; + ok(pointAgain); + equal(pointAgain.latitude, 44.0); + equal(pointAgain.longitude, -11.0); }); - it('supports array of geo points', done => { - var point1 = new Parse.GeoPoint(44.0, -11.0); - var point2 = new Parse.GeoPoint(22.0, -55.0); - var obj = new TestObject(); - obj.set('locations', [ point1, point2 ]); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var locations = results[0].get('locations'); - expect(locations.length).toEqual(2); - expect(locations[0]).toEqual(point1); - expect(locations[1]).toEqual(point2); - done(); - } - }); - } - }); + it('supports array of geo points', async () => { + const point1 = new Parse.GeoPoint(44.0, -11.0); + const point2 = new Parse.GeoPoint(22.0, -55.0); + const obj = new TestObject(); + obj.set('locations', [point1, point2]); + await obj.save(); + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const locations = results[0].get('locations'); + expect(locations.length).toEqual(2); + expect(locations[0]).toEqual(point1); + expect(locations[1]).toEqual(point2); }); - it('equalTo geopoint', (done) => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); + it('equalTo geopoint', done => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); obj.set('location', point); - obj.save().then(() => { - const query = new Parse.Query(TestObject); - query.equalTo('location', point); - return query.find(); - }).then((results) => { - equal(results.length, 1); - const loc = results[0].get('location'); - equal(loc.latitude, point.latitude); - equal(loc.longitude, point.longitude); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('location', point); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + const loc = results[0].get('location'); + equal(loc.latitude, point.latitude); + equal(loc.longitude, point.longitude); + done(); + }); }); - it('supports withinPolygon open path', (done) => { + it('supports withinPolygon open path', done => { const inbound = new Parse.GeoPoint(1.5, 1.5); const onbound = new Parse.GeoPoint(10, 10); const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('Polygon', {location: inbound}); - const obj2 = new Parse.Object('Polygon', {location: onbound}); - const obj3 = new Parse.Object('Polygon', {location: outbound}); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 0 } - ] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(2); - done(); - }, done.fail); + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); }); - it('supports withinPolygon closed path', (done) => { + it('supports withinPolygon closed path', done => { const inbound = new Parse.GeoPoint(1.5, 1.5); const onbound = new Parse.GeoPoint(10, 10); const outbound = new Parse.GeoPoint(20, 20); - const obj1 = new Parse.Object('Polygon', {location: inbound}); - const obj2 = new Parse.Object('Polygon', {location: onbound}); - const obj3 = new Parse.Object('Polygon', {location: outbound}); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 10 }, - { __type: 'GeoPoint', latitude: 10, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 } - ] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('supports withinPolygon Polygon object', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + const polygon = { + __type: 'Polygon', + coordinates: [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], + }; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('invalid Polygon object withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + const polygon = { + __type: 'Polygon', + coordinates: [[0, 0], [10, 0]], + }; + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - expect(resp.results.length).toBe(2); - done(); - }, done.fail); }); - it('invalid input withinPolygon', (done) => { + it('out of bounds Polygon object withinPolygon', done => { const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', {location: point}); - obj.save().then(() => { - const where = { - location: { - $geoWithin: { - $polygon: 1234 - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj = new Parse.Object('Polygon', { location: point }); + const polygon = { + __type: 'Polygon', + coordinates: [[0, 0], [181, 0], [0, 10]], + }; + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); - it('invalid geoPoint withinPolygon', (done) => { + it('invalid input withinPolygon', done => { const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', {location: point}); - obj.save().then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - {} - ] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: 1234, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); - it('invalid latitude withinPolygon', (done) => { + it('invalid geoPoint withinPolygon', done => { const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', {location: point}); - obj.save().then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 181, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 } - ] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [{}], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(1); - done(); - }); }); - it('invalid longitude withinPolygon', (done) => { + it('invalid latitude withinPolygon', done => { const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', {location: point}); - obj.save().then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [ - { __type: 'GeoPoint', latitude: 0, longitude: 0 }, - { __type: 'GeoPoint', latitude: 0, longitude: 181 }, - { __type: 'GeoPoint', latitude: 0, longitude: 0 } - ] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 181, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(1); - done(); - }); }); - it('minimum 3 points withinPolygon', (done) => { + it('invalid longitude withinPolygon', done => { const point = new Parse.GeoPoint(1.5, 1.5); - const obj = new Parse.Object('Polygon', {location: point}); - obj.save().then(() => { - const where = { - location: { - $geoWithin: { - $polygon: [] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/Polygon', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 181 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(107); - done(); + }); + + it('minimum 3 points withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(107); + done(); + }); + }); + + it('withinKilometers supports count', async () => { + const inside = new Parse.GeoPoint(10, 10); + const outside = new Parse.GeoPoint(20, 20); + + const obj1 = new Parse.Object('TestObject', { location: inside }); + const obj2 = new Parse.Object('TestObject', { location: outside }); + + await Parse.Object.saveAll([obj1, obj2]); + + const q = new Parse.Query(TestObject).withinKilometers( + 'location', + inside, + 5 + ); + const count = await q.count(); + + equal(count, 1); + }); + + it('withinKilometers complex supports count', async () => { + const inside = new Parse.GeoPoint(10, 10); + const middle = new Parse.GeoPoint(20, 20); + const outside = new Parse.GeoPoint(30, 30); + const obj1 = new Parse.Object('TestObject', { location: inside }); + const obj2 = new Parse.Object('TestObject', { location: middle }); + const obj3 = new Parse.Object('TestObject', { location: outside }); + + await Parse.Object.saveAll([obj1, obj2, obj3]); + + const q1 = new Parse.Query(TestObject).withinKilometers( + 'location', + inside, + 5 + ); + const q2 = new Parse.Query(TestObject).withinKilometers( + 'location', + middle, + 5 + ); + const query = Parse.Query.or(q1, q2); + const count = await query.count(); + + equal(count, 2); + }); + + it('fails to fetch geopoints that are specifically not at (0,0)', async () => { + const tmp = new TestObject({ + location: new Parse.GeoPoint({ latitude: 0, longitude: 0 }), + }); + const tmp2 = new TestObject({ + location: new Parse.GeoPoint({ + latitude: 49.2577142, + longitude: -123.1941149, + }), }); + await Parse.Object.saveAll([tmp, tmp2]); + const query = new Parse.Query(TestObject); + query.notEqualTo( + 'location', + new Parse.GeoPoint({ latitude: 0, longitude: 0 }) + ); + const results = await query.find(); + expect(results.length).toEqual(1); }); }); diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 45411d1eb2..9f8b109ed3 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,74 +1,133 @@ 'use strict'; -var request = require('request'); -const Config = require('../src/Config'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); describe('a GlobalConfig', () => { beforeEach(done => { const config = Config.get('test'); - const query = on_db('mongo', () => { - // Legacy is with an int... - return { objectId: 1 }; - }, () => { - return { objectId: "1" } - }) - config.database.adapter.upsertOneObject( - '_GlobalConfig', - { fields: { objectId: { type: 'Number' }, params: {type: 'Object'}} }, - query, - { params: { companies: ['US', 'DK'] } } - ).then(done, (err) => { - jfail(err); + const query = on_db( + 'mongo', + () => { + // Legacy is with an int... + return { objectId: 1 }; + }, + () => { + return { objectId: '1' }; + } + ); + config.database.adapter + .upsertOneObject( + '_GlobalConfig', + { + fields: { + objectId: { type: 'Number' }, + params: { type: 'Object' }, + masterKeyOnly: { type: 'Object' }, + }, + }, + query, + { + params: { companies: ['US', 'DK'], internalParam: 'internal' }, + masterKeyOnly: { internalParam: true }, + } + ) + .then(done, err => { + jfail(err); + done(); + }); + }); + + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + + it('can be retrieved', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.companies).toEqual(['US', 'DK']); + } catch (e) { + jfail(e); + } done(); }); }); - it('can be retrieved', (done) => { - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { + it('internal parameter can be retrieved with master key', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; try { - expect(response.statusCode).toEqual(200); - expect(body.params.companies).toEqual(['US', 'DK']); - } catch(e) { jfail(e); } + expect(response.status).toEqual(200); + expect(body.params.internalParam).toEqual('internal'); + } catch (e) { + jfail(e); + } done(); }); }); - it('can be updated when a master key exists', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: ['US', 'DK', 'SE'] } }, + it('internal parameter cannot be retrieved without master key', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.internalParam).toBeUndefined(); + } catch (e) { + jfail(e); } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); + done(); + }); + }); + + it('can be updated when a master key exists', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: ['US', 'DK', 'SE'] } }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); expect(body.result).toEqual(true); done(); }); }); - it('can add and retrive files', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { file: { __type: 'File', name: 'name', url: 'http://url' } } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); + it('can add and retrive files', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { file: { __type: 'File', name: 'name', url: 'http://url' } }, + }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); expect(body.result).toEqual(true); - Parse.Config.get().then((res) => { + Parse.Config.get().then(res => { const file = res.get('file'); expect(file.name()).toBe('name'); expect(file.url()).toBe('http://url'); @@ -77,20 +136,19 @@ describe('a GlobalConfig', () => { }); }); - it('can add and retrive Geopoints', (done) => { - const geopoint = new Parse.GeoPoint(10,-20); - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { point: geopoint.toJSON() } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); + it('can add and retrive Geopoints', done => { + const geopoint = new Parse.GeoPoint(10, -20); + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { point: geopoint.toJSON() } }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); expect(body.result).toEqual(true); - Parse.Config.get().then((res) => { + Parse.Config.get().then(res => { const point = res.get('point'); expect(point.latitude).toBe(10); expect(point.longitude).toBe(-20); @@ -99,75 +157,84 @@ describe('a GlobalConfig', () => { }); }); - it('properly handles delete op', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: {__op: 'Delete'}, foo: 'bar' } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); + it('properly handles delete op', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + companies: { __op: 'Delete' }, + internalParam: { __op: 'Delete' }, + foo: 'bar', + }, + }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); expect(body.result).toEqual(true); - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; try { - expect(response.statusCode).toEqual(200); + expect(response.status).toEqual(200); expect(body.params.companies).toBeUndefined(); expect(body.params.foo).toBe('bar'); expect(Object.keys(body.params).length).toBe(1); - } catch(e) { jfail(e); } + } catch (e) { + jfail(e); + } done(); }); }); }); - it('fail to update if master key is missing', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: [] } }, + it('fail to update if master key is missing', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: [] } }, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key' : 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(fail, response => { + const body = response.data; + expect(response.status).toEqual(403); expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); - it('failed getting config when it is missing', (done) => { + it('failed getting config when it is missing', done => { const config = Config.get('test'); - config.database.adapter.deleteObjectsByQuery( - '_GlobalConfig', - { fields: { params: { __type: 'String' } } }, - { objectId: "1" } - ).then(() => { - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.params).toEqual({}); + config.database.adapter + .deleteObjectsByQuery( + '_GlobalConfig', + { fields: { params: { __type: 'String' } } }, + { objectId: '1' } + ) + .then(() => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + expect(body.params).toEqual({}); + done(); + }); + }) + .catch(e => { + jfail(e); done(); }); - }).catch((e) => { - jfail(e); - done(); - }); }); }); diff --git a/spec/ParseGraphQLClassNameTransformer.spec.js b/spec/ParseGraphQLClassNameTransformer.spec.js new file mode 100644 index 0000000000..c2a89e2f60 --- /dev/null +++ b/spec/ParseGraphQLClassNameTransformer.spec.js @@ -0,0 +1,11 @@ +const { + transformClassNameToGraphQL, +} = require('../lib/GraphQL/transformers/className'); + +describe('transformClassNameToGraphQL', () => { + it('should remove starting _ and tansform first letter to upper case', () => { + expect( + ['_User', '_user', 'User', 'user'].map(transformClassNameToGraphQL) + ).toEqual(['User', 'User', 'User', 'User']); + }); +}); diff --git a/spec/ParseGraphQLController.spec.js b/spec/ParseGraphQLController.spec.js new file mode 100644 index 0000000000..2d214c2fb6 --- /dev/null +++ b/spec/ParseGraphQLController.spec.js @@ -0,0 +1,1071 @@ +const { + default: ParseGraphQLController, + GraphQLConfigClassName, + GraphQLConfigId, + GraphQLConfigKey, +} = require('../lib/Controllers/ParseGraphQLController'); +const { isEqual } = require('lodash'); + +describe('ParseGraphQLController', () => { + let parseServer; + let databaseController; + let cacheController; + let databaseUpdateArgs; + + // Holds the graphQLConfig in memory instead of using the db + let graphQLConfigRecord; + + const setConfigOnDb = graphQLConfigData => { + graphQLConfigRecord = { + objectId: GraphQLConfigId, + [GraphQLConfigKey]: graphQLConfigData, + }; + }; + const removeConfigFromDb = () => { + graphQLConfigRecord = null; + }; + const getConfigFromDb = () => { + return graphQLConfigRecord; + }; + + beforeAll(async () => { + parseServer = await global.reconfigureServer({ + schemaCacheTTL: 100, + }); + databaseController = parseServer.config.databaseController; + cacheController = parseServer.config.cacheController; + + const defaultFind = databaseController.find.bind(databaseController); + databaseController.find = async (className, query, ...args) => { + if ( + className === GraphQLConfigClassName && + isEqual(query, { objectId: GraphQLConfigId }) + ) { + const graphQLConfigRecord = getConfigFromDb(); + return graphQLConfigRecord ? [graphQLConfigRecord] : []; + } else { + return defaultFind(className, query, ...args); + } + }; + + const defaultUpdate = databaseController.update.bind(databaseController); + databaseController.update = async ( + className, + query, + update, + fullQueryOptions + ) => { + databaseUpdateArgs = [className, query, update, fullQueryOptions]; + if ( + className === GraphQLConfigClassName && + isEqual(query, { objectId: GraphQLConfigId }) && + update && + !!update[GraphQLConfigKey] && + fullQueryOptions && + isEqual(fullQueryOptions, { upsert: true }) + ) { + setConfigOnDb(update[GraphQLConfigKey]); + } else { + return defaultUpdate(...databaseUpdateArgs); + } + }; + }); + + beforeEach(() => { + databaseUpdateArgs = null; + }); + + describe('constructor', () => { + it('should require a databaseController', () => { + expect(() => new ParseGraphQLController()).toThrow( + 'ParseGraphQLController requires a "databaseController" to be instantiated.' + ); + expect(() => new ParseGraphQLController({ cacheController })).toThrow( + 'ParseGraphQLController requires a "databaseController" to be instantiated.' + ); + expect( + () => + new ParseGraphQLController({ + cacheController, + mountGraphQL: false, + }) + ).toThrow( + 'ParseGraphQLController requires a "databaseController" to be instantiated.' + ); + }); + it('should construct without a cacheController', () => { + expect( + () => + new ParseGraphQLController({ + databaseController, + }) + ).not.toThrow(); + expect( + () => + new ParseGraphQLController({ + databaseController, + mountGraphQL: true, + }) + ).not.toThrow(); + }); + it('should set isMounted to true if config.mountGraphQL is true', () => { + const mountedController = new ParseGraphQLController({ + databaseController, + mountGraphQL: true, + }); + expect(mountedController.isMounted).toBe(true); + const unmountedController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + expect(unmountedController.isMounted).toBe(false); + const unmountedController2 = new ParseGraphQLController({ + databaseController, + }); + expect(unmountedController2.isMounted).toBe(false); + }); + }); + + describe('getGraphQLConfig', () => { + it('should return an empty graphQLConfig if collection has none', async () => { + removeConfigFromDb(); + + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({}); + }); + it('should return an existing graphQLConfig', async () => { + setConfigOnDb({ enabledForClasses: ['_User'] }); + + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ enabledForClasses: ['_User'] }); + }); + it('should use the cache if mounted, and return the stored graphQLConfig', async () => { + removeConfigFromDb(); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + cacheController.graphQL.put(parseGraphQLController.configCacheKey, { + enabledForClasses: ['SuperCar'], + }); + + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ enabledForClasses: ['SuperCar'] }); + }); + it('should use the database when mounted and cache is empty', async () => { + setConfigOnDb({ disabledForClasses: ['SuperCar'] }); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ disabledForClasses: ['SuperCar'] }); + }); + it('should store the graphQLConfig in cache if mounted', async () => { + setConfigOnDb({ enabledForClasses: ['SuperCar'] }); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const cachedValueBefore = await cacheController.graphQL.get( + parseGraphQLController.configCacheKey + ); + expect(cachedValueBefore).toBeNull(); + await parseGraphQLController.getGraphQLConfig(); + const cachedValueAfter = await cacheController.graphQL.get( + parseGraphQLController.configCacheKey + ); + expect(cachedValueAfter).toEqual({ enabledForClasses: ['SuperCar'] }); + }); + }); + + describe('updateGraphQLConfig', () => { + const successfulUpdateResponse = { response: { result: true } }; + + it('should throw if graphQLConfig is not provided', async function() { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig() + ).toBeRejectedWith('You must provide a graphQLConfig!'); + }); + + it('should correct update the graphQLConfig object using the databaseController', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + const graphQLConfig = { + enabledForClasses: ['ClassA', 'ClassB'], + disabledForClasses: [], + classConfigs: [ + { className: 'ClassA', query: { get: false } }, + { className: 'ClassB', mutation: { destroy: false }, type: {} }, + ], + }; + + await parseGraphQLController.updateGraphQLConfig(graphQLConfig); + + expect(databaseUpdateArgs).toBeTruthy(); + const [className, query, update, op] = databaseUpdateArgs; + expect(className).toBe(GraphQLConfigClassName); + expect(query).toEqual({ objectId: GraphQLConfigId }); + expect(update).toEqual({ + [GraphQLConfigKey]: graphQLConfig, + }); + expect(op).toEqual({ upsert: true }); + }); + + it('should throw if graphQLConfig is not an object', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig([]) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig(function() {}) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig(Promise.resolve({})) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig('') + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({}) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if graphQLConfig has an invalid root key', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ invalidKey: true }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({}) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if graphQLConfig has invalid class filters', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ enabledForClasses: {} }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: [undefined], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + disabledForClasses: [null], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: ['_User', null], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ disabledForClasses: [''] }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: [], + disabledForClasses: ['_User'], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if classConfigs array is invalid', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ classConfigs: {} }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ classConfigs: [null] }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [undefined], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [{ className: 'ValidClass' }, null], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ classConfigs: [] }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.inputFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: [], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + invalidKey: true, + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: {}, + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + update: [null], + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: [], + update: [], + }, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: ['make', 'model'], + update: [], + }, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.outputFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: ['name', undefined], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [''], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: ['name'], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.constraintFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: ['name', undefined], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [''], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: ['name'], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.sortFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: undefined, + asc: true, + desc: true, + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: '', + asc: true, + desc: false, + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: 'false', + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: true, + }, + null, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: true, + }, + ], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid query params', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + get: 1, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + find: 'true', + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + get: false, + find: true, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid mutation params', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + destroy: 1, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + update: 'true', + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + create: true, + update: true, + destroy: false, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + + it('should throw if _User create fields is missing username or password', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + inputFields: { + create: ['username', 'no-password'], + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + inputFields: { + create: ['username', 'password'], + }, + }, + }, + ], + }) + ).toBeResolved(successfulUpdateResponse); + }); + it('should update the cache if mounted', async () => { + removeConfigFromDb(); + cacheController.graphQL.clear(); + const mountedController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const unmountedController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: false, + }); + + let cacheBeforeValue; + let cacheAfterValue; + + cacheBeforeValue = await cacheController.graphQL.get( + mountedController.configCacheKey + ); + expect(cacheBeforeValue).toBeNull(); + + await mountedController.updateGraphQLConfig({ + enabledForClasses: ['SuperCar'], + }); + cacheAfterValue = await cacheController.graphQL.get( + mountedController.configCacheKey + ); + expect(cacheAfterValue).toEqual({ enabledForClasses: ['SuperCar'] }); + + // reset + removeConfigFromDb(); + cacheController.graphQL.clear(); + + cacheBeforeValue = await cacheController.graphQL.get( + unmountedController.configCacheKey + ); + expect(cacheBeforeValue).toBeNull(); + + await unmountedController.updateGraphQLConfig({ + enabledForClasses: ['SuperCar'], + }); + cacheAfterValue = await cacheController.graphQL.get( + unmountedController.configCacheKey + ); + expect(cacheAfterValue).toBeNull(); + }); + }); + + describe('alias', () => { + it('should fail if query alias is not a string', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + + const className = 'Bar'; + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + query: { + get: true, + getAlias: 1, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "query.getAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + query: { + find: true, + findAlias: { not: 'valid' }, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "query.findAlias" must be a string` + ); + }); + + it('should fail if mutation alias is not a string', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + + const className = 'Bar'; + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + create: true, + createAlias: true, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.createAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + update: true, + updateAlias: 1, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.updateAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + destroy: true, + destroyAlias: { not: 'valid' }, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.destroyAlias" must be a string` + ); + }); + }); +}); diff --git a/spec/ParseGraphQLSchema.spec.js b/spec/ParseGraphQLSchema.spec.js new file mode 100644 index 0000000000..3b368b57b4 --- /dev/null +++ b/spec/ParseGraphQLSchema.spec.js @@ -0,0 +1,641 @@ +const { GraphQLObjectType } = require('graphql'); +const defaultLogger = require('../lib/logger').default; +const { ParseGraphQLSchema } = require('../lib/GraphQL/ParseGraphQLSchema'); + +describe('ParseGraphQLSchema', () => { + let parseServer; + let databaseController; + let parseGraphQLController; + let parseGraphQLSchema; + const appId = 'test'; + + beforeAll(async () => { + parseServer = await global.reconfigureServer({ + schemaCacheTTL: 100, + }); + databaseController = parseServer.config.databaseController; + parseGraphQLController = parseServer.config.parseGraphQLController; + parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + }); + + describe('constructor', () => { + it('should require a parseGraphQLController, databaseController, a log instance, and the appId', () => { + expect(() => new ParseGraphQLSchema()).toThrow( + 'You must provide a parseGraphQLController instance!' + ); + expect( + () => new ParseGraphQLSchema({ parseGraphQLController: {} }) + ).toThrow('You must provide a databaseController instance!'); + expect( + () => + new ParseGraphQLSchema({ + parseGraphQLController: {}, + databaseController: {}, + }) + ).toThrow('You must provide a log instance!'); + expect( + () => + new ParseGraphQLSchema({ + parseGraphQLController: {}, + databaseController: {}, + log: {}, + }) + ).toThrow('You must provide the appId!'); + }); + }); + + describe('load', () => { + it('should cache schema', async () => { + const graphQLSchema = await parseGraphQLSchema.load(); + const updatedGraphQLSchema = await parseGraphQLSchema.load(); + expect(graphQLSchema).toBe(updatedGraphQLSchema); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(graphQLSchema).toBe(await parseGraphQLSchema.load()); + }); + + it('should load a brand new GraphQL Schema if Parse Schema changes', async () => { + await parseGraphQLSchema.load(); + const parseClasses = parseGraphQLSchema.parseClasses; + const parseClassesString = parseGraphQLSchema.parseClassesString; + const parseClassTypes = parseGraphQLSchema.parseClassTypes; + const graphQLSchema = parseGraphQLSchema.graphQLSchema; + const graphQLTypes = parseGraphQLSchema.graphQLTypes; + const graphQLQueries = parseGraphQLSchema.graphQLQueries; + const graphQLMutations = parseGraphQLSchema.graphQLMutations; + const graphQLSubscriptions = parseGraphQLSchema.graphQLSubscriptions; + const newClassObject = new Parse.Object('NewClass'); + await newClassObject.save(); + await databaseController.schemaCache.clear(); + await new Promise(resolve => setTimeout(resolve, 200)); + await parseGraphQLSchema.load(); + expect(parseClasses).not.toBe(parseGraphQLSchema.parseClasses); + expect(parseClassesString).not.toBe( + parseGraphQLSchema.parseClassesString + ); + expect(parseClassTypes).not.toBe(parseGraphQLSchema.parseClassTypes); + expect(graphQLSchema).not.toBe(parseGraphQLSchema.graphQLSchema); + expect(graphQLTypes).not.toBe(parseGraphQLSchema.graphQLTypes); + expect(graphQLQueries).not.toBe(parseGraphQLSchema.graphQLQueries); + expect(graphQLMutations).not.toBe(parseGraphQLSchema.graphQLMutations); + expect(graphQLSubscriptions).not.toBe( + parseGraphQLSchema.graphQLSubscriptions + ); + }); + + it('should load a brand new GraphQL Schema if graphQLConfig changes', async () => { + const parseGraphQLController = { + graphQLConfig: { enabledForClasses: [] }, + getGraphQLConfig() { + return this.graphQLConfig; + }, + }; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + await parseGraphQLSchema.load(); + const parseClasses = parseGraphQLSchema.parseClasses; + const parseClassesString = parseGraphQLSchema.parseClassesString; + const parseClassTypes = parseGraphQLSchema.parseClassTypes; + const graphQLSchema = parseGraphQLSchema.graphQLSchema; + const graphQLTypes = parseGraphQLSchema.graphQLTypes; + const graphQLQueries = parseGraphQLSchema.graphQLQueries; + const graphQLMutations = parseGraphQLSchema.graphQLMutations; + const graphQLSubscriptions = parseGraphQLSchema.graphQLSubscriptions; + + parseGraphQLController.graphQLConfig = { + enabledForClasses: ['_User'], + }; + + await new Promise(resolve => setTimeout(resolve, 200)); + await parseGraphQLSchema.load(); + expect(parseClasses).not.toBe(parseGraphQLSchema.parseClasses); + expect(parseClassesString).not.toBe( + parseGraphQLSchema.parseClassesString + ); + expect(parseClassTypes).not.toBe(parseGraphQLSchema.parseClassTypes); + expect(graphQLSchema).not.toBe(parseGraphQLSchema.graphQLSchema); + expect(graphQLTypes).not.toBe(parseGraphQLSchema.graphQLTypes); + expect(graphQLQueries).not.toBe(parseGraphQLSchema.graphQLQueries); + expect(graphQLMutations).not.toBe(parseGraphQLSchema.graphQLMutations); + expect(graphQLSubscriptions).not.toBe( + parseGraphQLSchema.graphQLSubscriptions + ); + }); + }); + + describe('addGraphQLType', () => { + it('should not load and warn duplicated types', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Type SomeClass could not be added to the auto schema because it collided with an existing type.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'SomeClass' }); + expect(parseGraphQLSchema.addGraphQLType(type)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + expect( + parseGraphQLSchema.addGraphQLType( + new GraphQLObjectType({ name: 'SomeClass' }) + ) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'SomeClass' }); + expect(parseGraphQLSchema.addGraphQLType(type, true)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + expect(() => + parseGraphQLSchema.addGraphQLType( + new GraphQLObjectType({ name: 'SomeClass' }), + true + ) + ).toThrowError( + 'Type SomeClass could not be added to the auto schema because it collided with an existing type.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Type String could not be added to the auto schema because it collided with an existing type.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect( + parseGraphQLSchema.addGraphQLType( + new GraphQLObjectType({ name: 'String' }) + ) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'String' }); + expect(parseGraphQLSchema.addGraphQLType(type, true, true)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + }); + }); + + describe('addGraphQLQuery', () => { + it('should not load and warn duplicated queries', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Query someClasses could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLQuery('someClasses', field)).toBe( + field + ); + expect(parseGraphQLSchema.graphQLQueries['someClasses']).toBe(field); + expect( + parseGraphQLSchema.addGraphQLQuery('someClasses', {}) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLQuery('someClasses', field)).toBe( + field + ); + expect(parseGraphQLSchema.graphQLQueries['someClasses']).toBe(field); + expect(() => + parseGraphQLSchema.addGraphQLQuery('someClasses', {}, true) + ).toThrowError( + 'Query someClasses could not be added to the auto schema because it collided with an existing field.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Query viewer could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect(parseGraphQLSchema.addGraphQLQuery('viewer', {})).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + delete parseGraphQLSchema.graphQLQueries.viewer; + const field = {}; + expect( + parseGraphQLSchema.addGraphQLQuery('viewer', field, true, true) + ).toBe(field); + expect(parseGraphQLSchema.graphQLQueries['viewer']).toBe(field); + }); + }); + + describe('addGraphQLMutation', () => { + it('should not load and warn duplicated mutations', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Mutation createSomeClass could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect( + parseGraphQLSchema.addGraphQLMutation('createSomeClass', field) + ).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['createSomeClass']).toBe( + field + ); + expect( + parseGraphQLSchema.addGraphQLMutation('createSomeClass', {}) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect( + parseGraphQLSchema.addGraphQLMutation('createSomeClass', field) + ).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['createSomeClass']).toBe( + field + ); + expect(() => + parseGraphQLSchema.addGraphQLMutation('createSomeClass', {}, true) + ).toThrowError( + 'Mutation createSomeClass could not be added to the auto schema because it collided with an existing field.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Mutation signUp could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect( + parseGraphQLSchema.addGraphQLMutation('signUp', {}) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + delete parseGraphQLSchema.graphQLMutations.signUp; + const field = {}; + expect( + parseGraphQLSchema.addGraphQLMutation('signUp', field, true, true) + ).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['signUp']).toBe(field); + }); + }); + + describe('_getParseClassesWithConfig', () => { + it('should sort classes', () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + expect( + parseGraphQLSchema + ._getParseClassesWithConfig( + [ + { className: 'b' }, + { className: '_b' }, + { className: 'B' }, + { className: '_B' }, + { className: 'a' }, + { className: '_a' }, + { className: 'A' }, + { className: '_A' }, + ], + { + classConfigs: [], + } + ) + .map(item => item[0]) + ).toEqual([ + { className: '_A' }, + { className: '_B' }, + { className: '_a' }, + { className: '_b' }, + { className: 'A' }, + { className: 'B' }, + { className: 'a' }, + { className: 'b' }, + ]); + }); + }); + + describe('name collision', () => { + it('should not generate duplicate types when colliding to default classes', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const types1 = parseGraphQLSchema.graphQLTypes; + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const user = new Parse.Object('User'); + await user.save(); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const types2 = parseGraphQLSchema.graphQLTypes; + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(types1).not.toBe(types2); + expect(types1.map(type => type.name).sort()).toEqual( + types2.map(type => type.name).sort() + ); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual( + Object.keys(queries2).sort() + ); + expect(mutations1).not.toBe(mutations2); + expect(Object.keys(mutations1).sort()).toEqual( + Object.keys(mutations2).sort() + ); + }); + + it('should not generate duplicate types when colliding the same name', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + const car1 = new Parse.Object('Car'); + await car1.save(); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const types1 = parseGraphQLSchema.graphQLTypes; + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const car2 = new Parse.Object('car'); + await car2.save(); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const types2 = parseGraphQLSchema.graphQLTypes; + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(types1).not.toBe(types2); + expect(types1.map(type => type.name).sort()).toEqual( + types2.map(type => type.name).sort() + ); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual( + Object.keys(queries2).sort() + ); + expect(mutations1).not.toBe(mutations2); + expect(Object.keys(mutations1).sort()).toEqual( + Object.keys(mutations2).sort() + ); + }); + + it('should not generate duplicate queries when query name collide', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + const car = new Parse.Object('Car'); + await car.save(); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const cars = new Parse.Object('cars'); + await cars.save(); + await parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual( + Object.keys(queries2).sort() + ); + expect(mutations1).not.toBe(mutations2); + expect( + Object.keys(mutations1) + .concat('createCars', 'updateCars', 'deleteCars') + .sort() + ).toEqual(Object.keys(mutations2).sort()); + }); + }); + describe('alias', () => { + it('Should be able to define alias for get and find query', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + + await parseGraphQLSchema.parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'Data', + query: { + get: true, + getAlias: 'precious_data', + find: true, + findAlias: 'data_results', + }, + }, + ], + }); + + const data = new Parse.Object('Data'); + + await data.save(); + + await parseGraphQLSchema.databaseController.schemaCache.clear(); + await parseGraphQLSchema.load(); + + const queries1 = parseGraphQLSchema.graphQLQueries; + + expect(Object.keys(queries1)).toContain('data_results'); + expect(Object.keys(queries1)).toContain('precious_data'); + }); + + it('Should be able to define alias for mutation', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + + await parseGraphQLSchema.parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'Track', + mutation: { + create: true, + createAlias: 'addTrack', + update: true, + updateAlias: 'modifyTrack', + destroy: true, + destroyAlias: 'eraseTrack', + }, + }, + ], + }); + + const data = new Parse.Object('Track'); + + await data.save(); + + await parseGraphQLSchema.databaseController.schemaCache.clear(); + await parseGraphQLSchema.load(); + + const mutations = parseGraphQLSchema.graphQLMutations; + + expect(Object.keys(mutations)).toContain('addTrack'); + expect(Object.keys(mutations)).toContain('modifyTrack'); + expect(Object.keys(mutations)).toContain('eraseTrack'); + }); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js new file mode 100644 index 0000000000..ba11c0a934 --- /dev/null +++ b/spec/ParseGraphQLServer.spec.js @@ -0,0 +1,10897 @@ +const http = require('http'); +const express = require('express'); +const req = require('../lib/request'); +const fetch = require('node-fetch'); +const FormData = require('form-data'); +const ws = require('ws'); +require('./helper'); +const { updateCLP } = require('./dev'); + +const pluralize = require('pluralize'); +const { getMainDefinition } = require('apollo-utilities'); +const { ApolloLink, split } = require('apollo-link'); +const { createHttpLink } = require('apollo-link-http'); +const { InMemoryCache } = require('apollo-cache-inmemory'); +const { createUploadLink } = require('apollo-upload-client'); +const { SubscriptionClient } = require('subscriptions-transport-ws'); +const { WebSocketLink } = require('apollo-link-ws'); +const ApolloClient = require('apollo-client').default; +const gql = require('graphql-tag'); +const { + GraphQLObjectType, + GraphQLString, + GraphQLNonNull, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLSchema, +} = require('graphql'); +const { ParseServer } = require('../'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); +const ReadPreference = require('mongodb').ReadPreference; +const uuidv4 = require('uuid/v4'); + +function handleError(e) { + if ( + e && + e.networkError && + e.networkError.result && + e.networkError.result.errors + ) { + fail(e.networkError.result.errors); + } else { + fail(e); + } +} + +describe('ParseGraphQLServer', () => { + let parseServer; + let parseGraphQLServer; + + beforeAll(async () => { + parseServer = await global.reconfigureServer({}); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + subscriptionsPath: '/subscriptions', + }); + }); + + describe('constructor', () => { + it('should require a parseServer instance', () => { + expect(() => new ParseGraphQLServer()).toThrow( + 'You must provide a parseServer instance!' + ); + }); + + it('should require config.graphQLPath', () => { + expect(() => new ParseGraphQLServer(parseServer)).toThrow( + 'You must provide a config.graphQLPath!' + ); + expect(() => new ParseGraphQLServer(parseServer, {})).toThrow( + 'You must provide a config.graphQLPath!' + ); + }); + + it('should only require parseServer and config.graphQLPath args', () => { + let parseGraphQLServer; + expect(() => { + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + }).not.toThrow(); + expect(parseGraphQLServer.parseGraphQLSchema).toBeDefined(); + expect(parseGraphQLServer.parseGraphQLSchema.databaseController).toEqual( + parseServer.config.databaseController + ); + }); + + it('should initialize parseGraphQLSchema with a log controller', async () => { + const loggerAdapter = { + log: () => {}, + error: () => {}, + }; + const parseServer = await global.reconfigureServer({ + loggerAdapter, + }); + const parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + expect(parseGraphQLServer.parseGraphQLSchema.log.adapter).toBe( + loggerAdapter + ); + }); + }); + + describe('_getGraphQLOptions', () => { + const req = { + info: new Object(), + config: new Object(), + auth: new Object(), + }; + + it("should return schema and context with req's info, config and auth", async () => { + const options = await parseGraphQLServer._getGraphQLOptions(req); + expect(options.schema).toEqual( + parseGraphQLServer.parseGraphQLSchema.graphQLSchema + ); + expect(options.context.info).toEqual(req.info); + expect(options.context.config).toEqual(req.config); + expect(options.context.auth).toEqual(req.auth); + }); + + it('should load GraphQL schema in every call', async () => { + const originalLoad = parseGraphQLServer.parseGraphQLSchema.load; + let counter = 0; + parseGraphQLServer.parseGraphQLSchema.load = () => ++counter; + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual( + 1 + ); + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual( + 2 + ); + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual( + 3 + ); + parseGraphQLServer.parseGraphQLSchema.load = originalLoad; + }); + }); + + describe('_transformMaxUploadSizeToBytes', () => { + it('should transform to bytes', () => { + expect(parseGraphQLServer._transformMaxUploadSizeToBytes('20mb')).toBe( + 20971520 + ); + expect(parseGraphQLServer._transformMaxUploadSizeToBytes('333Gb')).toBe( + 357556027392 + ); + expect( + parseGraphQLServer._transformMaxUploadSizeToBytes('123456KB') + ).toBe(126418944); + }); + }); + + describe('applyGraphQL', () => { + it('should require an Express.js app instance', () => { + expect(() => parseGraphQLServer.applyGraphQL()).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyGraphQL({})).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => + parseGraphQLServer.applyGraphQL(new express()) + ).not.toThrow(); + }); + + it('should apply middlewares at config.graphQLPath', () => { + let useCount = 0; + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'somepath', + }).applyGraphQL({ + use: path => { + useCount++; + expect(path).toEqual('somepath'); + }, + }) + ).not.toThrow(); + expect(useCount).toBeGreaterThan(0); + }); + }); + + describe('applyPlayground', () => { + it('should require an Express.js app instance', () => { + expect(() => parseGraphQLServer.applyPlayground()).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyPlayground({})).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => + parseGraphQLServer.applyPlayground(new express()) + ).not.toThrow(); + }); + + it('should require initialization with config.playgroundPath', () => { + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }).applyPlayground(new express()) + ).toThrow('You must provide a config.playgroundPath to applyPlayground!'); + }); + + it('should apply middlewares at config.playgroundPath', () => { + let useCount = 0; + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphQL', + playgroundPath: 'somepath', + }).applyPlayground({ + get: path => { + useCount++; + expect(path).toEqual('somepath'); + }, + }) + ).not.toThrow(); + expect(useCount).toBeGreaterThan(0); + }); + }); + + describe('createSubscriptions', () => { + it('should require initialization with config.subscriptionsPath', () => { + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }).createSubscriptions({}) + ).toThrow( + 'You must provide a config.subscriptionsPath to createSubscriptions!' + ); + }); + }); + + describe('setGraphQLConfig', () => { + let parseGraphQLServer; + beforeEach(() => { + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + }); + it('should pass the graphQLConfig onto the parseGraphQLController', async () => { + let received; + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig(graphQLConfig) { + received = graphQLConfig; + return {}; + }, + }; + const graphQLConfig = { enabledForClasses: [] }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + expect(received).toBe(graphQLConfig); + }); + it('should not absorb exceptions from parseGraphQLController', async () => { + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig() { + throw new Error('Network request failed'); + }, + }; + await expectAsync( + parseGraphQLServer.setGraphQLConfig({}) + ).toBeRejectedWith(new Error('Network request failed')); + }); + it('should return the response from parseGraphQLController', async () => { + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig() { + return { response: { result: true } }; + }, + }; + await expectAsync( + parseGraphQLServer.setGraphQLConfig({}) + ).toBeResolvedTo({ response: { result: true } }); + }); + }); + + describe('Auto API', () => { + let httpServer; + let parseLiveQueryServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + + let apolloClient; + + let user1; + let user2; + let user3; + let user4; + let user5; + let role; + let object1; + let object2; + let object3; + let object4; + let objects = []; + + async function prepareData() { + user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('user1'); + user1.setEmail('user1@user1.user1'); + await user1.signUp(); + + user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('user2'); + await user2.signUp(); + + user3 = new Parse.User(); + user3.setUsername('user3'); + user3.setPassword('user3'); + await user3.signUp(); + + user4 = new Parse.User(); + user4.setUsername('user4'); + user4.setPassword('user4'); + await user4.signUp(); + + user5 = new Parse.User(); + user5.setUsername('user5'); + user5.setPassword('user5'); + await user5.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + role = new Parse.Role(); + role.setName('role'); + role.setACL(roleACL); + role.getUsers().add(user1); + role.getUsers().add(user3); + role = await role.save(); + + const schemaController = await parseServer.config.databaseController.loadSchema(); + try { + await schemaController.addClassIfNotExists( + 'GraphQLClass', + { + someField: { type: 'String' }, + pointerToUser: { type: 'Pointer', targetClass: '_User' }, + }, + { + find: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + create: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + get: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + update: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + addField: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + delete: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + readUserFields: ['pointerToUser'], + writeUserFields: ['pointerToUser'], + }, + {} + ); + } catch (err) { + if ( + !(err instanceof Parse.Error) || + err.message !== 'Class GraphQLClass already exists.' + ) { + throw err; + } + } + + object1 = new Parse.Object('GraphQLClass'); + object1.set('someField', 'someValue1'); + object1.set('someOtherField', 'A'); + const object1ACL = new Parse.ACL(); + object1ACL.setPublicReadAccess(false); + object1ACL.setPublicWriteAccess(false); + object1ACL.setRoleReadAccess(role, true); + object1ACL.setRoleWriteAccess(role, true); + object1ACL.setReadAccess(user1.id, true); + object1ACL.setWriteAccess(user1.id, true); + object1ACL.setReadAccess(user2.id, true); + object1ACL.setWriteAccess(user2.id, true); + object1.setACL(object1ACL); + await object1.save(undefined, { useMasterKey: true }); + + object2 = new Parse.Object('GraphQLClass'); + object2.set('someField', 'someValue2'); + object2.set('someOtherField', 'A'); + const object2ACL = new Parse.ACL(); + object2ACL.setPublicReadAccess(false); + object2ACL.setPublicWriteAccess(false); + object2ACL.setReadAccess(user1.id, true); + object2ACL.setWriteAccess(user1.id, true); + object2ACL.setReadAccess(user2.id, true); + object2ACL.setWriteAccess(user2.id, true); + object2ACL.setReadAccess(user5.id, true); + object2ACL.setWriteAccess(user5.id, true); + object2.setACL(object2ACL); + await object2.save(undefined, { useMasterKey: true }); + + object3 = new Parse.Object('GraphQLClass'); + object3.set('someField', 'someValue3'); + object3.set('someOtherField', 'B'); + object3.set('pointerToUser', user5); + await object3.save(undefined, { useMasterKey: true }); + + object4 = new Parse.Object('PublicClass'); + object4.set('someField', 'someValue4'); + await object4.save(); + + objects = []; + objects.push(object1, object2, object3, object4); + } + + beforeAll(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', parseServer.app); + parseLiveQueryServer = ParseServer.createLiveQueryServer(httpServer, { + port: 1338, + }); + parseGraphQLServer.applyGraphQL(expressApp); + parseGraphQLServer.applyPlayground(expressApp); + parseGraphQLServer.createSubscriptions(httpServer); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + + const subscriptionClient = new SubscriptionClient( + 'ws://localhost:13377/subscriptions', + { + reconnect: true, + connectionParams: headers, + }, + ws + ); + const wsLink = new WebSocketLink(subscriptionClient); + const httpLink = createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: split( + ({ query }) => { + const { kind, operation } = getMainDefinition(query); + return ( + kind === 'OperationDefinition' && operation === 'subscription' + ); + }, + wsLink, + httpLink + ), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + beforeEach(() => { + spyOn(console, 'warn').and.callFake(() => {}); + spyOn(console, 'error').and.callFake(() => {}); + }); + + afterAll(async () => { + await parseLiveQueryServer.server.close(); + await httpServer.close(); + }); + + describe('GraphQL', () => { + it('should be healthy', async () => { + try { + const health = ( + await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }) + ).data.health; + expect(health).toBeTruthy(); + } catch (e) { + handleError(e); + } + }); + + it('should be cors enabled', async () => { + let checked = false; + const apolloClient = new ApolloClient({ + link: new ApolloLink((operation, forward) => { + return forward(operation).map(response => { + const context = operation.getContext(); + const { + response: { headers }, + } = context; + expect(headers.get('access-control-allow-origin')).toEqual('*'); + checked = true; + return response; + }); + }).concat( + createHttpLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers: { + ...headers, + Origin: 'http://someorigin.com', + }, + }) + ), + cache: new InMemoryCache(), + }); + const healthResponse = await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }); + expect(healthResponse.data.health).toBeTruthy(); + expect(checked).toBeTruthy(); + }); + + it('should handle Parse headers', async () => { + let checked = false; + const originalGetGraphQLOptions = parseGraphQLServer._getGraphQLOptions; + parseGraphQLServer._getGraphQLOptions = async req => { + expect(req.info).toBeDefined(); + expect(req.config).toBeDefined(); + expect(req.auth).toBeDefined(); + checked = true; + return await originalGetGraphQLOptions.bind(parseGraphQLServer)(req); + }; + const health = ( + await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }) + ).data.health; + expect(health).toBeTruthy(); + expect(checked).toBeTruthy(); + parseGraphQLServer._getGraphQLOptions = originalGetGraphQLOptions; + }); + }); + + describe('Playground', () => { + it('should mount playground', async () => { + const res = await req({ + method: 'GET', + url: 'http://localhost:13377/playground', + }); + expect(res.status).toEqual(200); + }); + }); + + describe('Schema', () => { + const resetGraphQLCache = async () => { + await Promise.all([ + parseGraphQLServer.parseGraphQLController.cacheController.graphQL.clear(), + parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(), + ]); + }; + + describe('Default Types', () => { + it('should have Object scalar type', async () => { + const objectType = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "Object") { + kind + } + } + `, + }) + ).data['__type']; + expect(objectType.kind).toEqual('SCALAR'); + }); + + it('should have Date scalar type', async () => { + const dateType = ( + await apolloClient.query({ + query: gql` + query DateType { + __type(name: "Date") { + kind + } + } + `, + }) + ).data['__type']; + expect(dateType.kind).toEqual('SCALAR'); + }); + + it('should have ArrayResult type', async () => { + const arrayResultType = ( + await apolloClient.query({ + query: gql` + query ArrayResultType { + __type(name: "ArrayResult") { + kind + } + } + `, + }) + ).data['__type']; + expect(arrayResultType.kind).toEqual('UNION'); + }); + + it('should have File object type', async () => { + const fileType = ( + await apolloClient.query({ + query: gql` + query FileType { + __type(name: "FileInfo") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(fileType.kind).toEqual('OBJECT'); + expect(fileType.fields.map(field => field.name).sort()).toEqual([ + 'name', + 'url', + ]); + }); + + it('should have Class interface type', async () => { + const classType = ( + await apolloClient.query({ + query: gql` + query ClassType { + __type(name: "ParseObject") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(classType.kind).toEqual('INTERFACE'); + expect(classType.fields.map(field => field.name).sort()).toEqual([ + 'ACL', + 'createdAt', + 'objectId', + 'updatedAt', + ]); + }); + + it('should have ReadPreference enum type', async () => { + const readPreferenceType = ( + await apolloClient.query({ + query: gql` + query ReadPreferenceType { + __type(name: "ReadPreference") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(readPreferenceType.kind).toEqual('ENUM'); + expect( + readPreferenceType.enumValues.map(value => value.name).sort() + ).toEqual([ + 'NEAREST', + 'PRIMARY', + 'PRIMARY_PREFERRED', + 'SECONDARY', + 'SECONDARY_PREFERRED', + ]); + }); + + it('should have GraphQLUpload object type', async () => { + const graphQLUploadType = ( + await apolloClient.query({ + query: gql` + query GraphQLUploadType { + __type(name: "Upload") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(graphQLUploadType.kind).toEqual('SCALAR'); + }); + + it('should have all expected types', async () => { + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + }) + ).data['__schema'].types.map(type => type.name); + + const expectedTypes = [ + 'ParseObject', + 'Date', + 'FileInfo', + 'ReadPreference', + 'Upload', + ]; + expect( + expectedTypes.every(type => schemaTypes.indexOf(type) !== -1) + ).toBeTruthy(JSON.stringify(schemaTypes.types)); + }); + }); + + describe('Relay Specific Types', () => { + beforeAll(async () => { + await resetGraphQLCache(); + }); + + afterAll(async () => { + await resetGraphQLCache(); + }); + + it('should have Node interface', async () => { + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + }) + ).data['__schema'].types.map(type => type.name); + + expect(schemaTypes).toContain('Node'); + }); + + it('should have node query', async () => { + const queryFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "Query") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + + expect(queryFields).toContain('node'); + }); + + it('should return global id', async () => { + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + + expect(userFields).toContain('id'); + expect(userFields).toContain('objectId'); + }); + + it('should have clientMutationId in create file input', async () => { + const createFileInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateFileInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createFileInputFields).toEqual(['clientMutationId', 'upload']); + }); + + it('should have clientMutationId in create file payload', async () => { + const createFilePayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateFilePayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createFilePayloadFields).toEqual([ + 'clientMutationId', + 'fileInfo', + ]); + }); + + it('should have clientMutationId in call function input', async () => { + Parse.Cloud.define('hello', () => {}); + + const callFunctionInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CallCloudCodeInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(callFunctionInputFields).toEqual([ + 'clientMutationId', + 'functionName', + 'params', + ]); + }); + + it('should have clientMutationId in call function payload', async () => { + Parse.Cloud.define('hello', () => {}); + + const callFunctionPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CallCloudCodePayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(callFunctionPayloadFields).toEqual([ + 'clientMutationId', + 'result', + ]); + }); + + it('should have clientMutationId in sign up mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "SignUpInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'fields']); + }); + + it('should have clientMutationId in sign up mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "SignUpPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'viewer']); + }); + + it('should have clientMutationId in log in mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogInInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual([ + 'clientMutationId', + 'password', + 'username', + ]); + }); + + it('should have clientMutationId in log in mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogInPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'viewer']); + }); + + it('should have clientMutationId in log out mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogOutInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId']); + }); + + it('should have clientMutationId in log out mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogOutPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'viewer']); + }); + + it('should have clientMutationId in createClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual([ + 'clientMutationId', + 'name', + 'schemaFields', + ]); + }); + + it('should have clientMutationId in createClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in updateClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual([ + 'clientMutationId', + 'name', + 'schemaFields', + ]); + }); + + it('should have clientMutationId in updateClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in deleteClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'name']); + }); + + it('should have clientMutationId in deleteClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in custom create object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual([ + 'clientMutationId', + 'fields', + ]); + }); + + it('should have clientMutationId in custom create object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual([ + 'clientMutationId', + 'someClass', + ]); + }); + + it('should have clientMutationId in custom update object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual([ + 'clientMutationId', + 'fields', + 'id', + ]); + }); + + it('should have clientMutationId in custom update object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual([ + 'clientMutationId', + 'someClass', + ]); + }); + + it('should have clientMutationId in custom delete object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual(['clientMutationId', 'id']); + }); + + it('should have clientMutationId in custom delete object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual([ + 'clientMutationId', + 'someClass', + ]); + }); + }); + + describe('Parse Class Types', () => { + it('should have all expected types', async () => { + await parseServer.config.databaseController.loadSchema(); + + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + }) + ).data['__schema'].types.map(type => type.name); + + const expectedTypes = [ + 'Role', + 'RoleWhereInput', + 'CreateRoleFieldsInput', + 'UpdateRoleFieldsInput', + 'RoleConnection', + 'User', + 'UserWhereInput', + 'UserConnection', + 'CreateUserFieldsInput', + 'UpdateUserFieldsInput', + ]; + expect( + expectedTypes.every(type => schemaTypes.indexOf(type) !== -1) + ).toBeTruthy(JSON.stringify(schemaTypes)); + }); + + it('should ArrayResult contains all types', async () => { + const objectType = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "ArrayResult") { + kind + possibleTypes { + name + } + } + } + `, + }) + ).data['__type']; + const possibleTypes = objectType.possibleTypes.map(o => o.name); + expect(possibleTypes).toContain('User'); + expect(possibleTypes).toContain('Role'); + expect(possibleTypes).toContain('Element'); + }); + + it('should update schema when it changes', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.updateClass('_User', { + foo: { type: 'String' }, + }); + + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + expect(userFields.indexOf('foo') !== -1).toBeTruthy(); + }); + + it('should not contain password field from _User class', async () => { + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + expect(userFields.includes('password')).toBeFalsy(); + }); + }); + + describe('Configuration', function() { + const resetGraphQLCache = async () => { + await Promise.all([ + parseGraphQLServer.parseGraphQLController.cacheController.graphQL.clear(), + parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(), + ]); + }; + + beforeEach(async () => { + await parseGraphQLServer.setGraphQLConfig({}); + await resetGraphQLCache(); + }); + + it('should only include types in the enabledForClasses list', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + foo: { type: 'String' }, + }); + + const graphQLConfig = { + enabledForClasses: ['SuperCar'], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + const { data } = await apolloClient.query({ + query: gql` + query UserType { + userType: __type(name: "User") { + fields { + name + } + } + superCarType: __type(name: "SuperCar") { + fields { + name + } + } + } + `, + }); + expect(data.userType).toBeNull(); + expect(data.superCarType).toBeTruthy(); + }); + it('should not include types in the disabledForClasses list', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + foo: { type: 'String' }, + }); + + const graphQLConfig = { + disabledForClasses: ['SuperCar'], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + const { data } = await apolloClient.query({ + query: gql` + query UserType { + userType: __type(name: "User") { + fields { + name + } + } + superCarType: __type(name: "SuperCar") { + fields { + name + } + } + } + `, + }); + expect(data.superCarType).toBeNull(); + expect(data.userType).toBeTruthy(); + }); + it('should remove query operations when disabled', async () => { + const superCar = new Parse.Object('SuperCar'); + await superCar.save({ foo: 'bar' }); + const customer = new Parse.Object('Customer'); + await customer.save({ foo: 'bar' }); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindCustomer { + customers { + count + } + } + `, + }) + ).toBeResolved(); + + const graphQLConfig = { + classConfigs: [ + { + className: 'SuperCar', + query: { + get: false, + find: true, + }, + }, + { + className: 'Customer', + query: { + get: true, + find: false, + }, + }, + ], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + id + } + } + `, + variables: { + id: customer.id, + }, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars { + count + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindCustomer { + customers { + count + } + } + `, + }) + ).toBeRejected(); + }); + + it('should remove mutation operations, create, update and delete, when disabled', async () => { + const superCar1 = new Parse.Object('SuperCar'); + await superCar1.save({ foo: 'bar' }); + const customer1 = new Parse.Object('Customer'); + await customer1.save({ foo: 'bar' }); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateSuperCar($id: ID!, $foo: String!) { + updateSuperCar(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: superCar1.id, + foo: 'lah', + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteCustomer($id: ID!) { + deleteCustomer(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: customer1.id, + }, + }) + ).toBeResolved(); + + const { data: customerData } = await apolloClient.query({ + query: gql` + mutation CreateCustomer($foo: String!) { + createCustomer(input: { fields: { foo: $foo } }) { + customer { + id + } + } + } + `, + variables: { + foo: 'rah', + }, + }); + expect(customerData.createCustomer.customer).toBeTruthy(); + + // used later + const customer2Id = customerData.createCustomer.customer.id; + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + mutation: { + create: true, + update: false, + destroy: true, + }, + }, + { + className: 'Customer', + mutation: { + create: false, + update: true, + destroy: false, + }, + }, + ], + }); + await resetGraphQLCache(); + + const { data: superCarData } = await apolloClient.query({ + query: gql` + mutation CreateSuperCar($foo: String!) { + createSuperCar(input: { fields: { foo: $foo } }) { + superCar { + id + } + } + } + `, + variables: { + foo: 'mah', + }, + }); + expect(superCarData.createSuperCar).toBeTruthy(); + const superCar3Id = superCarData.createSuperCar.superCar.id; + + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateSupercar($id: ID!, $foo: String!) { + updateSuperCar(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: superCar3Id, + }, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteSuperCar($id: ID!) { + deleteSuperCar(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: superCar3Id, + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation CreateCustomer($foo: String!) { + createCustomer(input: { fields: { foo: $foo } }) { + customer { + id + } + } + } + `, + variables: { + foo: 'rah', + }, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateCustomer($id: ID!, $foo: String!) { + updateCustomer(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: customer2Id, + foo: 'tah', + }, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteCustomer($id: ID!, $foo: String!) { + deleteCustomer(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: customer2Id, + }, + }) + ).toBeRejected(); + }); + + it('should only allow the supplied create and update fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: ['engine', 'doors', 'price'], + update: ['price', 'mileage'], + }, + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation InvalidCreateSuperCar { + createSuperCar( + input: { fields: { engine: "diesel", mileage: 1000 } } + ) { + superCar { + id + } + } + } + `, + }) + ).toBeRejected(); + const { id: superCarId } = ( + await apolloClient.query({ + query: gql` + mutation ValidCreateSuperCar { + createSuperCar( + input: { + fields: { engine: "diesel", doors: 5, price: "£10000" } + } + ) { + superCar { + id + } + } + } + `, + }) + ).data.createSuperCar.superCar; + + expect(superCarId).toBeTruthy(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation InvalidUpdateSuperCar($id: ID!) { + updateSuperCar( + input: { id: $id, fields: { engine: "petrol" } } + ) { + clientMutationId + } + } + `, + variables: { + id: superCarId, + }, + }) + ).toBeRejected(); + + const updatedSuperCar = ( + await apolloClient.query({ + query: gql` + mutation ValidUpdateSuperCar($id: ID!) { + updateSuperCar( + input: { id: $id, fields: { mileage: 2000 } } + ) { + clientMutationId + } + } + `, + variables: { + id: superCarId, + }, + }) + ).data.updateSuperCar; + expect(updatedSuperCar).toBeTruthy(); + }); + + it('should handle required fields from the Parse class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String', required: true }, + doors: { type: 'Number', required: true }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await resetGraphQLCache(); + + const { + data: { __type }, + } = await apolloClient.query({ + query: gql` + query requiredFields { + __type(name: "CreateSuperCarFieldsInput") { + inputFields { + name + type { + kind + } + } + } + } + `, + }); + expect( + __type.inputFields.find(o => o.name === 'price').type.kind + ).toEqual('SCALAR'); + expect( + __type.inputFields.find(o => o.name === 'engine').type.kind + ).toEqual('NON_NULL'); + expect( + __type.inputFields.find(o => o.name === 'doors').type.kind + ).toEqual('NON_NULL'); + + const { + data: { __type: __type2 }, + } = await apolloClient.query({ + query: gql` + query requiredFields { + __type(name: "SuperCar") { + fields { + name + type { + kind + } + } + } + } + `, + }); + expect( + __type2.fields.find(o => o.name === 'price').type.kind + ).toEqual('SCALAR'); + expect( + __type2.fields.find(o => o.name === 'engine').type.kind + ).toEqual('NON_NULL'); + expect( + __type2.fields.find(o => o.name === 'doors').type.kind + ).toEqual('NON_NULL'); + }); + + it('should only allow the supplied output fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + insuranceClaims: { type: 'Number' }, + }); + + const superCar = await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: '£7500', + mileage: 0, + insuranceCertificate: 'private-file.pdf', + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + outputFields: ['engine', 'doors', 'price', 'mileage'], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + engine + doors + price + mileage + insuranceCertificate + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + let getSuperCar = ( + await apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + engine + doors + price + mileage + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).data.superCar; + expect(getSuperCar).toBeTruthy(); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + outputFields: [], + }, + }, + ], + }); + + await resetGraphQLCache(); + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + engine + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + getSuperCar = ( + await apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).data.superCar; + expect(getSuperCar.objectId).toBe(superCar.id); + }); + + it('should only allow the supplied constraint fields for a class', async () => { + try { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + model: { type: 'String' }, + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + insuranceCertificate: { type: 'String' }, + }); + + await new Parse.Object('SuperCar').save({ + model: 'McLaren', + engine: 'petrol', + doors: 3, + price: '£7500', + mileage: 0, + insuranceCertificate: 'private-file.pdf', + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + constraintFields: ['engine', 'doors', 'price'], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars( + where: { + insuranceCertificate: { equalTo: "private-file.pdf" } + } + ) { + count + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(where: { mileage: { equalTo: 0 } }) { + count + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(where: { engine: { equalTo: "petrol" } }) { + count + } + } + `, + }) + ).toBeResolved(); + } catch (e) { + handleError(e); + } + }); + + it('should only allow the supplied sort fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: '£7500', + mileage: 0, + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + sortFields: [ + { + field: 'doors', + asc: true, + desc: true, + }, + { + field: 'price', + asc: true, + desc: true, + }, + { + field: 'mileage', + asc: true, + desc: false, + }, + ], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [engine_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [engine_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [mileage_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [mileage_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [doors_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [price_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [price_ASC, doors_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + }); + }); + + describe('Relay Spec', () => { + beforeAll(async () => { + await resetGraphQLCache(); + }); + + afterAll(async () => { + await resetGraphQLCache(); + }); + + describe('Object Identification', () => { + it('Class get custom method should return valid gobal id', async () => { + const obj = new Parse.Object('SomeClass'); + obj.set('someField', 'some value'); + await obj.save(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeClass($objectId: ID!) { + someClass(id: $objectId) { + id + objectId + } + } + `, + variables: { + objectId: obj.id, + }, + }); + + expect(getResult.data.someClass.objectId).toBe(obj.id); + + const nodeResult = await apolloClient.query({ + query: gql` + query Node($id: ID!) { + node(id: $id) { + id + ... on SomeClass { + objectId + someField + } + } + } + `, + variables: { + id: getResult.data.someClass.id, + }, + }); + + expect(nodeResult.data.node.id).toBe(getResult.data.someClass.id); + expect(nodeResult.data.node.objectId).toBe(obj.id); + expect(nodeResult.data.node.someField).toBe('some value'); + }); + + it('Class find custom method should return valid gobal id', async () => { + const obj1 = new Parse.Object('SomeClass'); + obj1.set('someField', 'some value 1'); + await obj1.save(); + + const obj2 = new Parse.Object('SomeClass'); + obj2.set('someField', 'some value 2'); + await obj2.save(); + + const findResult = await apolloClient.query({ + query: gql` + query FindSomeClass { + someClasses(order: [createdAt_ASC]) { + edges { + node { + id + objectId + } + } + } + } + `, + }); + + expect(findResult.data.someClasses.edges[0].node.objectId).toBe( + obj1.id + ); + expect(findResult.data.someClasses.edges[1].node.objectId).toBe( + obj2.id + ); + + const nodeResult = await apolloClient.query({ + query: gql` + query Node($id1: ID!, $id2: ID!) { + node1: node(id: $id1) { + id + ... on SomeClass { + objectId + someField + } + } + node2: node(id: $id2) { + id + ... on SomeClass { + objectId + someField + } + } + } + `, + variables: { + id1: findResult.data.someClasses.edges[0].node.id, + id2: findResult.data.someClasses.edges[1].node.id, + }, + }); + + expect(nodeResult.data.node1.id).toBe( + findResult.data.someClasses.edges[0].node.id + ); + expect(nodeResult.data.node1.objectId).toBe(obj1.id); + expect(nodeResult.data.node1.someField).toBe('some value 1'); + expect(nodeResult.data.node2.id).toBe( + findResult.data.someClasses.edges[1].node.id + ); + expect(nodeResult.data.node2.objectId).toBe(obj2.id); + expect(nodeResult.data.node2.someField).toBe('some value 2'); + }); + + it_only_db('mongo')( + 'Id inputs should work either with global id or object id', + async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClasses { + secondaryObject: createClass( + input: { + name: "SecondaryObject" + schemaFields: { addStrings: [{ name: "someField" }] } + } + ) { + clientMutationId + } + primaryObject: createClass( + input: { + name: "PrimaryObject" + schemaFields: { + addStrings: [{ name: "stringField" }] + addArrays: [{ name: "arrayField" }] + addPointers: [ + { + name: "pointerField" + targetClassName: "SecondaryObject" + } + ] + addRelations: [ + { + name: "relationField" + targetClassName: "SecondaryObject" + } + ] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await resetGraphQLCache(); + + const createSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSecondaryObjects { + secondaryObject1: createSecondaryObject( + input: { fields: { someField: "some value 1" } } + ) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject2: createSecondaryObject( + input: { fields: { someField: "some value 2" } } + ) { + secondaryObject { + id + someField + } + } + secondaryObject3: createSecondaryObject( + input: { fields: { someField: "some value 3" } } + ) { + secondaryObject { + objectId + someField + } + } + secondaryObject4: createSecondaryObject( + input: { fields: { someField: "some value 4" } } + ) { + secondaryObject { + id + objectId + } + } + secondaryObject5: createSecondaryObject( + input: { fields: { someField: "some value 5" } } + ) { + secondaryObject { + id + } + } + secondaryObject6: createSecondaryObject( + input: { fields: { someField: "some value 6" } } + ) { + secondaryObject { + objectId + } + } + secondaryObject7: createSecondaryObject( + input: { fields: { someField: "some value 7" } } + ) { + secondaryObject { + someField + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const updateSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdateSecondaryObjects( + $id1: ID! + $id2: ID! + $id3: ID! + $id4: ID! + $id5: ID! + $id6: ID! + ) { + secondaryObject1: updateSecondaryObject( + input: { + id: $id1 + fields: { someField: "some value 11" } + } + ) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject2: updateSecondaryObject( + input: { + id: $id2 + fields: { someField: "some value 22" } + } + ) { + secondaryObject { + id + someField + } + } + secondaryObject3: updateSecondaryObject( + input: { + id: $id3 + fields: { someField: "some value 33" } + } + ) { + secondaryObject { + objectId + someField + } + } + secondaryObject4: updateSecondaryObject( + input: { + id: $id4 + fields: { someField: "some value 44" } + } + ) { + secondaryObject { + id + objectId + } + } + secondaryObject5: updateSecondaryObject( + input: { + id: $id5 + fields: { someField: "some value 55" } + } + ) { + secondaryObject { + id + } + } + secondaryObject6: updateSecondaryObject( + input: { + id: $id6 + fields: { someField: "some value 66" } + } + ) { + secondaryObject { + objectId + } + } + } + `, + variables: { + id1: + createSecondaryObjectsResult.data.secondaryObject1 + .secondaryObject.id, + id2: + createSecondaryObjectsResult.data.secondaryObject2 + .secondaryObject.id, + id3: + createSecondaryObjectsResult.data.secondaryObject3 + .secondaryObject.objectId, + id4: + createSecondaryObjectsResult.data.secondaryObject4 + .secondaryObject.objectId, + id5: + createSecondaryObjectsResult.data.secondaryObject5 + .secondaryObject.id, + id6: + createSecondaryObjectsResult.data.secondaryObject6 + .secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const deleteSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation DeleteSecondaryObjects( + $id1: ID! + $id3: ID! + $id5: ID! + $id6: ID! + ) { + secondaryObject1: deleteSecondaryObject( + input: { id: $id1 } + ) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject3: deleteSecondaryObject( + input: { id: $id3 } + ) { + secondaryObject { + objectId + someField + } + } + secondaryObject5: deleteSecondaryObject( + input: { id: $id5 } + ) { + secondaryObject { + id + } + } + secondaryObject6: deleteSecondaryObject( + input: { id: $id6 } + ) { + secondaryObject { + objectId + } + } + } + `, + variables: { + id1: + updateSecondaryObjectsResult.data.secondaryObject1 + .secondaryObject.id, + id3: + updateSecondaryObjectsResult.data.secondaryObject3 + .secondaryObject.objectId, + id5: + updateSecondaryObjectsResult.data.secondaryObject5 + .secondaryObject.id, + id6: + updateSecondaryObjectsResult.data.secondaryObject6 + .secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const getSecondaryObjectsResult = await apolloClient.query({ + query: gql` + query GetSecondaryObjects($id2: ID!, $id4: ID!) { + secondaryObject2: secondaryObject(id: $id2) { + id + objectId + someField + } + secondaryObject4: secondaryObject(id: $id4) { + objectId + someField + } + } + `, + variables: { + id2: + updateSecondaryObjectsResult.data.secondaryObject2 + .secondaryObject.id, + id4: + updateSecondaryObjectsResult.data.secondaryObject4 + .secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const findSecondaryObjectsResult = await apolloClient.query({ + query: gql` + query FindSecondaryObjects( + $id1: ID! + $id2: ID! + $id3: ID! + $id4: ID! + $id5: ID! + $id6: ID! + ) { + secondaryObjects( + where: { + AND: [ + { + OR: [ + { id: { equalTo: $id2 } } + { + AND: [ + { id: { equalTo: $id4 } } + { objectId: { equalTo: $id4 } } + ] + } + ] + } + { id: { notEqualTo: $id1 } } + { id: { notEqualTo: $id3 } } + { objectId: { notEqualTo: $id2 } } + { objectId: { notIn: [$id5, $id6] } } + { id: { in: [$id2, $id4] } } + ] + } + order: [id_ASC, objectId_ASC] + ) { + edges { + node { + id + objectId + someField + } + } + count + } + } + `, + variables: { + id1: + deleteSecondaryObjectsResult.data.secondaryObject1 + .secondaryObject.objectId, + id2: getSecondaryObjectsResult.data.secondaryObject2.id, + id3: + deleteSecondaryObjectsResult.data.secondaryObject3 + .secondaryObject.objectId, + id4: + getSecondaryObjectsResult.data.secondaryObject4.objectId, + id5: + deleteSecondaryObjectsResult.data.secondaryObject5 + .secondaryObject.id, + id6: + deleteSecondaryObjectsResult.data.secondaryObject6 + .secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + findSecondaryObjectsResult.data.secondaryObjects.count + ).toEqual(2); + expect( + findSecondaryObjectsResult.data.secondaryObjects.edges + .map(value => value.node.someField) + .sort() + ).toEqual(['some value 22', 'some value 44']); + expect( + findSecondaryObjectsResult.data.secondaryObjects.edges[0].node + .id + ).toBeLessThan( + findSecondaryObjectsResult.data.secondaryObjects.edges[1].node + .id + ); + expect( + findSecondaryObjectsResult.data.secondaryObjects.edges[0].node + .objectId + ).toBeLessThan( + findSecondaryObjectsResult.data.secondaryObjects.edges[1].node + .objectId + ); + + const createPrimaryObjectResult = await apolloClient.mutate({ + mutation: gql` + mutation CreatePrimaryObject( + $pointer: Any + $secondaryObject2: ID! + $secondaryObject4: ID! + ) { + createPrimaryObject( + input: { + fields: { + stringField: "some value" + arrayField: [1, "abc", $pointer] + pointerField: { link: $secondaryObject2 } + relationField: { + add: [$secondaryObject2, $secondaryObject4] + } + } + } + ) { + primaryObject { + id + stringField + arrayField { + ... on Element { + value + } + ... on SecondaryObject { + someField + } + } + pointerField { + id + objectId + someField + } + relationField { + edges { + node { + id + objectId + someField + } + } + } + } + } + } + `, + variables: { + pointer: { + __type: 'Pointer', + className: 'SecondaryObject', + objectId: + getSecondaryObjectsResult.data.secondaryObject4 + .objectId, + }, + secondaryObject2: + getSecondaryObjectsResult.data.secondaryObject2.id, + secondaryObject4: + getSecondaryObjectsResult.data.secondaryObject4.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const updatePrimaryObjectResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdatePrimaryObject( + $id: ID! + $secondaryObject2: ID! + $secondaryObject4: ID! + ) { + updatePrimaryObject( + input: { + id: $id + fields: { + pointerField: { link: $secondaryObject4 } + relationField: { + remove: [$secondaryObject2, $secondaryObject4] + } + } + } + ) { + primaryObject { + id + stringField + arrayField { + ... on Element { + value + } + ... on SecondaryObject { + someField + } + } + pointerField { + id + objectId + someField + } + relationField { + edges { + node { + id + objectId + someField + } + } + } + } + } + } + `, + variables: { + id: + createPrimaryObjectResult.data.createPrimaryObject + .primaryObject.id, + secondaryObject2: + getSecondaryObjectsResult.data.secondaryObject2.id, + secondaryObject4: + getSecondaryObjectsResult.data.secondaryObject4.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + createPrimaryObjectResult.data.createPrimaryObject + .primaryObject.stringField + ).toEqual('some value'); + expect( + createPrimaryObjectResult.data.createPrimaryObject + .primaryObject.arrayField + ).toEqual([ + { __typename: 'Element', value: 1 }, + { __typename: 'Element', value: 'abc' }, + { __typename: 'SecondaryObject', someField: 'some value 44' }, + ]); + expect( + createPrimaryObjectResult.data.createPrimaryObject + .primaryObject.pointerField.someField + ).toEqual('some value 22'); + expect( + createPrimaryObjectResult.data.createPrimaryObject.primaryObject.relationField.edges + .map(value => value.node.someField) + .sort() + ).toEqual(['some value 22', 'some value 44']); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject + .primaryObject.stringField + ).toEqual('some value'); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject + .primaryObject.arrayField + ).toEqual([ + { __typename: 'Element', value: 1 }, + { __typename: 'Element', value: 'abc' }, + { __typename: 'SecondaryObject', someField: 'some value 44' }, + ]); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject + .primaryObject.pointerField.someField + ).toEqual('some value 44'); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject + .primaryObject.relationField.edges + ).toEqual([]); + } catch (e) { + handleError(e); + } + } + ); + }); + }); + + describe('Class Schema Mutations', () => { + it('should create a new class', async () => { + try { + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + class1: createClass( + input: { name: "Class1", clientMutationId: "cmid1" } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class2: createClass( + input: { + name: "Class2" + schemaFields: null + clientMutationId: "cmid2" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class3: createClass( + input: { + name: "Class3" + schemaFields: {} + clientMutationId: "cmid3" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class4: createClass( + input: { + name: "Class4" + schemaFields: { + addStrings: null + addNumbers: null + addBooleans: null + addArrays: null + addObjects: null + addDates: null + addFiles: null + addGeoPoint: null + addPolygons: null + addBytes: null + addPointers: null + addRelations: null + } + clientMutationId: "cmid4" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class5: createClass( + input: { + name: "Class5" + schemaFields: { + addStrings: [] + addNumbers: [] + addBooleans: [] + addArrays: [] + addObjects: [] + addDates: [] + addFiles: [] + addPolygons: [] + addBytes: [] + addPointers: [] + addRelations: [] + } + clientMutationId: "cmid5" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class6: createClass( + input: { + name: "Class6" + schemaFields: { + addStrings: [ + { name: "stringField1" } + { name: "stringField2" } + { name: "stringField3" } + ] + addNumbers: [ + { name: "numberField1" } + { name: "numberField2" } + { name: "numberField3" } + ] + addBooleans: [ + { name: "booleanField1" } + { name: "booleanField2" } + { name: "booleanField3" } + ] + addArrays: [ + { name: "arrayField1" } + { name: "arrayField2" } + { name: "arrayField3" } + ] + addObjects: [ + { name: "objectField1" } + { name: "objectField2" } + { name: "objectField3" } + ] + addDates: [ + { name: "dateField1" } + { name: "dateField2" } + { name: "dateField3" } + ] + addFiles: [ + { name: "fileField1" } + { name: "fileField2" } + { name: "fileField3" } + ] + addGeoPoint: { name: "geoPointField" } + addPolygons: [ + { name: "polygonField1" } + { name: "polygonField2" } + { name: "polygonField3" } + ] + addBytes: [ + { name: "bytesField1" } + { name: "bytesField2" } + { name: "bytesField3" } + ] + addPointers: [ + { name: "pointerField1", targetClassName: "Class1" } + { name: "pointerField2", targetClassName: "Class6" } + { name: "pointerField3", targetClassName: "Class2" } + ] + addRelations: [ + { name: "relationField1", targetClassName: "Class1" } + { name: "relationField2", targetClassName: "Class6" } + { name: "relationField3", targetClassName: "Class2" } + ] + remove: [ + { name: "stringField3" } + { name: "numberField3" } + { name: "booleanField3" } + { name: "arrayField3" } + { name: "objectField3" } + { name: "dateField3" } + { name: "fileField3" } + { name: "polygonField3" } + { name: "bytesField3" } + { name: "pointerField3" } + { name: "relationField3" } + { name: "doesNotExist" } + ] + } + clientMutationId: "cmid6" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + const classes = Object.keys(result.data).map(fieldName => ({ + clientMutationId: result.data[fieldName].clientMutationId, + class: { + name: result.data[fieldName].class.name, + schemaFields: result.data[ + fieldName + ].class.schemaFields.sort((a, b) => (a.name > b.name ? 1 : -1)), + __typename: result.data[fieldName].class.__typename, + }, + __typename: result.data[fieldName].__typename, + })); + expect(classes).toEqual([ + { + clientMutationId: 'cmid1', + class: { + name: 'Class1', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid2', + class: { + name: 'Class2', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid3', + class: { + name: 'Class3', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid4', + class: { + name: 'Class4', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid5', + class: { + name: 'Class5', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid6', + class: { + name: 'Class6', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + ]); + + const findResult = await apolloClient.query({ + query: gql` + query { + classes { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + findResult.data.classes = findResult.data.classes + .filter(schemaClass => !schemaClass.name.startsWith('_')) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + findResult.data.classes.forEach(schemaClass => { + schemaClass.schemaFields = schemaClass.schemaFields.sort((a, b) => + a.name > b.name ? 1 : -1 + ); + }); + expect(findResult.data.classes).toEqual([ + { + name: 'Class1', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class2', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class3', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class4', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class5', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class6', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + ]); + } catch (e) { + handleError(e); + } + }); + + it('should require master key to create a new class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.OPERATION_FORBIDDEN + ); + expect(e.graphQLErrors[0].message).toEqual( + 'unauthorized: master key is required' + ); + } + }); + + it('should not allow duplicated field names when creating', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "SomeClass" + schemaFields: { + addStrings: [{ name: "someField" }] + addNumbers: [{ name: "someField" }] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.INVALID_KEY_NAME + ); + expect(e.graphQLErrors[0].message).toEqual( + 'Duplicated field name: someField' + ); + } + }); + + it('should update an existing class', async () => { + try { + const clientMutationId = uuidv4(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "MyNewClass" + schemaFields: { addStrings: [{ name: "willBeRemoved" }] } + } + ) { + class { + name + schemaFields { + name + __typename + } + } + } + updateClass(input: { + clientMutationId: "${clientMutationId}" + name: "MyNewClass" + schemaFields: { + addStrings: [ + { name: "stringField1" } + { name: "stringField2" } + { name: "stringField3" } + ] + addNumbers: [ + { name: "numberField1" } + { name: "numberField2" } + { name: "numberField3" } + ] + addBooleans: [ + { name: "booleanField1" } + { name: "booleanField2" } + { name: "booleanField3" } + ] + addArrays: [ + { name: "arrayField1" } + { name: "arrayField2" } + { name: "arrayField3" } + ] + addObjects: [ + { name: "objectField1" } + { name: "objectField2" } + { name: "objectField3" } + ] + addDates: [ + { name: "dateField1" } + { name: "dateField2" } + { name: "dateField3" } + ] + addFiles: [ + { name: "fileField1" } + { name: "fileField2" } + { name: "fileField3" } + ] + addGeoPoint: { name: "geoPointField" } + addPolygons: [ + { name: "polygonField1" } + { name: "polygonField2" } + { name: "polygonField3" } + ] + addBytes: [ + { name: "bytesField1" } + { name: "bytesField2" } + { name: "bytesField3" } + ] + addPointers: [ + { name: "pointerField1", targetClassName: "Class1" } + { name: "pointerField2", targetClassName: "Class6" } + { name: "pointerField3", targetClassName: "Class2" } + ] + addRelations: [ + { name: "relationField1", targetClassName: "Class1" } + { name: "relationField2", targetClassName: "Class6" } + { name: "relationField3", targetClassName: "Class2" } + ] + remove: [ + { name: "willBeRemoved" } + { name: "stringField3" } + { name: "numberField3" } + { name: "booleanField3" } + { name: "arrayField3" } + { name: "objectField3" } + { name: "dateField3" } + { name: "fileField3" } + { name: "polygonField3" } + { name: "bytesField3" } + { name: "pointerField3" } + { name: "relationField3" } + { name: "doesNotExist" } + ] + } + }) { + clientMutationId + class { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + result.data.createClass.class.schemaFields = result.data.createClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + result.data.updateClass.class.schemaFields = result.data.updateClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + expect(result).toEqual({ + data: { + createClass: { + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + updateClass: { + clientMutationId, + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { + name: 'booleanField1', + __typename: 'SchemaBooleanField', + }, + { + name: 'booleanField2', + __typename: 'SchemaBooleanField', + }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { + name: 'polygonField1', + __typename: 'SchemaPolygonField', + }, + { + name: 'polygonField2', + __typename: 'SchemaPolygonField', + }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'UpdateClassPayload', + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query { + class(name: "MyNewClass") { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + getResult.data.class.schemaFields = getResult.data.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + expect(getResult.data).toEqual({ + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + }); + } catch (e) { + handleError(e); + } + }); + + it('should require master key to update an existing class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.OPERATION_FORBIDDEN + ); + expect(e.graphQLErrors[0].message).toEqual( + 'unauthorized: master key is required' + ); + } + }); + + it('should not allow duplicated field names when updating', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "SomeClass" + schemaFields: { addStrings: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass( + input: { + name: "SomeClass" + schemaFields: { addNumbers: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.INVALID_KEY_NAME + ); + expect(e.graphQLErrors[0].message).toEqual( + 'Duplicated field name: someField' + ); + } + }); + + it('should fail if updating an inexistent class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass( + input: { + name: "SomeInexistentClass" + schemaFields: { addNumbers: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.INVALID_CLASS_NAME + ); + expect(e.graphQLErrors[0].message).toEqual( + 'Class SomeInexistentClass does not exist.' + ); + } + }); + + it('should delete an existing class', async () => { + try { + const clientMutationId = uuidv4(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "MyNewClass" + schemaFields: { addStrings: [{ name: "willBeRemoved" }] } + } + ) { + class { + name + schemaFields { + name + __typename + } + } + } + deleteClass(input: { clientMutationId: "${clientMutationId}" name: "MyNewClass" }) { + clientMutationId + class { + name + schemaFields { + name + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + result.data.createClass.class.schemaFields = result.data.createClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + result.data.deleteClass.class.schemaFields = result.data.deleteClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + expect(result).toEqual({ + data: { + createClass: { + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + deleteClass: { + clientMutationId, + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'DeleteClassPayload', + }, + }, + }); + + try { + await apolloClient.query({ + query: gql` + query { + class(name: "MyNewClass") { + name + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.INVALID_CLASS_NAME + ); + expect(e.graphQLErrors[0].message).toEqual( + 'Class MyNewClass does not exist.' + ); + } + } catch (e) { + handleError(e); + } + }); + + it('should require master key to delete an existing class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + deleteClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.OPERATION_FORBIDDEN + ); + expect(e.graphQLErrors[0].message).toEqual( + 'unauthorized: master key is required' + ); + } + }); + + it('should fail if deleting an inexistent class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + deleteClass(input: { name: "SomeInexistentClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.INVALID_CLASS_NAME + ); + expect(e.graphQLErrors[0].message).toEqual( + 'Class SomeInexistentClass does not exist.' + ); + } + }); + + it('should require master key to get an existing class', async () => { + try { + await apolloClient.query({ + query: gql` + query { + class(name: "_User") { + name + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.OPERATION_FORBIDDEN + ); + expect(e.graphQLErrors[0].message).toEqual( + 'unauthorized: master key is required' + ); + } + }); + + it('should require master key to find the existing classes', async () => { + try { + await apolloClient.query({ + query: gql` + query { + classes { + name + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual( + Parse.Error.OPERATION_FORBIDDEN + ); + expect(e.graphQLErrors[0].message).toEqual( + 'unauthorized: master key is required' + ); + } + }); + }); + + describe('Objects Queries', () => { + describe('Get', () => { + it('should return a class object using class specific query', async () => { + const obj = new Parse.Object('Customer'); + obj.set('someField', 'someValue'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + id + objectId + someField + createdAt + updatedAt + } + } + `, + variables: { + id: obj.id, + }, + }) + ).data.customer; + + expect(result.objectId).toEqual(obj.id); + expect(result.someField).toEqual('someValue'); + expect(new Date(result.createdAt)).toEqual(obj.createdAt); + expect(new Date(result.updatedAt)).toEqual(obj.updatedAt); + }); + + it_only_db('mongo')( + 'should return child objects in array fields', + async () => { + const obj1 = new Parse.Object('Customer'); + const obj2 = new Parse.Object('SomeClass'); + const obj3 = new Parse.Object('Customer'); + + obj1.set('someCustomerField', 'imCustomerOne'); + const arrayField = [42.42, 42, 'string', true]; + obj1.set('arrayField', arrayField); + await obj1.save(); + + obj2.set('someClassField', 'imSomeClassTwo'); + await obj2.save(); + + obj3.set('manyRelations', [obj1, obj2]); + await obj3.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + objectId + manyRelations { + ... on Customer { + objectId + someCustomerField + arrayField { + ... on Element { + value + } + } + } + ... on SomeClass { + objectId + someClassField + } + } + createdAt + updatedAt + } + } + `, + variables: { + id: obj3.id, + }, + }) + ).data.customer; + + expect(result.objectId).toEqual(obj3.id); + expect(result.manyRelations.length).toEqual(2); + + const customerSubObject = result.manyRelations.find( + o => o.objectId === obj1.id + ); + const someClassSubObject = result.manyRelations.find( + o => o.objectId === obj2.id + ); + + expect(customerSubObject).toBeDefined(); + expect(someClassSubObject).toBeDefined(); + expect(customerSubObject.someCustomerField).toEqual( + 'imCustomerOne' + ); + const formatedArrayField = customerSubObject.arrayField.map( + elem => elem.value + ); + expect(formatedArrayField).toEqual(arrayField); + expect(someClassSubObject.someClassField).toEqual( + 'imSomeClassTwo' + ); + } + ); + + it_only_db('mongo')( + 'should return many child objects in allow cyclic query', + async () => { + const obj1 = new Parse.Object('Employee'); + const obj2 = new Parse.Object('Team'); + const obj3 = new Parse.Object('Company'); + const obj4 = new Parse.Object('Country'); + + obj1.set('name', 'imAnEmployee'); + await obj1.save(); + + obj2.set('name', 'imATeam'); + obj2.set('employees', [obj1]); + await obj2.save(); + + obj3.set('name', 'imACompany'); + obj3.set('teams', [obj2]); + obj3.set('employees', [obj1]); + await obj3.save(); + + obj4.set('name', 'imACountry'); + obj4.set('companies', [obj3]); + await obj4.save(); + + obj1.set('country', obj4); + await obj1.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query DeepComplexGraphQLQuery($id: ID!) { + country(id: $id) { + objectId + name + companies { + ... on Company { + objectId + name + employees { + ... on Employee { + objectId + name + } + } + teams { + ... on Team { + objectId + name + employees { + ... on Employee { + objectId + name + country { + objectId + name + } + } + } + } + } + } + } + } + } + `, + variables: { + id: obj4.id, + }, + }) + ).data.country; + + const expectedResult = { + objectId: obj4.id, + name: 'imACountry', + __typename: 'Country', + companies: [ + { + objectId: obj3.id, + name: 'imACompany', + __typename: 'Company', + employees: [ + { + objectId: obj1.id, + name: 'imAnEmployee', + __typename: 'Employee', + }, + ], + teams: [ + { + objectId: obj2.id, + name: 'imATeam', + __typename: 'Team', + employees: [ + { + objectId: obj1.id, + name: 'imAnEmployee', + __typename: 'Employee', + country: { + objectId: obj4.id, + name: 'imACountry', + __typename: 'Country', + }, + }, + ], + }, + ], + }, + ], + }; + expect(result).toEqual(expectedResult); + } + ); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + async function getObject(className, id, headers) { + const specificQueryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: ${className.charAt(0).toLowerCase() + + className.slice(1)}(id: $id) { + id + createdAt + someField + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + + return specificQueryResult; + } + + await Promise.all( + objects + .slice(0, 3) + .map(obj => + expectAsync( + getObject(obj.className, obj.id) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')) + ) + ); + expect( + (await getObject(object4.className, object4.id)).data.get + .someField + ).toEqual('someValue4'); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await expectAsync( + getObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await Promise.all( + [object1, object3, object4].map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.slice(0, 3).map(obj => + expectAsync( + getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')) + ) + ); + expect( + ( + await getObject(object4.className, object4.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue4'); + await Promise.all( + objects.slice(0, 2).map(obj => + expectAsync( + getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')) + ) + ); + expect( + ( + await getObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue3'); + expect( + ( + await getObject(object4.className, object4.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue4'); + }); + + it('should support keys argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + someField + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + someField + pointerToUser { + id + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.get.someField).toBeDefined(); + expect(result1.data.get.pointerToUser).toBeUndefined(); + expect(result2.data.get.someField).toBeDefined(); + expect(result2.data.get.pointerToUser).toBeDefined(); + }); + + it('should support include argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + pointerToUser { + id + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass(id: $id) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.get.pointerToUser.username).toBeUndefined(); + expect( + result2.data.graphQLClass.pointerToUser.username + ).toBeDefined(); + }); + + it('should respect protectedFields', async done => { + await prepareData(); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const className = 'GraphQLClass'; + + await updateCLP( + { + get: { '*': true }, + find: { '*': true }, + + protectedFields: { + '*': ['someField', 'someOtherField'], + authenticated: ['someField'], + 'userField:pointerToUser': [], + [user2.id]: [], + }, + }, + className + ); + + const getObject = async (className, id, user) => { + const headers = user + ? { ['X-Parse-Session-Token']: user.getSessionToken() } + : undefined; + + const specificQueryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + pointerToUser { + username + id + } + someField + someOtherField + } + } + `, + variables: { + id: id, + }, + context: { + headers: headers, + }, + }); + + return specificQueryResult.data.get; + }; + + const id = object3.id; + + /* not authenticated */ + const objectPublic = await getObject(className, id, undefined); + + expect(objectPublic.someField).toBeNull(); + expect(objectPublic.someOtherField).toBeNull(); + + /* authenticated */ + const objectAuth = await getObject(className, id, user1); + + expect(objectAuth.someField).toBeNull(); + expect(objectAuth.someOtherField).toBe('B'); + + /* pointer field */ + const objectPointed = await getObject(className, id, user5); + + expect(objectPointed.someField).toBe('someValue3'); + expect(objectPointed.someOtherField).toBe('B'); + + /* for user id */ + const objectForUser = await getObject(className, id, user2); + + expect(objectForUser.someField).toBe('someValue3'); + expect(objectForUser.someOtherField).toBe('B'); + + done(); + }); + describe_only_db('mongo')('read preferences', () => { + it('should read from primary by default', async () => { + try { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass(id: $id) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if ( + call.args[0].ns.collection.indexOf('GraphQLClass') >= 0 + ) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.PRIMARY + ); + } else if ( + call.args[0].ns.collection.indexOf('_User') >= 0 + ) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.PRIMARY + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + } catch (e) { + handleError(e); + } + }); + + it('should support readPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass( + id: $id + options: { readPreference: SECONDARY } + ) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } else if (call.args[0].ns.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support includeReadPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass( + id: $id + options: { + readPreference: SECONDARY + includeReadPreference: NEAREST + } + ) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } else if (call.args[0].ns.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.NEAREST + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + }); + }); + + describe('Find', () => { + it('should return class objects using class specific query', async () => { + const obj1 = new Parse.Object('Customer'); + obj1.set('someField', 'someValue1'); + await obj1.save(); + const obj2 = new Parse.Object('Customer'); + obj2.set('someField', 'someValue1'); + await obj2.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindCustomer { + customers { + edges { + node { + objectId + someField + createdAt + updatedAt + } + } + } + } + `, + }); + + expect(result.data.customers.edges.length).toEqual(2); + + result.data.customers.edges.forEach(resultObj => { + const obj = resultObj.node.objectId === obj1.id ? obj1 : obj2; + expect(resultObj.node.objectId).toEqual(obj.id); + expect(resultObj.node.someField).toEqual(obj.get('someField')); + expect(new Date(resultObj.node.createdAt)).toEqual(obj.createdAt); + expect(new Date(resultObj.node.updatedAt)).toEqual(obj.updatedAt); + }); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + async function findObjects(className, headers) { + const graphqlClassName = pluralize( + className.charAt(0).toLowerCase() + className.slice(1) + ); + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: ${graphqlClassName} { + edges { + node { + id + someField + } + } + } + } + `, + context: { + headers, + }, + }); + + return result; + } + + expect( + (await findObjects('GraphQLClass')).data.find.edges.map( + object => object.node.someField + ) + ).toEqual([]); + expect( + (await findObjects('PublicClass')).data.find.edges.map( + object => object.node.someField + ) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Master-Key': 'test', + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('PublicClass', { + 'X-Parse-Master-Key': 'test', + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('PublicClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue3']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual([]); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue3']); + }); + + it('should support where argument using class specific query', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + result.data.graphQLClasses.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue3']); + }); + + it('should support in pointer operator using class specific query', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + pointerToUser: { + have: { + objectId: { + in: [user5.id], + }, + }, + }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const { edges } = result.data.graphQLClasses; + expect(edges.length).toBe(1); + expect(edges[0].node.someField).toEqual('someValue3'); + }); + + it('should support OR operation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query { + graphQLClasses( + where: { + OR: [ + { someField: { equalTo: "someValue1" } } + { someField: { equalTo: "someValue2" } } + ] + } + ) { + edges { + node { + someField + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + result.data.graphQLClasses.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2']); + }); + + it('should support full text search', async () => { + try { + const obj = new Parse.Object('FullTextSearchTest'); + obj.set('field1', 'Parse GraphQL Server'); + obj.set('field2', 'It rocks!'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FullTextSearchTests( + $where: FullTextSearchTestWhereInput + ) { + fullTextSearchTests(where: $where) { + edges { + node { + objectId + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + variables: { + where: { + field1: { + text: { + search: { + term: 'graphql', + }, + }, + }, + }, + }, + }); + + expect( + result.data.fullTextSearchTests.edges[0].node.objectId + ).toEqual(obj.id); + } catch (e) { + handleError(e); + } + }); + + it('should support in query key', async () => { + try { + const country = new Parse.Object('Country'); + country.set('code', 'FR'); + await country.save(); + + const country2 = new Parse.Object('Country'); + country2.set('code', 'US'); + await country2.save(); + + const city = new Parse.Object('City'); + city.set('country', 'FR'); + city.set('name', 'city1'); + await city.save(); + + const city2 = new Parse.Object('City'); + city2.set('country', 'US'); + city2.set('name', 'city2'); + await city2.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + cities: { edges: result }, + }, + } = await apolloClient.query({ + query: gql` + query inQueryKey($where: CityWhereInput) { + cities(where: $where) { + edges { + node { + country + name + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + variables: { + where: { + country: { + inQueryKey: { + query: { + className: 'Country', + where: { code: { equalTo: 'US' } }, + }, + key: 'code', + }, + }, + }, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].node.name).toEqual('city2'); + } catch (e) { + handleError(e); + } + }); + + it('should support order, skip and first arguments', async () => { + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + obj.set('someField', `someValue${i < 10 ? '0' : ''}${i}`); + obj.set('numberField', i % 3); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects( + $where: SomeClassWhereInput + $order: [SomeClassOrder!] + $skip: Int + $first: Int + ) { + find: someClasses( + where: $where + order: $order + skip: $skip + first: $first + ) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + someField: { + matchesRegex: '^someValue', + }, + }, + order: ['numberField_DESC', 'someField_ASC'], + skip: 4, + first: 2, + }, + }); + + expect( + result.data.find.edges.map(obj => obj.node.someField) + ).toEqual(['someValue14', 'someValue17']); + }); + + it('should support pagination', async () => { + const numberArray = (first, last) => { + const array = []; + for (let i = first; i <= last; i++) { + array.push(i); + } + return array; + }; + + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + obj.set('numberField', i); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const find = async ({ skip, after, first, before, last } = {}) => { + return await apolloClient.query({ + query: gql` + query FindSomeObjects( + $order: [SomeClassOrder!] + $skip: Int + $after: String + $first: Int + $before: String + $last: Int + ) { + someClasses( + order: $order + skip: $skip + after: $after + first: $first + before: $before + last: $last + ) { + edges { + cursor + node { + numberField + } + } + count + pageInfo { + hasPreviousPage + startCursor + endCursor + hasNextPage + } + } + } + `, + variables: { + order: ['numberField_ASC'], + skip, + after, + first, + before, + last, + }, + }); + }; + + let result = await find(); + expect( + result.data.someClasses.edges.map(edge => edge.node.numberField) + ).toEqual(numberArray(0, 99)); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( + false + ); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[99].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(false); + + result = await find({ first: 10 }); + expect( + result.data.someClasses.edges.map(edge => edge.node.numberField) + ).toEqual(numberArray(0, 9)); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( + false + ); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + + result = await find({ + first: 10, + after: result.data.someClasses.pageInfo.endCursor, + }); + expect( + result.data.someClasses.edges.map(edge => edge.node.numberField) + ).toEqual(numberArray(10, 19)); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( + true + ); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + + result = await find({ last: 10 }); + expect( + result.data.someClasses.edges.map(edge => edge.node.numberField) + ).toEqual(numberArray(90, 99)); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( + true + ); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(false); + + result = await find({ + last: 10, + before: result.data.someClasses.pageInfo.startCursor, + }); + expect( + result.data.someClasses.edges.map(edge => edge.node.numberField) + ).toEqual(numberArray(80, 89)); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual( + true + ); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + }); + + it('should support count', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const where = { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }; + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects( + $where: GraphQLClassWhereInput + $first: Int + ) { + find: graphQLClasses(where: $where, first: $first) { + edges { + node { + id + } + } + count + } + } + `, + variables: { + where, + first: 0, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges).toEqual([]); + expect(result.data.find.count).toEqual(2); + }); + + it('should only count', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const where = { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }; + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + count + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges).toBeUndefined(); + expect(result.data.find.count).toEqual(2); + }); + + it('should respect max limit', async () => { + parseServer = await global.reconfigureServer({ + maxLimit: 10, + }); + + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($limit: Int) { + find: someClasses( + where: { id: { exists: true } } + first: $limit + ) { + edges { + node { + id + } + } + count + } + } + `, + variables: { + limit: 50, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges.length).toEqual(10); + expect(result.data.find.count).toEqual(100); + }); + + it('should support keys argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + id: { equalTo: object3.id }, + }, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + someField + pointerToUser { + username + } + } + } + } + } + `, + variables: { + where: { + id: { equalTo: object3.id }, + }, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.find.edges[0].node.someField).toBeDefined(); + expect( + result1.data.find.edges[0].node.pointerToUser + ).toBeUndefined(); + expect(result2.data.find.edges[0].node.someField).toBeDefined(); + expect(result2.data.find.edges[0].node.pointerToUser).toBeDefined(); + }); + + it('should support include argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const where = { + id: { + equalTo: object3.id, + }, + }; + + const result1 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + pointerToUser { + id + } + } + } + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + expect( + result1.data.find.edges[0].node.pointerToUser.username + ).toBeUndefined(); + expect( + result2.data.find.edges[0].node.pointerToUser.username + ).toBeDefined(); + }); + + describe_only_db('mongo')('read preferences', () => { + it('should read from primary by default', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: graphQLClasses { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.PRIMARY + ); + } else if (call.args[0].ns.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.PRIMARY + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support readPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: graphQLClasses( + options: { readPreference: SECONDARY } + ) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } else if (call.args[0].ns.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support includeReadPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + graphQLClasses( + options: { + readPreference: SECONDARY + includeReadPreference: NEAREST + } + ) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } else if (call.args[0].ns.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.NEAREST + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support subqueryReadPreference argument', async () => { + try { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const databaseAdapter = + parseServer.config.databaseController.adapter; + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + find: graphQLClasses( + where: $where + options: { + readPreference: SECONDARY + subqueryReadPreference: NEAREST + } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + where: { + pointerToUser: { + have: { + objectId: { + equalTo: 'xxxx', + }, + }, + }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if ( + call.args[0].ns.collection.indexOf('GraphQLClass') >= 0 + ) { + foundGraphQLClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.SECONDARY + ); + } else if ( + call.args[0].ns.collection.indexOf('_User') >= 0 + ) { + foundUserClassReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.NEAREST + ); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + } catch (e) { + handleError(e); + } + }); + }); + + it('should order by multiple fields', async () => { + await prepareData(); + + await resetGraphQLCache(); + + let result; + try { + result = await apolloClient.query({ + query: gql` + query OrderByMultipleFields($order: [GraphQLClassOrder!]) { + graphQLClasses(order: $order) { + edges { + node { + objectId + } + } + } + } + `, + variables: { + order: ['someOtherField_DESC', 'someField_ASC'], + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + expect( + result.data.graphQLClasses.edges.map(edge => edge.node.objectId) + ).toEqual([object3.id, object1.id, object2.id]); + }); + + it_only_db('mongo')( + 'should order by multiple fields on a relation field', + async () => { + await prepareData(); + + const parentObject = new Parse.Object('ParentClass'); + const relation = parentObject.relation('graphQLClasses'); + relation.add(object1); + relation.add(object2); + relation.add(object3); + await parentObject.save(); + + await resetGraphQLCache(); + + let result; + try { + result = await apolloClient.query({ + query: gql` + query OrderByMultipleFieldsOnRelation( + $id: ID! + $order: [GraphQLClassOrder!] + ) { + parentClass(id: $id) { + graphQLClasses(order: $order) { + edges { + node { + objectId + } + } + } + } + } + `, + variables: { + id: parentObject.id, + order: ['someOtherField_DESC', 'someField_ASC'], + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + expect( + result.data.parentClass.graphQLClasses.edges.map( + edge => edge.node.objectId + ) + ).toEqual([object3.id, object1.id, object2.id]); + } + ); + }); + }); + + describe('Objects Mutations', () => { + describe('Create', () => { + it('should return specific type object using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const customerSchema = new Parse.Schema('Customer'); + customerSchema.addString('someField'); + await customerSchema.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateCustomer($input: CreateCustomerInput!) { + createCustomer(input: $input) { + clientMutationId + customer { + id + objectId + createdAt + someField + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + someField: 'someValue', + }, + }, + }, + }); + + expect(result.data.createCustomer.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.createCustomer.customer.id).toBeDefined(); + expect(result.data.createCustomer.customer.someField).toEqual( + 'someValue' + ); + + const customer = await new Parse.Query('Customer').get( + result.data.createCustomer.customer.objectId + ); + + expect(customer.createdAt).toEqual( + new Date(result.data.createCustomer.customer.createdAt) + ); + expect(customer.get('someField')).toEqual('someValue'); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + async function createObject(className, headers) { + const getClassName = + className.charAt(0).toLowerCase() + className.slice(1); + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject { + create${className}(input: {}) { + ${getClassName} { + id + createdAt + } + } + } + `, + context: { + headers, + }, + }); + + const specificCreate = + result.data[`create${className}`][getClassName]; + expect(specificCreate.id).toBeDefined(); + expect(specificCreate.createdAt).toBeDefined(); + + return result; + } + + await expectAsync(createObject('GraphQLClass')).toBeRejectedWith( + jasmine.stringMatching( + 'Permission denied for action create on class GraphQLClass' + ) + ); + await expectAsync(createObject('PublicClass')).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { 'X-Parse-Master-Key': 'test' }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { 'X-Parse-Master-Key': 'test' }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith( + jasmine.stringMatching( + 'Permission denied for action create on class GraphQLClass' + ) + ); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeResolved(); + }); + }); + + describe('Update', () => { + it('should return specific type object using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCustomer($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + clientMutationId + customer { + updatedAt + someField1 + someField2 + } + } + } + `, + variables: { + input: { + clientMutationId, + id: obj.id, + fields: { + someField1: 'someField1Value2', + }, + }, + }, + }); + + expect(result.data.updateCustomer.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.updateCustomer.customer.updatedAt).toBeDefined(); + expect(result.data.updateCustomer.customer.someField1).toEqual( + 'someField1Value2' + ); + expect(result.data.updateCustomer.customer.someField2).toEqual( + 'someField2Value1' + ); + + await obj.fetch(); + + expect(obj.get('someField1')).toEqual('someField1Value2'); + expect(obj.get('someField2')).toEqual('someField2Value1'); + }); + + it('should return only id using class specific mutation', async () => { + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCustomer( + $id: ID! + $fields: UpdateCustomerFieldsInput + ) { + updateCustomer(input: { id: $id, fields: $fields }) { + customer { + id + objectId + } + } + } + `, + variables: { + id: obj.id, + fields: { + someField1: 'someField1Value2', + }, + }, + }); + + expect(result.data.updateCustomer.customer.objectId).toEqual( + obj.id + ); + + await obj.fetch(); + + expect(obj.get('someField1')).toEqual('someField1Value2'); + expect(obj.get('someField2')).toEqual('someField2Value1'); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + async function updateObject(className, id, fields, headers) { + return await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: Update${className}FieldsInput + ) { + update: update${className}(input: { + id: $id + fields: $fields + clientMutationId: "someid" + }) { + clientMutationId + } + } + `, + variables: { + id, + fields, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject(obj.className, obj.id, { + someField: 'changedValue1', + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject(object4.className, object4.id, { + someField: 'changedValue1', + }) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue1'); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue2' }, + { 'X-Parse-Master-Key': 'test' } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue2'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue3' }, + { 'X-Parse-Session-Token': user1.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue3'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue4' }, + { 'X-Parse-Session-Token': user2.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue4'); + }) + ); + await Promise.all( + [object1, object3, object4].map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue5'); + }) + ); + const originalFieldValue = object2.get('someField'); + await expectAsync( + updateObject( + object2.className, + object2.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await object2.fetch({ useMasterKey: true }); + expect(object2.get('someField')).toEqual(originalFieldValue); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue6'); + await Promise.all( + objects.slice(0, 2).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object3.className, + object3.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object3.fetch({ useMasterKey: true }); + expect(object3.get('someField')).toEqual('changedValue7'); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue7'); + }); + + it('should respect level permissions with specific class mutation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + function updateObject(className, id, fields, headers) { + return apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: Update${className}FieldsInput + ) { + update${className}(input: { + id: $id + fields: $fields + }) { + ${className.charAt(0).toLowerCase() + + className.slice(1)} { + updatedAt + } + } + } + `, + variables: { + id, + fields, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject(obj.className, obj.id, { + someField: 'changedValue1', + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject(object4.className, object4.id, { + someField: 'changedValue1', + }) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue1'); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue2' }, + { 'X-Parse-Master-Key': 'test' } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue2'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue3' }, + { 'X-Parse-Session-Token': user1.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue3'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue4' }, + { 'X-Parse-Session-Token': user2.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue4'); + }) + ); + await Promise.all( + [object1, object3, object4].map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue5'); + }) + ); + const originalFieldValue = object2.get('someField'); + await expectAsync( + updateObject( + object2.className, + object2.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await object2.fetch({ useMasterKey: true }); + expect(object2.get('someField')).toEqual(originalFieldValue); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue6'); + await Promise.all( + objects.slice(0, 2).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object3.className, + object3.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data[`update${object3.className}`][ + object3.className.charAt(0).toLowerCase() + + object3.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object3.fetch({ useMasterKey: true }); + expect(object3.get('someField')).toEqual('changedValue7'); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue7'); + }); + }); + + describe('Delete', () => { + it('should return a specific type using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation DeleteCustomer($input: DeleteCustomerInput!) { + deleteCustomer(input: $input) { + clientMutationId + customer { + id + objectId + someField1 + someField2 + } + } + } + `, + variables: { + input: { + clientMutationId, + id: obj.id, + }, + }, + }); + + expect(result.data.deleteCustomer.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.deleteCustomer.customer.objectId).toEqual( + obj.id + ); + expect(result.data.deleteCustomer.customer.someField1).toEqual( + 'someField1Value1' + ); + expect(result.data.deleteCustomer.customer.someField2).toEqual( + 'someField2Value1' + ); + + await expectAsync( + obj.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + function deleteObject(className, id, headers) { + return apolloClient.mutate({ + mutation: gql` + mutation DeleteSomeObject( + $id: ID! + ) { + delete: delete${className}(input: { id: $id }) { + ${className.charAt(0).toLowerCase() + + className.slice(1)} { + objectId + } + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + (await deleteObject(object4.className, object4.id)).data.delete[ + object4.className.charAt(0).toLowerCase() + + object4.className.slice(1) + ] + ).toEqual({ objectId: object4.id, __typename: 'PublicClass' }); + await expectAsync( + object4.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object1.className, object1.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data.delete[ + object1.className.charAt(0).toLowerCase() + + object1.className.slice(1) + ] + ).toEqual({ objectId: object1.id, __typename: 'GraphQLClass' }); + await expectAsync( + object1.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.delete[ + object2.className.charAt(0).toLowerCase() + + object2.className.slice(1) + ] + ).toEqual({ objectId: object2.id, __typename: 'GraphQLClass' }); + await expectAsync( + object2.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.delete[ + object3.className.charAt(0).toLowerCase() + + object3.className.slice(1) + ] + ).toEqual({ objectId: object3.id, __typename: 'GraphQLClass' }); + await expectAsync( + object3.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + }); + + it('should respect level permissions with specific class mutation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + function deleteObject(className, id, headers) { + return apolloClient.mutate({ + mutation: gql` + mutation DeleteSomeObject( + $id: ID! + ) { + delete${className}(input: { id: $id }) { + ${className.charAt(0).toLowerCase() + + className.slice(1)} { + objectId + } + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + (await deleteObject(object4.className, object4.id)).data[ + `delete${object4.className}` + ][ + object4.className.charAt(0).toLowerCase() + + object4.className.slice(1) + ].objectId + ).toEqual(object4.id); + await expectAsync( + object4.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object1.className, object1.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data[`delete${object1.className}`][ + object1.className.charAt(0).toLowerCase() + + object1.className.slice(1) + ].objectId + ).toEqual(object1.id); + await expectAsync( + object1.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data[`delete${object2.className}`][ + object2.className.charAt(0).toLowerCase() + + object2.className.slice(1) + ].objectId + ).toEqual(object2.id); + await expectAsync( + object2.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + expect( + ( + await deleteObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data[`delete${object3.className}`][ + object3.className.charAt(0).toLowerCase() + + object3.className.slice(1) + ].objectId + ).toEqual(object3.id); + await expectAsync( + object3.fetch({ useMasterKey: true }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + }); + }); + }); + + describe('Files Mutations', () => { + describe('Create', () => { + it('should return File object', async () => { + const clientMutationId = uuidv4(); + + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + clientMutationId + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + clientMutationId, + upload: null, + }, + }, + }) + ); + body.append( + 'map', + JSON.stringify({ 1: ['variables.input.upload'] }) + ); + body.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + let res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + + expect(result.data.createFile.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + + res = await fetch(result.data.createFile.fileInfo.url); + + expect(res.status).toEqual(200); + expect(await res.text()).toEqual('My File Content'); + }); + }); + }); + + describe('Users Queries', () => { + it('should return current logged user', async () => { + const userName = 'user1', + password = 'user1', + email = 'emailUser1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + await user.signUp(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + id + username + email + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const { + id, + username: resultUserName, + email: resultEmail, + } = result.data.viewer.user; + expect(id).toBeDefined(); + expect(resultUserName).toEqual(userName); + expect(resultEmail).toEqual(email); + }); + + it('should return logged user including pointer', async () => { + const foo = new Parse.Object('Foo'); + foo.set('bar', 'hello'); + + const userName = 'user1', + password = 'user1', + email = 'emailUser1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + user.set('userFoo', foo); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + sessionToken + user { + id + objectId + userFoo { + bar + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const sessionToken = result.data.viewer.sessionToken; + const { objectId, userFoo: resultFoo } = result.data.viewer.user; + expect(objectId).toEqual(user.id); + expect(sessionToken).toBeDefined(); + expect(resultFoo).toBeDefined(); + expect(resultFoo.bar).toEqual('hello'); + }); + }); + + describe('Users Mutations', () => { + it('should sign user up', async () => { + const clientMutationId = uuidv4(); + const userSchema = new Parse.Schema('_User'); + userSchema.addString('someField'); + await userSchema.update(); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation SignUp($input: SignUpInput!) { + signUp(input: $input) { + clientMutationId + viewer { + sessionToken + user { + someField + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + username: 'user1', + password: 'user1', + someField: 'someValue', + }, + }, + }, + }); + + expect(result.data.signUp.clientMutationId).toEqual(clientMutationId); + expect(result.data.signUp.viewer.sessionToken).toBeDefined(); + expect(result.data.signUp.viewer.user.someField).toEqual('someValue'); + expect(typeof result.data.signUp.viewer.sessionToken).toBe('string'); + }); + + it('should login with user', async () => { + const clientMutationId = uuidv4(); + const userSchema = new Parse.Schema('_User'); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + myAuth: { + module: global.mockCustomAuthenticator('parse', 'graphql'), + }, + }, + }); + + userSchema.addString('someField'); + await userSchema.update(); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation LogInWith($input: LogInWithInput!) { + logInWith(input: $input) { + clientMutationId + viewer { + sessionToken + user { + someField + } + } + } + } + `, + variables: { + input: { + clientMutationId, + authData: { + myAuth: { + id: 'parse', + password: 'graphql', + }, + }, + fields: { + someField: 'someValue', + }, + }, + }, + }); + + expect(result.data.logInWith.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.logInWith.viewer.sessionToken).toBeDefined(); + expect(result.data.logInWith.viewer.user.someField).toEqual( + 'someValue' + ); + expect(typeof result.data.logInWith.viewer.sessionToken).toBe( + 'string' + ); + }); + + it('should log the user in', async () => { + const clientMutationId = uuidv4(); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.set('someField', 'someValue'); + await user.signUp(); + await Parse.User.logOut(); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation LogInUser($input: LogInInput!) { + logIn(input: $input) { + clientMutationId + viewer { + sessionToken + user { + someField + } + } + } + } + `, + variables: { + input: { + clientMutationId, + username: 'user1', + password: 'user1', + }, + }, + }); + + expect(result.data.logIn.clientMutationId).toEqual(clientMutationId); + expect(result.data.logIn.viewer.sessionToken).toBeDefined(); + expect(result.data.logIn.viewer.user.someField).toEqual('someValue'); + expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + }); + + it('should log the user out', async () => { + const clientMutationId = uuidv4(); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + await user.signUp(); + await Parse.User.logOut(); + + const logIn = await apolloClient.mutate({ + mutation: gql` + mutation LogInUser($input: LogInInput!) { + logIn(input: $input) { + viewer { + sessionToken + } + } + } + `, + variables: { + input: { + username: 'user1', + password: 'user1', + }, + }, + }); + + const sessionToken = logIn.data.logIn.viewer.sessionToken; + + const logOut = await apolloClient.mutate({ + mutation: gql` + mutation LogOutUser($input: LogOutInput!) { + logOut(input: $input) { + clientMutationId + viewer { + sessionToken + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': sessionToken, + }, + }, + variables: { + input: { + clientMutationId, + }, + }, + }); + expect(logOut.data.logOut.clientMutationId).toEqual(clientMutationId); + expect(logOut.data.logOut.viewer.sessionToken).toEqual(sessionToken); + + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + username + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': sessionToken, + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { statusCode, result } = err.networkError; + expect(statusCode).toBe(400); + expect(result).toEqual({ + code: 209, + error: 'Invalid session token', + }); + } + }); + + it('should send reset password', async () => { + const clientMutationId = uuidv4(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + parseServer = await global.reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://test.test', + }); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.setEmail('user1@user1.user1'); + await user.signUp(); + await Parse.User.logOut(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation ResetPassword($input: ResetPasswordInput!) { + resetPassword(input: $input) { + clientMutationId + ok + } + } + `, + variables: { + input: { + clientMutationId, + email: 'user1@user1.user1', + }, + }, + }); + + expect(result.data.resetPassword.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.resetPassword.ok).toBeTruthy(); + }); + it('should send verification email again', async () => { + const clientMutationId = uuidv4(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + parseServer = await global.reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://test.test', + }); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.setEmail('user1@user1.user1'); + await user.signUp(); + await Parse.User.logOut(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation SendVerificationEmail( + $input: SendVerificationEmailInput! + ) { + sendVerificationEmail(input: $input) { + clientMutationId + ok + } + } + `, + variables: { + input: { + clientMutationId, + email: 'user1@user1.user1', + }, + }, + }); + + expect(result.data.sendVerificationEmail.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.sendVerificationEmail.ok).toBeTruthy(); + }); + }); + + describe('Session Token', () => { + it('should fail due to invalid session token', async () => { + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + me { + username + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': 'foo', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { statusCode, result } = err.networkError; + expect(statusCode).toBe(400); + expect(result).toEqual({ + code: 209, + error: 'Invalid session token', + }); + } + }); + + it('should fail due to empty session token', async () => { + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + username + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': '', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { graphQLErrors } = err; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Invalid session token'); + } + }); + + it('should find a user and fail due to empty session token', async () => { + const car = new Parse.Object('Car'); + await car.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + username + } + } + cars { + edges { + node { + id + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': '', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { graphQLErrors } = err; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Invalid session token'); + } + }); + }); + + describe('Functions Mutations', () => { + it('can be called', async () => { + try { + const clientMutationId = uuidv4(); + + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation CallFunction($input: CallCloudCodeInput!) { + callCloudCode(input: $input) { + clientMutationId + result + } + } + `, + variables: { + input: { + clientMutationId, + functionName: 'hello', + }, + }, + }); + + expect(result.data.callCloudCode.clientMutationId).toEqual( + clientMutationId + ); + expect(result.data.callCloudCode.result).toEqual('Hello world!'); + } catch (e) { + handleError(e); + } + }); + + it('can throw errors', async () => { + Parse.Cloud.define('hello', async () => { + throw new Error('Some error message.'); + }); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation CallFunction { + callCloudCode(input: { functionName: hello }) { + result + } + } + `, + }); + fail('Should throw an error'); + } catch (e) { + const { graphQLErrors } = e; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Some error message.'); + } + }); + + it('should accept different params', done => { + Parse.Cloud.define('hello', async req => { + expect(req.params.date instanceof Date).toBe(true); + expect(req.params.date.getTime()).toBe(1463907600000); + expect(req.params.dateList[0] instanceof Date).toBe(true); + expect(req.params.dateList[0].getTime()).toBe(1463907600000); + expect(req.params.complexStructure.date[0] instanceof Date).toBe( + true + ); + expect(req.params.complexStructure.date[0].getTime()).toBe( + 1463907600000 + ); + expect( + req.params.complexStructure.deepDate.date[0] instanceof Date + ).toBe(true); + expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe( + 1463907600000 + ); + expect( + req.params.complexStructure.deepDate2[0].date instanceof Date + ).toBe(true); + expect( + req.params.complexStructure.deepDate2[0].date.getTime() + ).toBe(1463907600000); + // Regression for #2294 + expect(req.params.file instanceof Parse.File).toBe(true); + expect(req.params.file.url()).toEqual('https://some.url'); + // Regression for #2204 + expect(req.params.array).toEqual(['a', 'b', 'c']); + expect(Array.isArray(req.params.array)).toBe(true); + expect(req.params.arrayOfArray).toEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ]); + expect(Array.isArray(req.params.arrayOfArray)).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[0])).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[1])).toBe(true); + + done(); + }); + + const params = { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + dateList: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + lol: 'hello', + complexStructure: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + deepDate: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + }, + deepDate2: [ + { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + }, + ], + }, + file: Parse.File.fromJSON({ + __type: 'File', + name: 'name', + url: 'https://some.url', + }), + array: ['a', 'b', 'c'], + arrayOfArray: [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ], + }; + + apolloClient.mutate({ + mutation: gql` + mutation CallFunction($params: Object) { + callCloudCode(input: { functionName: hello, params: $params }) { + result + } + } + `, + variables: { + params, + }, + }); + }); + + it('should list all functions in the enum type', async () => { + try { + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('b', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('_underscored', async () => { + return 'hello _underscored'; + }); + + Parse.Cloud.define('contains1Number', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect( + functionEnum.enumValues.map(value => value.name).sort() + ).toEqual(['_underscored', 'a', 'b', 'contains1Number']); + } catch (e) { + handleError(e); + } + }); + + it('should warn functions not matching GraphQL allowed names', async () => { + try { + spyOn( + parseGraphQLServer.parseGraphQLSchema.log, + 'warn' + ).and.callThrough(); + + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('double-barrelled', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('1NumberInTheBeggning', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect( + functionEnum.enumValues.map(value => value.name).sort() + ).toEqual(['a']); + expect( + parseGraphQLServer.parseGraphQLSchema.log.warn.calls + .all() + .map(call => call.args[0]) + .sort() + ).toEqual([ + 'Function 1NumberInTheBeggning could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + 'Function double-barrelled could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + ]); + } catch (e) { + handleError(e); + } + }); + }); + + describe('Data Types', () => { + it('should support String', async () => { + try { + const someFieldValue = 'some string'; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addStrings: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('String'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: String) { + someClass(id: $id) { + someField + } + someClasses( + where: { someField: { equalTo: $someFieldValue } } + ) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('string'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Int numbers', async () => { + try { + const someFieldValue = 123; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addNumbers: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Number'); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Float) { + someClass(id: $id) { + someField + } + someClasses( + where: { someField: { equalTo: $someFieldValue } } + ) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('number'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Float numbers', async () => { + try { + const someFieldValue = 123.4; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addNumbers: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Number'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Float) { + someClass(id: $id) { + someField + } + someClasses( + where: { someField: { equalTo: $someFieldValue } } + ) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('number'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Boolean', async () => { + try { + const someFieldValueTrue = true; + const someFieldValueFalse = false; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addBooleans: [ + { name: 'someFieldTrue' }, + { name: 'someFieldFalse' }, + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someFieldTrue.type).toEqual('Boolean'); + expect(schema.fields.someFieldFalse.type).toEqual('Boolean'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someFieldTrue: someFieldValueTrue, + someFieldFalse: someFieldValueFalse, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject( + $id: ID! + $someFieldValueTrue: Boolean + $someFieldValueFalse: Boolean + ) { + someClass(id: $id) { + someFieldTrue + someFieldFalse + } + someClasses( + where: { + someFieldTrue: { equalTo: $someFieldValueTrue } + someFieldFalse: { equalTo: $someFieldValueFalse } + } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValueTrue, + someFieldValueFalse, + }, + }); + + expect(typeof getResult.data.someClass.someFieldTrue).toEqual( + 'boolean' + ); + expect(typeof getResult.data.someClass.someFieldFalse).toEqual( + 'boolean' + ); + expect(getResult.data.someClass.someFieldTrue).toEqual(true); + expect(getResult.data.someClass.someFieldFalse).toEqual(false); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Date', async () => { + try { + const someFieldValue = new Date(); + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addDates: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Date'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { exists: true } }) { + edges { + node { + id + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(new Date(getResult.data.someClass.someField)).toEqual( + someFieldValue + ); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support createdAt and updatedAt', async () => { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.createdAt.type).toEqual('Date'); + expect(schema.fields.updatedAt.type).toEqual('Date'); + }); + + it('should support ACL', async () => { + const someClass = new Parse.Object('SomeClass'); + await someClass.save(); + + const user = new Parse.User(); + user.set('username', 'username'); + user.set('password', 'password'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.set('username', 'username2'); + user2.set('password', 'password2'); + await user2.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + + const role = new Parse.Role('aRole', roleACL); + await role.save(); + + const role2 = new Parse.Role('aRole2', roleACL); + await role2.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { createSomeClass }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + objectId + ACL { + users { + userId + read + write + } + roles { + roleName + read + write + } + public { + read + write + } + } + } + } + } + `, + variables: { + fields: { + ACL: { + users: [ + { userId: user.id, read: true, write: true }, + { userId: user2.id, read: true, write: false }, + ], + roles: [ + { roleName: 'aRole', read: true, write: false }, + { roleName: 'aRole2', read: false, write: true }, + ], + public: { read: true, write: true }, + }, + }, + }, + }); + + const expectedCreateACL = { + __typename: 'ACL', + users: [ + { + userId: user.id, + read: true, + write: true, + __typename: 'UserACL', + }, + { + userId: user2.id, + read: true, + write: false, + __typename: 'UserACL', + }, + ], + roles: [ + { + roleName: 'aRole', + read: true, + write: false, + __typename: 'RoleACL', + }, + { + roleName: 'aRole2', + read: false, + write: true, + __typename: 'RoleACL', + }, + ], + public: { read: true, write: true, __typename: 'PublicACL' }, + }; + const query1 = new Parse.Query('SomeClass'); + const obj1 = ( + await query1.get(createSomeClass.someClass.objectId, { + useMasterKey: true, + }) + ).toJSON(); + expect(obj1.ACL[user.id]).toEqual({ read: true, write: true }); + expect(obj1.ACL[user2.id]).toEqual({ read: true }); + expect(obj1.ACL['role:aRole']).toEqual({ read: true }); + expect(obj1.ACL['role:aRole2']).toEqual({ write: true }); + expect(obj1.ACL['*']).toEqual({ read: true, write: true }); + expect(createSomeClass.someClass.ACL).toEqual(expectedCreateACL); + + const { + data: { updateSomeClass }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateSomeClassFieldsInput) { + updateSomeClass(input: { id: $id, fields: $fields }) { + someClass { + id + objectId + ACL { + users { + userId + read + write + } + roles { + roleName + read + write + } + public { + read + write + } + } + } + } + } + `, + variables: { + id: createSomeClass.someClass.id, + fields: { + ACL: { + roles: [{ roleName: 'aRole', write: true, read: true }], + public: { read: true, write: false }, + }, + }, + }, + }); + + const expectedUpdateACL = { + __typename: 'ACL', + users: null, + roles: [ + { + roleName: 'aRole', + read: true, + write: true, + __typename: 'RoleACL', + }, + ], + public: { read: true, write: false, __typename: 'PublicACL' }, + }; + + const query2 = new Parse.Query('SomeClass'); + const obj2 = ( + await query2.get(createSomeClass.someClass.objectId, { + useMasterKey: true, + }) + ).toJSON(); + + expect(obj2.ACL['role:aRole']).toEqual({ write: true, read: true }); + expect(obj2.ACL[user.id]).toBeUndefined(); + expect(obj2.ACL['*']).toEqual({ read: true }); + expect(updateSomeClass.someClass.ACL).toEqual(expectedUpdateACL); + }); + + it('should support pointer on create', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + objectId + company { + id + objectId + name + } + } + } + } + `, + variables: { + fields: { + name: 'imCountry2', + company: { link: company2.id }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.objectId).toEqual(company2.id); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support nested pointer on create', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + company { + id + name + } + } + } + } + `, + variables: { + fields: { + name: 'imCountry2', + company: { + createAndLink: { + name: 'imACompany2', + }, + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.id).toBeDefined(); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support pointer on update', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateCountryFieldsInput) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + objectId + company { + id + objectId + name + } + } + } + } + `, + variables: { + id: country.id, + fields: { + company: { link: company2.id }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.objectId).toEqual(company2.id); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support nested pointer on update', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateCountryFieldsInput) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + company { + id + name + } + } + } + } + `, + variables: { + id: country.id, + fields: { + company: { + createAndLink: { + name: 'imACompany2', + }, + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.id).toBeDefined(); + expect(result.company.name).toEqual('imACompany2'); + }); + + it_only_db('mongo')( + 'should support relation and nested relation on create', + async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + objectId + name + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + `, + variables: { + fields: { + name: 'imACountry2', + companies: { + add: [company.id], + createAndAdd: [ + { + name: 'imACompany2', + }, + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(3); + expect( + result.companies.edges.some(o => o.node.objectId === company.id) + ).toBeTruthy(); + expect( + result.companies.edges.some(o => o.node.name === 'imACompany2') + ).toBeTruthy(); + expect( + result.companies.edges.some(o => o.node.name === 'imACompany3') + ).toBeTruthy(); + } + ); + + it_only_db('mongo')('should support deep nested creation', async () => { + const team = new Parse.Object('Team'); + team.set('name', 'imATeam1'); + await team.save(); + + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + company.relation('teams').add(team); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + name + companies { + edges { + node { + id + name + teams { + edges { + node { + id + name + } + } + } + } + } + } + } + } + } + `, + variables: { + fields: { + name: 'imACountry2', + companies: { + createAndAdd: [ + { + name: 'imACompany2', + teams: { + createAndAdd: { + name: 'imATeam2', + }, + }, + }, + { + name: 'imACompany3', + teams: { + createAndAdd: { + name: 'imATeam3', + }, + }, + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(2); + expect( + result.companies.edges.some( + c => + c.node.name === 'imACompany2' && + c.node.teams.edges.some(t => t.node.name === 'imATeam2') + ) + ).toBeTruthy(); + expect( + result.companies.edges.some( + c => + c.node.name === 'imACompany3' && + c.node.teams.edges.some(t => t.node.name === 'imATeam3') + ) + ).toBeTruthy(); + }); + + it_only_db('mongo')( + 'should support relation and nested relation on update', + async () => { + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company1); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCountry( + $id: ID! + $fields: UpdateCountryFieldsInput + ) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + `, + variables: { + id: country.id, + fields: { + companies: { + add: [company2.id], + remove: [company1.id], + createAndAdd: [ + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.objectId).toEqual(country.id); + expect(result.companies.edges.length).toEqual(2); + expect( + result.companies.edges.some(o => o.node.objectId === company2.id) + ).toBeTruthy(); + expect( + result.companies.edges.some(o => o.node.name === 'imACompany3') + ).toBeTruthy(); + expect( + result.companies.edges.some(o => o.node.objectId === company1.id) + ).toBeFalsy(); + } + ); + + it_only_db('mongo')( + 'should support nested relation on create with filter', + async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry( + $fields: CreateCountryFieldsInput + $where: CompanyWhereInput + ) { + createCountry(input: { fields: $fields }) { + country { + id + name + companies(where: $where) { + edges { + node { + id + name + } + } + } + } + } + } + `, + variables: { + where: { + name: { + equalTo: 'imACompany2', + }, + }, + fields: { + name: 'imACountry2', + companies: { + add: [company.id], + createAndAdd: [ + { + name: 'imACompany2', + }, + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(1); + expect( + result.companies.edges.some(o => o.node.name === 'imACompany2') + ).toBeTruthy(); + } + ); + + it_only_db('mongo')('should support relation on query', async () => { + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add([company1, company2]); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + // Without where + const { + data: { country: result1 }, + } = await apolloClient.query({ + query: gql` + query getCountry($id: ID!) { + country(id: $id) { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + count + } + } + } + `, + variables: { + id: country.id, + }, + }); + + expect(result1.objectId).toEqual(country.id); + expect(result1.companies.edges.length).toEqual(2); + expect( + result1.companies.edges.some(o => o.node.objectId === company1.id) + ).toBeTruthy(); + expect( + result1.companies.edges.some(o => o.node.objectId === company2.id) + ).toBeTruthy(); + + // With where + const { + data: { country: result2 }, + } = await apolloClient.query({ + query: gql` + query getCountry($id: ID!, $where: CompanyWhereInput) { + country(id: $id) { + id + objectId + companies(where: $where) { + edges { + node { + id + objectId + name + } + } + } + } + } + `, + variables: { + id: country.id, + where: { + name: { equalTo: 'imACompany1' }, + }, + }, + }); + expect(result2.objectId).toEqual(country.id); + expect(result2.companies.edges.length).toEqual(1); + expect(result2.companies.edges[0].node.objectId).toEqual(company1.id); + }); + + it_only_db('mongo')( + 'should support relational where query', + async () => { + const president = new Parse.Object('President'); + president.set('name', 'James'); + await president.save(); + + const employee = new Parse.Object('Employee'); + employee.set('name', 'John'); + await employee.save(); + + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + company2.relation('employees').add([employee]); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add([company1, company2]); + await country.save(); + + const country2 = new Parse.Object('Country'); + country2.set('name', 'imACountry2'); + country2.relation('companies').add([company1]); + await country2.save(); + + const country3 = new Parse.Object('Country'); + country3.set('name', 'imACountry3'); + country3.set('president', president); + await country3.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + let { + data: { + countries: { edges: result }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + } + `, + variables: { + where: { + companies: { + have: { + employees: { have: { name: { equalTo: 'John' } } }, + }, + }, + }, + }, + }); + expect(result.length).toEqual(1); + result = result[0].node; + expect(result.objectId).toEqual(country.id); + expect(result.companies.edges.length).toEqual(2); + + const { + data: { + countries: { edges: result2 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + } + `, + variables: { + where: { + companies: { + have: { + OR: [ + { name: { equalTo: 'imACompany1' } }, + { name: { equalTo: 'imACompany2' } }, + ], + }, + }, + }, + }, + }); + expect(result2.length).toEqual(2); + + const { + data: { + countries: { edges: result3 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + companies: { exists: false }, + }, + }, + }); + expect(result3.length).toEqual(1); + expect(result3[0].node.name).toEqual('imACountry3'); + + const { + data: { + countries: { edges: result4 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + president: { exists: false }, + }, + }, + }); + expect(result4.length).toEqual(2); + const { + data: { + countries: { edges: result5 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + president: { exists: true }, + }, + }, + }); + expect(result5.length).toEqual(1); + const { + data: { + countries: { edges: result6 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + name + } + } + } + } + `, + variables: { + where: { + companies: { + haveNot: { + OR: [ + { name: { equalTo: 'imACompany1' } }, + { name: { equalTo: 'imACompany2' } }, + ], + }, + }, + }, + }, + }); + expect(result6.length).toEqual(1); + expect(result6.length).toEqual(1); + expect(result6[0].node.name).toEqual('imACountry3'); + } + ); + + it('should support files', async () => { + try { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + upload: null, + }, + }, + }) + ); + body.append( + 'map', + JSON.stringify({ 1: ['variables.input.upload'] }) + ); + body.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + let res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + + const someFieldValue = result.data.createFile.fileInfo.name; + const someFieldObjectValue = result.data.createFile.fileInfo; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addFiles: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const body2 = new FormData(); + body2.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + $fields3: CreateSomeClassFieldsInput + ) { + createSomeClass1: createSomeClass( + input: { fields: $fields1 } + ) { + someClass { + id + someField { + name + url + } + } + } + createSomeClass2: createSomeClass( + input: { fields: $fields2 } + ) { + someClass { + id + someField { + name + url + } + } + } + createSomeClass3: createSomeClass( + input: { fields: $fields3 } + ) { + someClass { + id + someField { + name + url + } + } + } + } + `, + variables: { + fields1: { + someField: { file: someFieldValue }, + }, + fields2: { + someField: { + file: { + name: someFieldObjectValue.name, + url: someFieldObjectValue.url, + __type: 'File', + }, + }, + }, + fields3: { + someField: { upload: null }, + }, + }, + }) + ); + body2.append( + 'map', + JSON.stringify({ 1: ['variables.fields3.someField.upload'] }) + ); + body2.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body: body2, + }); + expect(res.status).toEqual(200); + const result2 = JSON.parse(await res.text()); + expect( + result2.data.createSomeClass1.someClass.someField.name + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result2.data.createSomeClass1.someClass.someField.url + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result2.data.createSomeClass2.someClass.someField.name + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result2.data.createSomeClass2.someClass.someField.url + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result2.data.createSomeClass3.someClass.someField.name + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result2.data.createSomeClass3.someClass.someField.url + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('File'); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField { + name + url + } + } + findSomeClass1: someClasses( + where: { someField: { exists: true } } + ) { + edges { + node { + someField { + name + url + } + } + } + } + findSomeClass2: someClasses( + where: { someField: { exists: true } } + ) { + edges { + node { + someField { + name + url + } + } + } + } + } + `, + variables: { + id: result2.data.createSomeClass1.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('object'); + expect(getResult.data.someClass.someField.name).toEqual( + result.data.createFile.fileInfo.name + ); + expect(getResult.data.someClass.someField.url).toEqual( + result.data.createFile.fileInfo.url + ); + expect(getResult.data.findSomeClass1.edges.length).toEqual(3); + expect(getResult.data.findSomeClass2.edges.length).toEqual(3); + + res = await fetch(getResult.data.someClass.someField.url); + + expect(res.status).toEqual(200); + expect(await res.text()).toEqual('My File Content'); + } catch (e) { + handleError(e); + } + }); + + it('should support object values', async () => { + try { + const someFieldValue = { + foo: { bar: 'baz' }, + number: 10, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addObjects: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Object'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const where = { + someField: { + equalTo: { key: 'foo.bar', value: 'baz' }, + notEqualTo: { key: 'foo.bar', value: 'bat' }, + greaterThan: { key: 'number', value: 9 }, + lessThan: { key: 'number', value: 11 }, + }, + }; + const queryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $where: SomeClassWhereInput) { + someClass(id: $id) { + id + someField + } + someClasses(where: $where) { + edges { + node { + id + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + where, + }, + }); + + const { someClass: getResult, someClasses } = queryResult.data; + + const { someField } = getResult; + expect(typeof someField).toEqual('object'); + expect(someField).toEqual(someFieldValue); + + // Checks class query results + expect(someClasses.edges.length).toEqual(1); + expect(someClasses.edges[0].node.someField).toEqual(someFieldValue); + } catch (e) { + handleError(e); + } + }); + + it('should support object composed queries', async () => { + try { + const someFieldValue = { + lorem: 'ipsum', + number: 10, + }; + const someFieldValue2 = { + foo: { + test: 'bar', + }, + number: 10, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass( + input: { + name: "SomeClass" + schemaFields: { addObjects: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + ) { + create1: createSomeClass(input: { fields: $fields1 }) { + someClass { + id + } + } + create2: createSomeClass(input: { fields: $fields2 }) { + someClass { + id + } + } + } + `, + variables: { + fields1: { + someField: someFieldValue, + }, + fields2: { + someField: someFieldValue2, + }, + }, + }); + + const where = { + AND: [ + { + someField: { + greaterThan: { key: 'number', value: 9 }, + }, + }, + { + someField: { + lessThan: { key: 'number', value: 11 }, + }, + }, + { + OR: [ + { + someField: { + equalTo: { key: 'lorem', value: 'ipsum' }, + }, + }, + { + someField: { + equalTo: { key: 'foo.test', value: 'bar' }, + }, + }, + ], + }, + ], + }; + const findResult = await apolloClient.query({ + query: gql` + query FindSomeObject($where: SomeClassWhereInput) { + someClasses(where: $where) { + edges { + node { + id + someField + } + } + } + } + `, + variables: { + where, + }, + }); + + const { create1, create2 } = createResult.data; + const { someClasses } = findResult.data; + + // Checks class query results + const { edges } = someClasses; + expect(edges.length).toEqual(2); + expect( + edges.find(result => result.node.id === create1.someClass.id).node + .someField + ).toEqual(someFieldValue); + expect( + edges.find(result => result.node.id === create2.someClass.id).node + .someField + ).toEqual(someFieldValue2); + } catch (e) { + handleError(e); + } + }); + + it('should support array values', async () => { + try { + const someFieldValue = [ + 1, + 'foo', + ['bar'], + { lorem: 'ipsum' }, + true, + ]; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addArrays: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Array'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField { + ... on Element { + value + } + } + } + someClasses(where: { someField: { exists: true } }) { + edges { + node { + id + someField { + ... on Element { + value + } + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + const { someField } = getResult.data.someClass; + expect(Array.isArray(someField)).toBeTruthy(); + expect(someField.map(element => element.value)).toEqual( + someFieldValue + ); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support undefined array', async () => { + const schema = await new Parse.Schema('SomeClass'); + schema.addArray('someArray'); + await schema.save(); + + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + id + someArray { + ... on Element { + value + } + } + } + } + `, + variables: { + id: obj.id, + }, + }); + expect(getResult.data.someClass.someArray).toEqual(null); + }); + + it('should support null values', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass( + input: { + name: "SomeClass" + schemaFields: { + addStrings: [ + { name: "someStringField" } + { name: "someNullField" } + ] + addNumbers: [{ name: "someNumberField" }] + addBooleans: [{ name: "someBooleanField" }] + addObjects: [{ name: "someObjectField" }] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someStringField: 'some string', + someNumberField: 123, + someBooleanField: true, + someObjectField: { someField: 'some value' }, + someNullField: null, + }, + }, + }); + + await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: UpdateSomeClassFieldsInput + ) { + updateSomeClass(input: { id: $id, fields: $fields }) { + clientMutationId + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + fields: { + someStringField: null, + someNumberField: null, + someBooleanField: null, + someObjectField: null, + someNullField: 'now it has a string', + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someStringField + someNumberField + someBooleanField + someObjectField + someNullField + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(getResult.data.someClass.someStringField).toBeFalsy(); + expect(getResult.data.someClass.someNumberField).toBeFalsy(); + expect(getResult.data.someClass.someBooleanField).toBeFalsy(); + expect(getResult.data.someClass.someObjectField).toBeFalsy(); + expect(getResult.data.someClass.someNullField).toEqual( + 'now it has a string' + ); + } catch (e) { + handleError(e); + } + }); + + it('should support Bytes', async () => { + try { + const someFieldValue = 'aGVsbG8gd29ybGQ='; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addBytes: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Bytes'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + ) { + createSomeClass1: createSomeClass( + input: { fields: $fields1 } + ) { + someClass { + id + } + } + createSomeClass2: createSomeClass( + input: { fields: $fields2 } + ) { + someClass { + id + } + } + } + `, + variables: { + fields1: { + someField: someFieldValue, + }, + fields2: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Bytes) { + someClass(id: $id) { + someField + } + someClasses( + where: { someField: { equalTo: $someFieldValue } } + ) { + edges { + node { + id + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass1.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('string'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(2); + } catch (e) { + handleError(e); + } + }); + + it('should support Geo Points', async () => { + try { + const someFieldValue = { + __typename: 'GeoPoint', + latitude: 45, + longitude: 45, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addGeoPoint: { name: 'someField' }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('GeoPoint'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: { + latitude: someFieldValue.latitude, + longitude: someFieldValue.longitude, + }, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField { + latitude + longitude + } + } + someClasses(where: { someField: { exists: true } }) { + edges { + node { + id + someField { + latitude + longitude + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('object'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + + const getGeoWhere = await apolloClient.query({ + query: gql` + query GeoQuery($latitude: Float!, $longitude: Float!) { + nearSphere: someClasses( + where: { + someField: { + nearSphere: { + latitude: $latitude + longitude: $longitude + } + } + } + ) { + edges { + node { + id + } + } + } + geoWithin: someClasses( + where: { + someField: { + geoWithin: { + centerSphere: { + distance: 10 + center: { + latitude: $latitude + longitude: $longitude + } + } + } + } + } + ) { + edges { + node { + id + } + } + } + within: someClasses( + where: { + someField: { + within: { + box: { + bottomLeft: { + latitude: $latitude + longitude: $longitude + } + upperRight: { + latitude: $latitude + longitude: $longitude + } + } + } + } + } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + latitude: 45, + longitude: 45, + }, + }); + expect(getGeoWhere.data.nearSphere.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + expect(getGeoWhere.data.geoWithin.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + expect(getGeoWhere.data.within.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + } catch (e) { + handleError(e); + } + }); + + it('should support Polygons', async () => { + try { + const somePolygonFieldValue = [ + [44, 45], + [46, 47], + [48, 49], + [44, 45], + ].map(point => ({ + latitude: point[0], + longitude: point[1], + })); + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass( + input: { name: "SomeClass", schemaFields: $schemaFields } + ) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addPolygons: [{ name: 'somePolygonField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.somePolygonField.type).toEqual('Polygon'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + somePolygonField: somePolygonFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + somePolygonField { + latitude + longitude + } + } + someClasses(where: { somePolygonField: { exists: true } }) { + edges { + node { + id + somePolygonField { + latitude + longitude + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.somePolygonField).toEqual( + 'object' + ); + expect(getResult.data.someClass.somePolygonField).toEqual( + somePolygonFieldValue.map(geoPoint => ({ + ...geoPoint, + __typename: 'GeoPoint', + })) + ); + expect(getResult.data.someClasses.edges.length).toEqual(1); + const getIntersect = await apolloClient.query({ + query: gql` + query IntersectQuery($point: GeoPointInput!) { + someClasses( + where: { + somePolygonField: { geoIntersects: { point: $point } } + } + ) { + edges { + node { + id + somePolygonField { + latitude + longitude + } + } + } + } + } + `, + variables: { + point: { latitude: 44, longitude: 45 }, + }, + }); + expect(getIntersect.data.someClasses.edges.length).toEqual(1); + expect(getIntersect.data.someClasses.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + } catch (e) { + handleError(e); + } + }); + + it_only_db('mongo')('should support bytes values', async () => { + const SomeClass = Parse.Object.extend('SomeClass'); + const someClass = new SomeClass(); + someClass.set('someField', { + __type: 'Bytes', + base64: 'foo', + }); + await someClass.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Bytes'); + + const someFieldValue = { + __type: 'Bytes', + base64: 'bytesContent', + }; + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(getResult.data.someClass.someField).toEqual( + someFieldValue.base64 + ); + + const updatedSomeFieldValue = { + __type: 'Bytes', + base64: 'newBytesContent', + }; + + const updatedResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: UpdateSomeClassFieldsInput + ) { + updateSomeClass(input: { id: $id, fields: $fields }) { + someClass { + updatedAt + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + fields: { + someField: updatedSomeFieldValue, + }, + }, + }); + + const { updatedAt } = updatedResult.data.updateSomeClass.someClass; + expect(updatedAt).toBeDefined(); + + const findResult = await apolloClient.query({ + query: gql` + query FindSomeObject($where: SomeClassWhereInput!) { + someClasses(where: $where) { + edges { + node { + id + } + } + } + } + `, + variables: { + where: { + someField: { + equalTo: updatedSomeFieldValue.base64, + }, + }, + }, + }); + const findResults = findResult.data.someClasses.edges; + expect(findResults.length).toBe(1); + expect(findResults[0].node.id).toBe( + createResult.data.createSomeClass.someClass.id + ); + }); + }); + + describe('Special Classes', () => { + it('should support User class', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: user(id: $id) { + objectId + } + } + `, + variables: { + id: user.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(user.id); + }); + + it('should support Installation class', async () => { + const installation = new Parse.Installation(); + await installation.save({ + deviceType: 'foo', + }); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: installation(id: $id) { + objectId + } + } + `, + variables: { + id: installation.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(installation.id); + }); + + it('should support Role class', async () => { + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('MyRole', roleACL); + await role.save(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: role(id: $id) { + objectId + } + } + `, + variables: { + id: role.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(role.id); + }); + + it('should support Session class', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const session = await Parse.Session.current(); + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: session(id: $id) { + id + objectId + } + } + `, + variables: { + id: session.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + expect(getResult.data.get.objectId).toEqual(session.id); + }); + + it('should support Product class', async () => { + const Product = Parse.Object.extend('_Product'); + const product = new Product(); + await product.save( + { + productIdentifier: 'foo', + icon: new Parse.File('icon', ['foo']), + order: 1, + title: 'Foo', + subtitle: 'My product', + }, + { useMasterKey: true } + ); + + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: product(id: $id) { + objectId + } + } + `, + variables: { + id: product.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(getResult.data.get.objectId).toEqual(product.id); + }); + }); + }); + }); + + describe('Custom API', () => { + describe('GraphQL Schema Based', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + beforeAll(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: gql` + extend type Query { + hello: String @resolve + hello2: String @resolve(to: "hello") + userEcho(user: CreateUserFieldsInput!): User! @resolve + hello3: String! @mock(with: "Hello world!") + hello4: User! @mock(with: { username: "somefolk" }) + } + `, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => + httpServer.listen({ port: 13377 }, resolve) + ); + const httpLink = createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterAll(async () => { + await httpServer.close(); + }); + + it('can resolve a custom query using default function name', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + hello + } + `, + }); + + expect(result.data.hello).toEqual('Hello world!'); + }); + + it('can resolve a custom query using function name set by "to" argument', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + hello2 + } + `, + }); + + expect(result.data.hello2).toEqual('Hello world!'); + }); + }); + + describe('SDL Based', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + + beforeAll(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + const TypeEnum = new GraphQLEnumType({ + name: 'TypeEnum', + values: { + human: { value: 'human' }, + robot: { value: 'robot' }, + }, + }); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + customQuery: { + type: new GraphQLNonNull(GraphQLString), + args: { + message: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: (p, { message }) => message, + }, + }, + }), + types: [ + new GraphQLInputObjectType({ + name: 'CreateSomeClassFieldsInput', + fields: { + type: { type: TypeEnum }, + }, + }), + new GraphQLInputObjectType({ + name: 'UpdateSomeClassFieldsInput', + fields: { + type: { type: TypeEnum }, + }, + }), + new GraphQLObjectType({ + name: 'SomeClass', + fields: { + nameUpperCase: { + type: new GraphQLNonNull(GraphQLString), + resolve: p => p.name.toUpperCase(), + }, + type: { type: TypeEnum }, + language: { + type: new GraphQLEnumType({ + name: 'LanguageEnum', + values: { + fr: { value: 'fr' }, + en: { value: 'en' }, + }, + }), + resolve: () => 'fr', + }, + }, + }), + ], + }), + }); + + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => + httpServer.listen({ port: 13377 }, resolve) + ); + const httpLink = createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterAll(async () => { + await httpServer.close(); + }); + + it('can resolve a custom query', async () => { + const result = await apolloClient.query({ + variables: { message: 'hello' }, + query: gql` + query CustomQuery($message: String!) { + customQuery(message: $message) + } + `, + }); + expect(result.data.customQuery).toEqual('hello'); + }); + + it('can resolve a custom extend type', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot' }); + await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear(); + const result = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query someClass($id: ID!) { + someClass(id: $id) { + nameUpperCase + language + type + } + } + `, + }); + expect(result.data.someClass.nameUpperCase).toEqual('ANAME'); + expect(result.data.someClass.language).toEqual('fr'); + expect(result.data.someClass.type).toEqual('robot'); + + const result2 = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query someClass($id: ID!) { + someClass(id: $id) { + name + language + } + } + `, + }); + expect(result2.data.someClass.name).toEqual('aname'); + expect(result.data.someClass.language).toEqual('fr'); + const result3 = await apolloClient.mutate({ + variables: { id: obj.id, name: 'anewname', type: 'human' }, + mutation: gql` + mutation someClass($id: ID!, $name: String!, $type: TypeEnum!) { + updateSomeClass( + input: { id: $id, fields: { name: $name, type: $type } } + ) { + someClass { + nameUpperCase + type + } + } + } + `, + }); + expect(result3.data.updateSomeClass.someClass.nameUpperCase).toEqual( + 'ANEWNAME' + ); + expect(result3.data.updateSomeClass.someClass.type).toEqual('human'); + }); + }); + describe('Async Function Based Merge', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + + beforeAll(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: ({ autoSchema, mergeSchemas }) => + mergeSchemas({ schemas: [autoSchema] }), + }); + + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => + httpServer.listen({ port: 13377 }, resolve) + ); + const httpLink = createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterAll(async () => { + await httpServer.close(); + }); + + it('can resolve a query', async () => { + const result = await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }); + expect(result.data.health).toEqual(true); + }); + }); + }); +}); diff --git a/spec/ParseHooks.spec.js b/spec/ParseHooks.spec.js index 0bc5650bb0..b3f45fc16f 100644 --- a/spec/ParseHooks.spec.js +++ b/spec/ParseHooks.spec.js @@ -1,498 +1,768 @@ -"use strict"; -/* global describe, it, expect, fail, Parse */ -var request = require('request'); -var triggers = require('../src/triggers'); -var HooksController = require('../src/Controllers/HooksController').default; -var express = require("express"); -var bodyParser = require('body-parser'); +'use strict'; -var port = 12345; -var hookServerURL = "http://localhost:" + port; -const AppCache = require('../src/cache').AppCache; +const request = require('../lib/request'); +const triggers = require('../lib/triggers'); +const HooksController = require('../lib/Controllers/HooksController').default; +const express = require('express'); +const bodyParser = require('body-parser'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); -var app = express(); -app.use(bodyParser.json({ 'type': '*/*' })) -app.listen(12345); +const port = 12345; +const hookServerURL = 'http://localhost:' + port; +const AppCache = require('../lib/cache').AppCache; describe('Hooks', () => { - it("should have no hooks registered", (done) => { - Parse.Hooks.getFunctions().then((res) => { - expect(res.constructor).toBe(Array.prototype.constructor); - done(); - }, (err) => { - jfail(err); - done(); - }); + let server; + let app; + beforeAll(done => { + app = express(); + app.use(bodyParser.json({ type: '*/*' })); + server = app.listen(12345, undefined, done); }); - it("should have no triggers registered", (done) => { - Parse.Hooks.getTriggers().then((res) => { - expect(res.constructor).toBe(Array.prototype.constructor); - done(); - }, (err) => { - jfail(err); - done(); - }); + afterAll(done => { + server.close(done); + }); + + it('should have no hooks registered', done => { + Parse.Hooks.getFunctions().then( + res => { + expect(res.constructor).toBe(Array.prototype.constructor); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should have no triggers registered', done => { + Parse.Hooks.getTriggers().then( + res => { + expect(res.constructor).toBe(Array.prototype.constructor); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it("should CRUD a function registration", (done) => { + it('should CRUD a function registration', done => { // Create - Parse.Hooks.createFunction("My-Test-Function", "http://someurl") + Parse.Hooks.createFunction('My-Test-Function', 'http://someurl') .then(response => { - expect(response.functionName).toBe("My-Test-Function"); - expect(response.url).toBe("http://someurl") + expect(response.functionName).toBe('My-Test-Function'); + expect(response.url).toBe('http://someurl'); // Find - return Parse.Hooks.getFunction("My-Test-Function") - }).then(response => { + return Parse.Hooks.getFunction('My-Test-Function'); + }) + .then(response => { expect(response.objectId).toBeUndefined(); - expect(response.url).toBe("http://someurl"); - return Parse.Hooks.updateFunction("My-Test-Function", "http://anotherurl"); + expect(response.url).toBe('http://someurl'); + return Parse.Hooks.updateFunction( + 'My-Test-Function', + 'http://anotherurl' + ); }) - .then((res) => { + .then(res => { expect(res.objectId).toBeUndefined(); - expect(res.functionName).toBe("My-Test-Function"); - expect(res.url).toBe("http://anotherurl") + expect(res.functionName).toBe('My-Test-Function'); + expect(res.url).toBe('http://anotherurl'); // delete - return Parse.Hooks.removeFunction("My-Test-Function") + return Parse.Hooks.removeFunction('My-Test-Function'); }) .then(() => { - // Find again! but should be deleted - return Parse.Hooks.getFunction("My-Test-Function") - .then(res => { - fail("Failed to delete hook") - fail(res) + // Find again! but should be deleted + return Parse.Hooks.getFunction('My-Test-Function').then( + res => { + fail('Failed to delete hook'); + fail(res); done(); return Promise.resolve(); - }, (err) => { + }, + err => { expect(err.code).toBe(143); - expect(err.message).toBe("no function named: My-Test-Function is defined") + expect(err.message).toBe( + 'no function named: My-Test-Function is defined' + ); done(); return Promise.resolve(); - }) + } + ); }) .catch(error => { jfail(error); done(); - }) + }); }); - it("should CRUD a trigger registration", (done) => { + it('should CRUD a trigger registration', done => { // Create - Parse.Hooks.createTrigger("MyClass","beforeDelete", "http://someurl").then((res) => { - expect(res.className).toBe("MyClass"); - expect(res.triggerName).toBe("beforeDelete"); - expect(res.url).toBe("http://someurl") - // Find - return Parse.Hooks.getTrigger("MyClass","beforeDelete"); - }, (err) => { - fail(err); - done(); - }).then((res) => { - expect(res).not.toBe(null); - expect(res).not.toBe(undefined); - expect(res.objectId).toBeUndefined(); - expect(res.url).toBe("http://someurl"); - // delete - return Parse.Hooks.updateTrigger("MyClass","beforeDelete", "http://anotherurl"); - }, (err) => { - jfail(err); - done(); - }).then((res) => { - expect(res.className).toBe("MyClass"); - expect(res.url).toBe("http://anotherurl") - expect(res.objectId).toBeUndefined(); - - return Parse.Hooks.removeTrigger("MyClass","beforeDelete"); - }, (err) => { - jfail(err); - done(); - }).then(() => { - // Find again! but should be deleted - return Parse.Hooks.getTrigger("MyClass","beforeDelete"); - }, (err) => { - jfail(err); - done(); - }).then(function(){ - fail("should not succeed"); - done(); - }, (err) => { - if (err) { - expect(err).not.toBe(null); - expect(err).not.toBe(undefined); - expect(err.code).toBe(143); - expect(err.message).toBe("class MyClass does not exist") - } else { - fail('should have errored'); - } - done(); - }); + Parse.Hooks.createTrigger('MyClass', 'beforeDelete', 'http://someurl') + .then( + res => { + expect(res.className).toBe('MyClass'); + expect(res.triggerName).toBe('beforeDelete'); + expect(res.url).toBe('http://someurl'); + // Find + return Parse.Hooks.getTrigger('MyClass', 'beforeDelete'); + }, + err => { + fail(err); + done(); + } + ) + .then( + res => { + expect(res).not.toBe(null); + expect(res).not.toBe(undefined); + expect(res.objectId).toBeUndefined(); + expect(res.url).toBe('http://someurl'); + // delete + return Parse.Hooks.updateTrigger( + 'MyClass', + 'beforeDelete', + 'http://anotherurl' + ); + }, + err => { + jfail(err); + done(); + } + ) + .then( + res => { + expect(res.className).toBe('MyClass'); + expect(res.url).toBe('http://anotherurl'); + expect(res.objectId).toBeUndefined(); + + return Parse.Hooks.removeTrigger('MyClass', 'beforeDelete'); + }, + err => { + jfail(err); + done(); + } + ) + .then( + () => { + // Find again! but should be deleted + return Parse.Hooks.getTrigger('MyClass', 'beforeDelete'); + }, + err => { + jfail(err); + done(); + } + ) + .then( + function() { + fail('should not succeed'); + done(); + }, + err => { + if (err) { + expect(err).not.toBe(null); + expect(err).not.toBe(undefined); + expect(err.code).toBe(143); + expect(err.message).toBe('class MyClass does not exist'); + } else { + fail('should have errored'); + } + done(); + } + ); }); - it("should fail to register hooks without Master Key", (done) => { - request.post(Parse.serverURL + "/hooks/functions", { + it('should fail to register hooks without Master Key', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', headers: { - "X-Parse-Application-Id": Parse.applicationId, - "X-Parse-REST-API-Key": Parse.restKey, + 'X-Parse-Application-Id': Parse.applicationId, }, - body: JSON.stringify({ url: "http://hello.word", functionName: "SomeFunction"}) - }, (err, res, body) => { - body = JSON.parse(body); - expect(body.error).toBe("unauthorized"); + body: JSON.stringify({ + url: 'http://hello.word', + functionName: 'SomeFunction', + }), + }).then(fail, response => { + const body = response.data; + expect(body.error).toBe('unauthorized'); done(); - }) + }); }); - it("should fail trying to create two times the same function", (done) => { - Parse.Hooks.createFunction("my_new_function", "http://url.com") + it('should fail trying to create two times the same function', done => { + Parse.Hooks.createFunction('my_new_function', 'http://url.com') .then(() => new Promise(resolve => setTimeout(resolve, 100))) - .then(() => { - return Parse.Hooks.createFunction("my_new_function", "http://url.com") - }, () => { - fail("should create a new function"); - }).then(() => { - fail("should not be able to create the same function"); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('function name: my_new_function already exits') + .then( + () => { + return Parse.Hooks.createFunction( + 'my_new_function', + 'http://url.com' + ); + }, + () => { + fail('should create a new function'); } - return Parse.Hooks.removeFunction("my_new_function"); - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); - }) + ) + .then( + () => { + fail('should not be able to create the same function'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe( + 'function name: my_new_function already exits' + ); + } + return Parse.Hooks.removeFunction('my_new_function'); + } + ) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it("should fail trying to create two times the same trigger", (done) => { - Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com").then(() => { - return Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com") - }, () => { - fail("should create a new trigger"); - }).then(() => { - fail("should not be able to create the same trigger"); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('class MyClass already has trigger beforeSave') - } - return Parse.Hooks.removeTrigger("MyClass", "beforeSave"); - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); - }) + it('should fail trying to create two times the same trigger', done => { + Parse.Hooks.createTrigger('MyClass', 'beforeSave', 'http://url.com') + .then( + () => { + return Parse.Hooks.createTrigger( + 'MyClass', + 'beforeSave', + 'http://url.com' + ); + }, + () => { + fail('should create a new trigger'); + } + ) + .then( + () => { + fail('should not be able to create the same trigger'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe( + 'class MyClass already has trigger beforeSave' + ); + } + return Parse.Hooks.removeTrigger('MyClass', 'beforeSave'); + } + ) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it("should fail trying to update a function that don't exist", (done) => { - Parse.Hooks.updateFunction("A_COOL_FUNCTION", "http://url.com").then(() => { - fail("Should not succeed") - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); - } - return Parse.Hooks.getFunction("A_COOL_FUNCTION") - }).then(() => { - fail("the function should not exist"); - done(); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); - } - done(); - }); + it("should fail trying to update a function that don't exist", done => { + Parse.Hooks.updateFunction('A_COOL_FUNCTION', 'http://url.com') + .then( + () => { + fail('Should not succeed'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe( + 'no function named: A_COOL_FUNCTION is defined' + ); + } + return Parse.Hooks.getFunction('A_COOL_FUNCTION'); + } + ) + .then( + () => { + fail('the function should not exist'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe( + 'no function named: A_COOL_FUNCTION is defined' + ); + } + done(); + } + ); }); - it("should fail trying to update a trigger that don't exist", (done) => { - Parse.Hooks.updateTrigger("AClassName","beforeSave", "http://url.com").then(() => { - fail("Should not succeed") - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('class AClassName does not exist'); - } - return Parse.Hooks.getTrigger("AClassName","beforeSave") - }).then(() => { - fail("the function should not exist"); - done(); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.message).toBe('class AClassName does not exist'); - } - done(); - }); + it("should fail trying to update a trigger that don't exist", done => { + Parse.Hooks.updateTrigger('AClassName', 'beforeSave', 'http://url.com') + .then( + () => { + fail('Should not succeed'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('class AClassName does not exist'); + } + return Parse.Hooks.getTrigger('AClassName', 'beforeSave'); + } + ) + .then( + () => { + fail('the function should not exist'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('class AClassName does not exist'); + } + done(); + } + ); }); - - it("should fail trying to create a malformed function", (done) => { - Parse.Hooks.createFunction("MyFunction").then((res) => { - fail(res); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(143); - expect(err.error).toBe("invalid hook declaration"); + it('should fail trying to create a malformed function', done => { + Parse.Hooks.createFunction('MyFunction').then( + res => { + fail(res); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.error).toBe('invalid hook declaration'); + } + done(); } - done(); - }); + ); }); - it("should fail trying to create a malformed function (REST)", (done) => { - request.post(Parse.serverURL + "/hooks/functions", { + it('should fail trying to create a malformed function (REST)', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', headers: { - "X-Parse-Application-Id": Parse.applicationId, - "X-Parse-Master-Key": Parse.masterKey, + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, }, - body: JSON.stringify({ functionName: "SomeFunction"}) - }, (err, res, body) => { - body = JSON.parse(body); - expect(body.error).toBe("invalid hook declaration"); + body: JSON.stringify({ functionName: 'SomeFunction' }), + }).then(fail, response => { + const body = response.data; + expect(body.error).toBe('invalid hook declaration'); expect(body.code).toBe(143); done(); - }) + }); }); - - it("should create hooks and properly preload them", (done) => { - - var promises = []; - for (var i = 0; i < 5; i++) { - promises.push(Parse.Hooks.createTrigger("MyClass" + i, "beforeSave", "http://url.com/beforeSave/" + i)); - promises.push(Parse.Hooks.createFunction("AFunction" + i, "http://url.com/function" + i)); + it('should create hooks and properly preload them', done => { + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push( + Parse.Hooks.createTrigger( + 'MyClass' + i, + 'beforeSave', + 'http://url.com/beforeSave/' + i + ) + ); + promises.push( + Parse.Hooks.createFunction( + 'AFunction' + i, + 'http://url.com/function' + i + ) + ); } - Parse.Promise.when(promises).then(function(){ - for (var i = 0; i < 5; i++) { - // Delete everything from memory, as the server just started - triggers.removeTrigger("beforeSave", "MyClass" + i, Parse.applicationId); - triggers.removeFunction("AFunction" + i, Parse.applicationId); - expect(triggers.getTrigger("MyClass" + i, "beforeSave", Parse.applicationId)).toBeUndefined(); - expect(triggers.getFunction("AFunction" + i, Parse.applicationId)).toBeUndefined(); - } - const hooksController = new HooksController(Parse.applicationId, AppCache.get('test').databaseController); - return hooksController.load() - }, (err) => { - jfail(err); - fail('Should properly create all hooks'); - done(); - }).then(function() { - for (var i = 0; i < 5; i++) { - expect(triggers.getTrigger("MyClass" + i, "beforeSave", Parse.applicationId)).not.toBeUndefined(); - expect(triggers.getFunction("AFunction" + i, Parse.applicationId)).not.toBeUndefined(); - } - done(); - }, (err) => { - jfail(err); - fail('should properly load all hooks'); - done(); - }) + Promise.all(promises) + .then( + function() { + for (let i = 0; i < 5; i++) { + // Delete everything from memory, as the server just started + triggers.removeTrigger( + 'beforeSave', + 'MyClass' + i, + Parse.applicationId + ); + triggers.removeFunction('AFunction' + i, Parse.applicationId); + expect( + triggers.getTrigger( + 'MyClass' + i, + 'beforeSave', + Parse.applicationId + ) + ).toBeUndefined(); + expect( + triggers.getFunction('AFunction' + i, Parse.applicationId) + ).toBeUndefined(); + } + const hooksController = new HooksController( + Parse.applicationId, + AppCache.get('test').databaseController + ); + return hooksController.load(); + }, + err => { + jfail(err); + fail('Should properly create all hooks'); + done(); + } + ) + .then( + function() { + for (let i = 0; i < 5; i++) { + expect( + triggers.getTrigger( + 'MyClass' + i, + 'beforeSave', + Parse.applicationId + ) + ).not.toBeUndefined(); + expect( + triggers.getFunction('AFunction' + i, Parse.applicationId) + ).not.toBeUndefined(); + } + done(); + }, + err => { + jfail(err); + fail('should properly load all hooks'); + done(); + } + ); }); - it("should run the function on the test server", (done) => { - - app.post("/SomeFunction", function(req, res) { - res.json({success:"OK!"}); + it('should run the function on the test server', done => { + app.post('/SomeFunction', function(req, res) { + res.json({ success: 'OK!' }); }); - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL + "/SomeFunction").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - jfail(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - expect(res).toBe("OK!"); - done(); - }, (err) => { - jfail(err); - fail("Should not fail calling a function"); - done(); - }); + Parse.Hooks.createFunction( + 'SOME_TEST_FUNCTION', + hookServerURL + '/SomeFunction' + ) + .then( + function() { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function(res) { + expect(res).toBe('OK!'); + done(); + }, + err => { + jfail(err); + fail('Should not fail calling a function'); + done(); + } + ); }); - it("should run the function on the test server", (done) => { - - app.post("/SomeFunctionError", function(req, res) { - res.json({error: {code: 1337, error: "hacking that one!"}}); + it('should run the function on the test server (error handling)', done => { + app.post('/SomeFunctionError', function(req, res) { + res.json({ error: { code: 1337, error: 'hacking that one!' } }); }); // The function is deleted as the DB is dropped between calls - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL + "/SomeFunctionError").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - jfail(err); - fail("Should not fail creating a function"); - done(); - }).then(function() { - fail("Should not succeed calling that function"); - done(); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(141); - expect(err.message.code).toEqual(1337) - expect(err.message.error).toEqual("hacking that one!"); - } - done(); - }); + Parse.Hooks.createFunction( + 'SOME_TEST_FUNCTION', + hookServerURL + '/SomeFunctionError' + ) + .then( + function() { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function() { + fail('Should not succeed calling that function'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(141); + expect(err.message.code).toEqual(1337); + expect(err.message.error).toEqual('hacking that one!'); + } + done(); + } + ); }); - it("should provide X-Parse-Webhook-Key when defined", (done) => { - app.post("/ExpectingKey", function(req, res) { + it('should provide X-Parse-Webhook-Key when defined', done => { + app.post('/ExpectingKey', function(req, res) { if (req.get('X-Parse-Webhook-Key') === 'hook') { - res.json({success: "correct key provided"}); + res.json({ success: 'correct key provided' }); } else { - res.json({error: "incorrect key provided"}); + res.json({ error: 'incorrect key provided' }); } }); - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL + "/ExpectingKey").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - jfail(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - expect(res).toBe("correct key provided"); - done(); - }, (err) => { - jfail(err); - fail("Should not fail calling a function"); - done(); - }); - }); - - it("should not pass X-Parse-Webhook-Key if not provided", (done) => { - reconfigureServer({ webhookKey: undefined }) - .then(() => { - app.post("/ExpectingKeyAlso", function(req, res) { - if (req.get('X-Parse-Webhook-Key') === 'hook') { - res.json({success: "correct key provided"}); - } else { - res.json({error: "incorrect key provided"}); - } - }); - - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL + "/ExpectingKeyAlso").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { + Parse.Hooks.createFunction( + 'SOME_TEST_FUNCTION', + hookServerURL + '/ExpectingKey' + ) + .then( + function() { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { jfail(err); - fail("Should not fail creating a function"); + fail('Should not fail creating a function'); done(); - }).then(function(){ - fail("Should not succeed calling that function"); + } + ) + .then( + function(res) { + expect(res).toBe('correct key provided'); done(); - }, (err) => { - expect(err).not.toBe(undefined); - expect(err).not.toBe(null); - if (err) { - expect(err.code).toBe(141); - expect(err.message).toEqual("incorrect key provided"); - } + }, + err => { + jfail(err); + fail('Should not fail calling a function'); done(); - }); - }); + } + ); }); + it('should not pass X-Parse-Webhook-Key if not provided', done => { + reconfigureServer({ webhookKey: undefined }).then(() => { + app.post('/ExpectingKeyAlso', function(req, res) { + if (req.get('X-Parse-Webhook-Key') === 'hook') { + res.json({ success: 'correct key provided' }); + } else { + res.json({ error: 'incorrect key provided' }); + } + }); + + Parse.Hooks.createFunction( + 'SOME_TEST_FUNCTION', + hookServerURL + '/ExpectingKeyAlso' + ) + .then( + function() { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function() { + fail('Should not succeed calling that function'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(141); + expect(err.message).toEqual('incorrect key provided'); + } + done(); + } + ); + }); + }); - it("should run the beforeSave hook on the test server", (done) => { - var triggerCount = 0; - app.post("/BeforeSaveSome", function(req, res) { + it('should run the beforeSave hook on the test server', done => { + let triggerCount = 0; + app.post('/BeforeSaveSome', function(req, res) { triggerCount++; - var object = req.body.object; - object.hello = "world"; + const object = req.body.object; + object.hello = 'world'; // Would need parse cloud express to set much more // But this should override the key upon return - res.json({success: object}); + res.json({ success: object }); }); // The function is deleted as the DB is dropped between calls - Parse.Hooks.createTrigger("SomeRandomObject", "beforeSave", hookServerURL + "/BeforeSaveSome").then(function () { - const obj = new Parse.Object("SomeRandomObject"); - return obj.save(); - }).then(function(res) { - expect(triggerCount).toBe(1); - return res.fetch(); - }).then(function(res) { - expect(res.get("hello")).toEqual("world"); - done(); - }).fail((err) => { - jfail(err); - fail("Should not fail creating a function"); - done(); - }); + Parse.Hooks.createTrigger( + 'SomeRandomObject', + 'beforeSave', + hookServerURL + '/BeforeSaveSome' + ) + .then(function() { + const obj = new Parse.Object('SomeRandomObject'); + return obj.save(); + }) + .then(function(res) { + expect(triggerCount).toBe(1); + return res.fetch(); + }) + .then(function(res) { + expect(res.get('hello')).toEqual('world'); + done(); + }) + .catch(err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + }); }); - it("beforeSave hooks should correctly handle responses containing entire object", (done) => { - app.post("/BeforeSaveSome2", function(req, res) { - var object = Parse.Object.fromJSON(req.body.object); - object.set('hello', "world"); - res.json({success: object}); - }); - Parse.Hooks.createTrigger("SomeRandomObject2", "beforeSave", hookServerURL + "/BeforeSaveSome2").then(function(){ - const obj = new Parse.Object("SomeRandomObject2"); - return obj.save(); - }).then(function(res) { - return res.save(); - }).then(function(res) { - expect(res.get("hello")).toEqual("world"); - done(); - }).fail((err) => { - fail(`Should not fail: ${JSON.stringify(err)}`); - done(); + it('beforeSave hooks should correctly handle responses containing entire object', done => { + app.post('/BeforeSaveSome2', function(req, res) { + const object = Parse.Object.fromJSON(req.body.object); + object.set('hello', 'world'); + res.json({ success: object }); }); + Parse.Hooks.createTrigger( + 'SomeRandomObject2', + 'beforeSave', + hookServerURL + '/BeforeSaveSome2' + ) + .then(function() { + const obj = new Parse.Object('SomeRandomObject2'); + return obj.save(); + }) + .then(function(res) { + return res.save(); + }) + .then(function(res) { + expect(res.get('hello')).toEqual('world'); + done(); + }) + .catch(err => { + fail(`Should not fail: ${JSON.stringify(err)}`); + done(); + }); }); - it("should run the afterSave hook on the test server", (done) => { - var triggerCount = 0; - var newObjectId; - app.post("/AfterSaveSome", function(req, res) { + it('should run the afterSave hook on the test server', done => { + let triggerCount = 0; + let newObjectId; + app.post('/AfterSaveSome', function(req, res) { triggerCount++; - var obj = new Parse.Object("AnotherObject"); - obj.set("foo", "bar"); - obj.save().then(function(obj){ + const obj = new Parse.Object('AnotherObject'); + obj.set('foo', 'bar'); + obj.save().then(function(obj) { newObjectId = obj.id; - res.json({success: {}}); - }) + res.json({ success: {} }); + }); }); // The function is deleted as the DB is dropped between calls - Parse.Hooks.createTrigger("SomeRandomObject", "afterSave", hookServerURL + "/AfterSaveSome").then(function(){ - const obj = new Parse.Object("SomeRandomObject"); - return obj.save(); - }).then(function() { - var promise = new Parse.Promise(); - // Wait a bit here as it's an after save - setTimeout(() => { - expect(triggerCount).toBe(1); - new Parse.Query("AnotherObject") - .get(newObjectId) - .then((r) => promise.resolve(r)); - }, 500); - return promise; - }).then(function(res){ - expect(res.get("foo")).toEqual("bar"); - done(); - }).fail((err) => { - jfail(err); - fail("Should not fail creating a function"); - done(); - }); + Parse.Hooks.createTrigger( + 'SomeRandomObject', + 'afterSave', + hookServerURL + '/AfterSaveSome' + ) + .then(function() { + const obj = new Parse.Object('SomeRandomObject'); + return obj.save(); + }) + .then(function() { + return new Promise(resolve => { + setTimeout(() => { + expect(triggerCount).toBe(1); + new Parse.Query('AnotherObject') + .get(newObjectId) + .then(r => resolve(r)); + }, 500); + }); + }) + .then(function(res) { + expect(res.get('foo')).toEqual('bar'); + done(); + }) + .catch(err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + }); + }); +}); + +describe('triggers', () => { + it('should produce a proper request object with context in beforeSave', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = { + originalKey: 'original', + }; + const req = triggers.getRequestObject( + triggers.Types.beforeSave, + master, + {}, + {}, + config, + context + ); + expect(req.context.originalKey).toBe('original'); + req.context = { + key: 'value', + }; + expect(context.key).toBe(undefined); + req.context = { + key: 'newValue', + }; + expect(context.key).toBe(undefined); + }); + + it('should produce a proper request object with context in afterSave', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = {}; + const req = triggers.getRequestObject( + triggers.Types.afterSave, + master, + {}, + {}, + config, + context + ); + expect(req.context).not.toBeUndefined(); + }); + + it('should not set context on beforeFind', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = {}; + const req = triggers.getRequestObject( + triggers.Types.beforeFind, + master, + {}, + {}, + config, + context + ); + expect(req.context).toBeUndefined(); }); }); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 05de290825..920a28bf1a 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -2,96 +2,130 @@ // These tests check the Installations functionality of the REST API. // Ported from installation_collection_test.go -const auth = require('../src/Auth'); -const Config = require('../src/Config'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); const Parse = require('parse/node').Parse; -const rest = require('../src/rest'); -const request = require("request"); +const rest = require('../lib/rest'); +const request = require('../lib/request'); let config; let database; -const defaultColumns = require('../src/Controllers/SchemaController').defaultColumns; +const defaultColumns = require('../lib/Controllers/SchemaController') + .defaultColumns; const delay = function delay(delay) { return new Promise(resolve => setTimeout(resolve, delay)); -} +}; -const installationSchema = { fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation) }; +const installationSchema = { + fields: Object.assign( + {}, + defaultColumns._Default, + defaultColumns._Installation + ), +}; describe('Installations', () => { - beforeEach(() => { config = Config.get('test'); database = config.database; }); - it('creates an android installation with ids', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device + it('creates an android installation with ids', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.installationId).toEqual(installId); expect(obj.deviceType).toEqual(device); done(); - }).catch((error) => { console.log(error); jfail(error); done(); }); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('creates an ios installation with ids', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'ios'; - var input = { - 'deviceToken': t, - 'deviceType': device + it('creates an ios installation with ids', done => { + const t = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const device = 'ios'; + const input = { + deviceToken: t, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.deviceToken).toEqual(t); expect(obj.deviceType).toEqual(device); done(); - }).catch((error) => { console.log(error); jfail(error); done(); }); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('creates an embedded installation with ids', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'embedded'; - var input = { - 'installationId': installId, - 'deviceType': device + it('creates an embedded installation with ids', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'embedded'; + const input = { + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.installationId).toEqual(installId); expect(obj.deviceType).toEqual(device); done(); - }).catch((error) => { console.log(error); jfail(error); done(); }); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('creates an android installation with all fields', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device, - 'channels': ['foo', 'bar'] + it('creates an android installation with all fields', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.installationId).toEqual(installId); expect(obj.deviceType).toEqual(device); expect(typeof obj.channels).toEqual('object'); @@ -99,22 +133,31 @@ describe('Installations', () => { expect(obj.channels[0]).toEqual('foo'); expect(obj.channels[1]).toEqual('bar'); done(); - }).catch((error) => { console.log(error); jfail(error); done(); }); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('creates an ios installation with all fields', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'ios'; - var input = { - 'deviceToken': t, - 'deviceType': device, - 'channels': ['foo', 'bar'] + it('creates an ios installation with all fields', done => { + const t = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const device = 'ios'; + const input = { + deviceToken: t, + deviceType: device, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.deviceToken).toEqual(t); expect(obj.deviceType).toEqual(device); expect(typeof obj.channels).toEqual('object'); @@ -122,117 +165,145 @@ describe('Installations', () => { expect(obj.channels[0]).toEqual('foo'); expect(obj.channels[1]).toEqual('bar'); done(); - }).catch((error) => { console.log(error); jfail(error); done(); }); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('should properly fail queying installations', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device + it('should properly fail queying installations', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { const query = new Parse.Query(Parse.Installation); - return query.find() - }).then(() => { + return query.find(); + }) + .then(() => { fail('Should not succeed!'); done(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toBe(119); - expect(error.message).toBe('Clients aren\'t allowed to perform the find operation on the installation collection.') + expect(error.message).toBe( + "Clients aren't allowed to perform the find operation on the installation collection." + ); done(); }); }); - it('should properly queying installations with masterKey', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device + it('should properly queying installations with masterKey', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { const query = new Parse.Query(Parse.Installation); - return query.find({useMasterKey: true}); - }).then((results) => { + return query.find({ useMasterKey: true }); + }) + .then(results => { expect(results.length).toEqual(1); - var obj = results[0].toJSON(); + const obj = results[0].toJSON(); expect(obj.installationId).toEqual(installId); expect(obj.deviceType).toEqual(device); done(); - }).catch(() => { + }) + .catch(() => { fail('Should not fail'); done(); }); }); - it('fails with missing ids', (done) => { - var input = { - 'deviceType': 'android', - 'channels': ['foo', 'bar'] + it('fails with missing ids', done => { + const input = { + deviceType: 'android', + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { fail('Should not have been able to create an Installation.'); done(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toEqual(135); done(); }); }); - it('fails for android with missing type', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'channels': ['foo', 'bar'] + it('fails for android with missing type', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { fail('Should not have been able to create an Installation.'); done(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toEqual(135); done(); }); }); - it('creates an object with custom fields', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios', - 'channels': ['foo', 'bar'], - 'custom': 'allowed' + it('creates an object with custom fields', done => { + const t = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + deviceToken: t, + deviceType: 'ios', + channels: ['foo', 'bar'], + custom: 'allowed', }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(obj.custom).toEqual('allowed'); done(); - }).catch((error) => { console.log(error); }); + }) + .catch(error => { + console.log(error); + }); }); // Note: did not port test 'TestObjectIDForIdentifiers' - it('merging when installationId already exists', (done) => { - var installId1 = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios', - 'installationId': installId1, - 'channels': ['foo', 'bar'] + it('merging when installationId already exists', done => { + const installId1 = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + deviceToken: t, + deviceType: 'ios', + installationId: installId1, + channels: ['foo', 'bar'], }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); firstObject = results[0]; @@ -241,7 +312,9 @@ describe('Installations', () => { input['foo'] = 'bar'; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); secondObject = results[0]; @@ -249,35 +322,49 @@ describe('Installations', () => { expect(secondObject.channels.length).toEqual(2); expect(secondObject.foo).toEqual('bar'); done(); - }).catch((error) => { console.log(error); }); + }) + .catch(error => { + console.log(error); + }); }); - it('merging when two objects both only have one id', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input1 = { - 'installationId': installId, - 'deviceType': 'ios' + it('merging when two objects both only have one id', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input1 = { + installationId: installId, + deviceType: 'ios', }; - var input2 = { - 'deviceToken': t, - 'deviceType': 'ios' + const input2 = { + deviceToken: t, + deviceType: 'ios', }; - var input3 = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios' + const input3 = { + deviceToken: t, + installationId: installId, + deviceType: 'ios', }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input1) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input1) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); firstObject = results[0]; - return rest.create(config, auth.nobody(config), '_Installation', input2); + return rest.create( + config, + auth.nobody(config), + '_Installation', + input2 + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(2); if (results[0]['_id'] == firstObject._id) { @@ -285,196 +372,301 @@ describe('Installations', () => { } else { secondObject = results[0]; } - return rest.create(config, auth.nobody(config), '_Installation', input3); + return rest.create( + config, + auth.nobody(config), + '_Installation', + input3 + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0]['_id']).toEqual(secondObject._id); done(); - }).catch((error) => { + }) + .catch(error => { jfail(error); done(); }); }); - xit('creating multiple devices with same device token works', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var installId3 = '33333333-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceType': 'ios', - 'deviceToken': t + xit('creating multiple devices with same device token works', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const installId3 = '33333333-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input = { + installationId: installId1, + deviceType: 'ios', + deviceToken: t, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { input.installationId = installId2; return rest.create(config, auth.nobody(config), '_Installation', input); - }).then(() => { + }) + .then(() => { input.installationId = installId3; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', {installationId: installId1}, installationSchema, {})) + .then(() => + database.adapter.find( + '_Installation', + { installationId: installId1 }, + installationSchema, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); - return database.adapter.find('_Installation', {installationId: installId2}, installationSchema, {}); - }).then(results => { + return database.adapter.find( + '_Installation', + { installationId: installId2 }, + installationSchema, + {} + ); + }) + .then(results => { expect(results.length).toEqual(1); - return database.adapter.find('_Installation', {installationId: installId3}, installationSchema, {}); - }).then((results) => { + return database.adapter.find( + '_Installation', + { installationId: installId3 }, + installationSchema, + {} + ); + }) + .then(results => { expect(results.length).toEqual(1); done(); - }).catch((error) => { console.log(error); }); + }) + .catch(error => { + console.log(error); + }); }); - it('updating with new channels', (done) => { - var input = { + it('updating with new channels', done => { + const input = { installationId: '12345678-abcd-abcd-abcd-123456789abc', deviceType: 'android', - channels: ['foo', 'bar'] + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - var objectId = results[0].objectId; - var update = { - 'channels': ['baz'] + const objectId = results[0].objectId; + const update = { + channels: ['baz'], }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId }, update); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId }, + update + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].channels.length).toEqual(1); expect(results[0].channels[0]).toEqual('baz'); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update android fails with new installation id', (done) => { - var installId1 = '12345678-abcd-abcd-abcd-123456789abc'; - var installId2 = '87654321-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId1, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] + it('update android fails with new installation id', done => { + const installId1 = '12345678-abcd-abcd-abcd-123456789abc'; + const installId2 = '87654321-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId1, + deviceType: 'android', + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - input = { 'installationId': installId2 }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); - }).then(() => { + input = { installationId: installId2 }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { fail('Updating the installation should have failed.'); done(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toEqual(136); done(); }); }); - it('update ios fails with new deviceToken and no installationId', (done) => { - var a = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var b = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'deviceToken': a, - 'deviceType': 'ios', - 'channels': ['foo', 'bar'] + it('update ios fails with new deviceToken and no installationId', done => { + const a = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const b = + '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + let input = { + deviceToken: a, + deviceType: 'ios', + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); - input = { 'deviceToken': b }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); - }).then(() => { + input = { deviceToken: b }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { fail('Updating the installation should have failed.'); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toEqual(136); done(); }); }); - it('update ios updates device token', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var u = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'installationId': installId, - 'deviceType': 'ios', - 'deviceToken': t, - 'channels': ['foo', 'bar'] + it('update ios updates device token', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const u = + '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + let input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'installationId': installId, - 'deviceToken': u, - 'deviceType': 'ios' + installationId: installId, + deviceToken: u, + deviceType: 'ios', }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].deviceToken).toEqual(u); done(); - }).catch(err => { + }) + .catch(err => { jfail(err); done(); - }) + }); }); - it('update fails to change deviceType', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] + it('update fails to change deviceType', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId, + deviceType: 'android', + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'deviceType': 'ios' + deviceType: 'ios', }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); - }).then(() => { + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { fail('Should not have been able to update Installation.'); done(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toEqual(136); done(); }); }); - it('update android with custom field', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] + it('update android with custom field', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId, + deviceType: 'android', + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'custom': 'allowed' + custom: 'allowed', }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0]['custom']).toEqual('allowed'); @@ -482,226 +674,324 @@ describe('Installations', () => { }); }); - it('update android device token with duplicate device token', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'android' + it('update android device token with duplicate device token', async () => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + let input = { + installationId: installId1, + deviceToken: t, + deviceType: 'android', }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input = { - 'installationId': installId2, - 'deviceType': 'android' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) - .then(results => { - firstObject = results[0]; - expect(results.length).toEqual(1); - return database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {}); - }).then(results => { - expect(results.length).toEqual(1); - secondObject = results[0]; - // Update second installation to conflict with first installation - input = { - 'objectId': secondObject.objectId, - 'deviceToken': t - }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: secondObject.objectId }, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) - .then(results => { - // The first object should have been deleted - expect(results.length).toEqual(0); - done(); - }).catch(error => { - jfail(error); - done(); - }); + await rest.create(config, auth.nobody(config), '_Installation', input); + + input = { + installationId: installId2, + deviceType: 'android', + }; + await rest.create(config, auth.nobody(config), '_Installation', input); + await delay(100); + + let results = await database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId1 }, + {} + ); + expect(results.length).toEqual(1); + const firstObject = results[0]; + + results = await database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId2 }, + {} + ); + expect(results.length).toEqual(1); + const secondObject = results[0]; + + // Update second installation to conflict with first installation + input = { + objectId: secondObject.objectId, + deviceToken: t, + }; + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: secondObject.objectId }, + input + ); + await delay(100); + results = await database.adapter.find( + '_Installation', + installationSchema, + { objectId: firstObject.objectId }, + {} + ); + expect(results.length).toEqual(0); }); - it('update ios device token with duplicate device token', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'ios' + it('update ios device token with duplicate device token', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId1, + deviceToken: t, + deviceType: 'ios', }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { input = { - 'installationId': installId2, - 'deviceType': 'ios' + installationId: installId2, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) .then(() => delay(100)) - .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) - .then((results) => { + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId1 }, + {} + ) + ) + .then(results => { expect(results.length).toEqual(1); firstObject = results[0]; }) .then(() => delay(100)) - .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId2 }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); secondObject = results[0]; // Update second installation to conflict with first installation id input = { - 'installationId': installId2, - 'deviceToken': t + installationId: installId2, + deviceToken: t, }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: secondObject.objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: secondObject.objectId }, + input + ); }) .then(() => delay(100)) - .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: firstObject.objectId }, + {} + ) + ) .then(results => { - // The first object should have been deleted + // The first object should have been deleted expect(results.length).toEqual(0); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - xit('update ios device token with duplicate token different app', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'ios', - 'appIdentifier': 'foo' + xit('update ios device token with duplicate token different app', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input = { + installationId: installId1, + deviceToken: t, + deviceType: 'ios', + appIdentifier: 'foo', }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { input.installationId = installId2; input.appIdentifier = 'bar'; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { - // The first object should have been deleted during merge + // The first object should have been deleted during merge expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId2); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update ios token and channels', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' + it('update ios token and channels', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'deviceToken': t, - 'channels': [] + deviceToken: t, + channels: [], }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); expect(results[0].deviceToken).toEqual(t); expect(results[0].channels.length).toEqual(0); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update ios linking two existing objects', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' + it('update ios linking two existing objects', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { input = { - 'deviceToken': t, - 'deviceType': 'ios' + deviceToken: t, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { deviceToken: t }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios' + deviceToken: t, + installationId: installId, + deviceType: 'ios', }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); expect(results[0].deviceToken).toEqual(t); expect(results[0].deviceType).toEqual('ios'); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update is linking two existing objects w/ increment', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' + it('update is linking two existing objects w/ increment', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { input = { - 'deviceToken': t, - 'deviceType': 'ios' + deviceToken: t, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { deviceToken: t }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios', - 'score': { - '__op': 'Increment', - 'amount': 1 - } + deviceToken: t, + installationId: installId, + deviceType: 'ios', + score: { + __op: 'Increment', + amount: 1, + }, }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: results[0].objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); @@ -709,104 +999,155 @@ describe('Installations', () => { expect(results[0].deviceType).toEqual('ios'); expect(results[0].score).toEqual(1); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update is linking two existing with installation id', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' + it('update is linking two existing with installation id', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', }; - var installObj; - var tokenObj; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + let installObj; + let tokenObj; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); installObj = results[0]; input = { - 'deviceToken': t, - 'deviceType': 'ios' + deviceToken: t, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { deviceToken: t }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); tokenObj = results[0]; input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios' + installationId: installId, + deviceToken: t, + deviceType: 'ios', }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: installObj.objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: installObj.objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: tokenObj.objectId }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); expect(results[0].deviceToken).toEqual(t); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('update is linking two existing with installation id w/ op', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' + it('update is linking two existing with installation id w/ op', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', }; - var installObj; - var tokenObj; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + let installObj; + let tokenObj; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); installObj = results[0]; input = { - 'deviceToken': t, - 'deviceType': 'ios' + deviceToken: t, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { deviceToken: t }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); tokenObj = results[0]; input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios', - 'score': { - '__op': 'Increment', - 'amount': 1 - } + installationId: installId, + deviceToken: t, + deviceType: 'ios', + score: { + __op: 'Increment', + amount: 1, + }, }; - return rest.update(config, auth.nobody(config), '_Installation', { objectId: installObj.objectId }, input); + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: installObj.objectId }, + input + ); }) - .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: tokenObj.objectId }, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].installationId).toEqual(installId); expect(results[0].deviceToken).toEqual(t); expect(results[0].score).toEqual(1); done(); - }).catch(error => { + }) + .catch(error => { jfail(error); done(); }); }); - it('ios merge existing same token no installation id', (done) => { + it('ios merge existing same token no installation id', done => { // Test creating installation when there is an existing object with the // same device token but no installation ID. This is possible when // developers import device tokens from another push provider; the import @@ -817,24 +1158,30 @@ describe('Installations', () => { // imported installation, then we should reuse the existing installation // object in case the developer already added additional fields via Data // Browser or REST API (e.g. channel targeting info). - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios' + const t = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + deviceToken: t, + deviceType: 'ios', }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios' + installationId: installId, + deviceToken: t, + deviceType: 'ios', }; return rest.create(config, auth.nobody(config), '_Installation', input); }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(() => + database.adapter.find('_Installation', installationSchema, {}, {}) + ) .then(results => { expect(results.length).toEqual(1); expect(results[0].deviceToken).toEqual(t); @@ -852,20 +1199,23 @@ describe('Installations', () => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const device = 'android'; const input = { - 'installationId': installId, - 'deviceType': device + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(createResult => { const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-REST-API-Key': 'rest', }; - request.get({ + return request({ headers: headers, - url: 'http://localhost:8378/1/installations/' + createResult.response.objectId, - json: true, - }, (error, response, body) => { + url: + 'http://localhost:8378/1/installations/' + + createResult.response.objectId, + }).then(response => { + const body = response.data; expect(body.objectId).toEqual(createResult.response.objectId); done(); }); @@ -881,25 +1231,28 @@ describe('Installations', () => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const device = 'android'; const input = { - 'installationId': installId, - 'deviceType': device + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(() => { const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': installId + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': installId, }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/classes/_Installation', json: true, body: { - date: new Date() - } - }, (error, response, body) => { - expect(response.statusCode).toBe(200); + date: new Date(), + }, + }).then(response => { + const body = response.data; + expect(response.status).toBe(200); expect(body.updatedAt).not.toBeUndefined(); done(); }); @@ -915,19 +1268,24 @@ describe('Installations', () => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const device = 'android'; const input = { - 'installationId': installId, - 'deviceType': device + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) + rest + .create(config, auth.nobody(config), '_Installation', input) .then(createResult => { - const installationObj = Parse.Installation.createWithoutData(createResult.response.objectId); + const installationObj = Parse.Installation.createWithoutData( + createResult.response.objectId + ); installationObj.set('customField', 'custom value'); - return installationObj.save(null, {useMasterKey: true}); - }).then(updateResult => { + return installationObj.save(null, { useMasterKey: true }); + }) + .then(updateResult => { expect(updateResult).not.toBeUndefined(); expect(updateResult.get('customField')).toEqual('custom value'); done(); - }).catch(error => { + }) + .catch(error => { console.log(error); fail('failed'); done(); @@ -938,49 +1296,73 @@ describe('Installations', () => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const device = 'android'; const input = { - 'installationId': installId, - 'deviceType': device + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input).then(() => { - const query = new Parse.Query(Parse.Installation); - query.equalTo('installationId', installId); - query.first({useMasterKey: true}).then((installation) => { - return installation.save({ - key: 'value' - }, {useMasterKey: true}); - }).then(() => { - done(); - }, (err) => { - jfail(err) - done(); + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + const query = new Parse.Query(Parse.Installation); + query.equalTo('installationId', installId); + query + .first({ useMasterKey: true }) + .then(installation => { + return installation.save( + { + key: 'value', + }, + { useMasterKey: true } + ); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - }); }); it('should properly reject updating installationId', done => { const installId = '12345678-abcd-abcd-abcd-123456789abc'; const device = 'android'; const input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input).then(() => { - const query = new Parse.Query(Parse.Installation); - query.equalTo('installationId', installId); - query.first({useMasterKey: true}).then((installation) => { - return installation.save({ - key: 'value', - installationId: '22222222-abcd-abcd-abcd-123456789abc' - }, {useMasterKey: true}); - }).then(() => { - fail('should not succeed'); - done(); - }, (err) => { - expect(err.code).toBe(136); - expect(err.message).toBe('installationId may not be changed in this operation'); - done(); - }); - }); + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + const query = new Parse.Query(Parse.Installation); + query.equalTo('installationId', installId); + query + .first({ useMasterKey: true }) + .then(installation => { + return installation.save( + { + key: 'value', + installationId: '22222222-abcd-abcd-abcd-123456789abc', + }, + { useMasterKey: true } + ); + }) + .then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(136); + expect(err.message).toBe( + 'installationId may not be changed in this operation' + ); + done(); + } + ); + }); }); // TODO: Look at additional tests from installation_collection_test.go:882 diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js new file mode 100644 index 0000000000..fa83588b9d --- /dev/null +++ b/spec/ParseLiveQuery.spec.js @@ -0,0 +1,72 @@ +'use strict'; + +describe('ParseLiveQuery', function() { + it('can subscribe to query', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', async object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('handle invalid websocket payload length', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + websocketTimeout: 100, + }); + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + + // All control frames must have a payload length of 125 bytes or less. + // https://tools.ietf.org/html/rfc6455#section-5.5 + // + // 0x89 = 10001001 = ping + // 0xfe = 11111110 = first bit is masking the remaining 7 are 1111110 or 126 the payload length + // https://tools.ietf.org/html/rfc6455#section-5.2 + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.socket._socket.write(Buffer.from([0x89, 0xfe])); + + subscription.on('update', async object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + // Wait for Websocket timeout to reconnect + setTimeout(async () => { + object.set({ foo: 'bar' }); + await object.save(); + }, 1000); + }); + + afterEach(async function(done) { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.close(); + // Wait for live query client to disconnect + setTimeout(() => { + done(); + }, 1000); + }); +}); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index 86a68b5694..63ac0a0505 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -1,19 +1,27 @@ -var Parse = require('parse/node'); -var ParseLiveQueryServer = require('../src/LiveQuery/ParseLiveQueryServer').ParseLiveQueryServer; -var ParseServer = require('../src/ParseServer').default; +const Parse = require('parse/node'); +const ParseLiveQueryServer = require('../lib/LiveQuery/ParseLiveQueryServer') + .ParseLiveQueryServer; +const ParseServer = require('../lib/ParseServer').default; +const LiveQueryController = require('../lib/Controllers/LiveQueryController') + .LiveQueryController; +const auth = require('../lib/Auth'); // Global mock info -var queryHashValue = 'hash'; -var testUserId = 'userId'; -var testClassName = 'TestObject'; +const queryHashValue = 'hash'; +const testUserId = 'userId'; +const testClassName = 'TestObject'; describe('ParseLiveQueryServer', function() { beforeEach(function(done) { // Mock ParseWebSocketServer - var mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); - jasmine.mockLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer', mockParseWebSocketServer); + const mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); + jasmine.mockLibrary( + '../lib/LiveQuery/ParseWebSocketServer', + 'ParseWebSocketServer', + mockParseWebSocketServer + ); // Mock Client - var mockClient = function(id, socket, hasMasterKey) { + const mockClient = function(id, socket, hasMasterKey) { this.pushConnect = jasmine.createSpy('pushConnect'); this.pushSubscribe = jasmine.createSpy('pushSubscribe'); this.pushUnsubscribe = jasmine.createSpy('pushUnsubscribe'); @@ -26,61 +34,89 @@ describe('ParseLiveQueryServer', function() { this.getSubscriptionInfo = jasmine.createSpy('getSubscriptionInfo'); this.deleteSubscriptionInfo = jasmine.createSpy('deleteSubscriptionInfo'); this.hasMasterKey = hasMasterKey; - } + }; mockClient.pushError = jasmine.createSpy('pushError'); - jasmine.mockLibrary('../src/LiveQuery/Client', 'Client', mockClient); + jasmine.mockLibrary('../lib/LiveQuery/Client', 'Client', mockClient); // Mock Subscription - var mockSubscriotion = function() { + const mockSubscriotion = function() { this.addClientSubscription = jasmine.createSpy('addClientSubscription'); - this.deleteClientSubscription = jasmine.createSpy('deleteClientSubscription'); - } - jasmine.mockLibrary('../src/LiveQuery/Subscription', 'Subscription', mockSubscriotion); + this.deleteClientSubscription = jasmine.createSpy( + 'deleteClientSubscription' + ); + }; + jasmine.mockLibrary( + '../lib/LiveQuery/Subscription', + 'Subscription', + mockSubscriotion + ); // Mock queryHash - var mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue(queryHashValue); - jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'queryHash', mockQueryHash); + const mockQueryHash = jasmine + .createSpy('matchesQuery') + .and.returnValue(queryHashValue); + jasmine.mockLibrary( + '../lib/LiveQuery/QueryTools', + 'queryHash', + mockQueryHash + ); // Mock matchesQuery - var mockMatchesQuery = jasmine.createSpy('matchesQuery').and.returnValue(true); - jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'matchesQuery', mockMatchesQuery); - // Mock tv4 - var mockValidate = function() { - return true; - } - jasmine.mockLibrary('tv4', 'validate', mockValidate); + const mockMatchesQuery = jasmine + .createSpy('matchesQuery') + .and.returnValue(true); + jasmine.mockLibrary( + '../lib/LiveQuery/QueryTools', + 'matchesQuery', + mockMatchesQuery + ); // Mock ParsePubSub - var mockParsePubSub = { + const mockParsePubSub = { createPublisher: function() { return { publish: jasmine.createSpy('publish'), - on: jasmine.createSpy('on') - } + on: jasmine.createSpy('on'), + }; }, createSubscriber: function() { return { subscribe: jasmine.createSpy('subscribe'), - on: jasmine.createSpy('on') - } - } + on: jasmine.createSpy('on'), + }; + }, }; - jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); - // Make mock SessionTokenCache - var mockSessionTokenCache = function(){ - this.getUserId = function(sessionToken){ + jasmine.mockLibrary( + '../lib/LiveQuery/ParsePubSub', + 'ParsePubSub', + mockParsePubSub + ); + spyOn(auth, 'getAuthForSessionToken').and.callFake( + ({ sessionToken, cacheController }) => { if (typeof sessionToken === 'undefined') { - return Parse.Promise.as(undefined); + return Promise.reject(); } if (sessionToken === null) { - return Parse.Promise.error(); + return Promise.reject(); } - return Parse.Promise.as(testUserId); - }; - }; - jasmine.mockLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache', mockSessionTokenCache); + if (sessionToken === 'pleaseThrow') { + return Promise.reject(); + } + if (sessionToken === 'invalid') { + return Promise.reject( + new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'invalid session token' + ) + ); + } + return Promise.resolve( + new auth.Auth({ cacheController, user: { id: testUserId } }) + ); + } + ); done(); }); it('can be initialized', function() { - var httpServer = {}; - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, httpServer); + const httpServer = {}; + const parseLiveQueryServer = new ParseLiveQueryServer(httpServer); expect(parseLiveQueryServer.clientId).toBeUndefined(); expect(parseLiveQueryServer.clients.size).toBe(0); @@ -88,8 +124,11 @@ describe('ParseLiveQueryServer', function() { }); it('can be initialized from ParseServer', function() { - var httpServer = {}; - var parseLiveQueryServer = ParseServer.createLiveQueryServer(httpServer, {}); + const httpServer = {}; + const parseLiveQueryServer = ParseServer.createLiveQueryServer( + httpServer, + {} + ); expect(parseLiveQueryServer.clientId).toBeUndefined(); expect(parseLiveQueryServer.clients.size).toBe(0); @@ -97,8 +136,8 @@ describe('ParseLiveQueryServer', function() { }); it('can be initialized from ParseServer without httpServer', function(done) { - var parseLiveQueryServer = ParseServer.createLiveQueryServer(undefined, { - port: 22345 + const parseLiveQueryServer = ParseServer.createLiveQueryServer(undefined, { + port: 22345, }); expect(parseLiveQueryServer.clientId).toBeUndefined(); @@ -107,111 +146,216 @@ describe('ParseLiveQueryServer', function() { parseLiveQueryServer.server.close(done); }); - it('can be initialized through ParseServer without liveQueryServerOptions', function(done) { - var parseServer = ParseServer.start({ - appId: 'hello', - masterKey: 'world', - port: 22345, - mountPath: '/1', - serverURL: 'http://localhost:12345/1', - liveQuery: { - classNames: ['Yolo'] - }, - startLiveQueryServer: true + describe_only_db('mongo')('initialization', () => { + it('can be initialized through ParseServer without liveQueryServerOptions', function(done) { + const parseServer = ParseServer.start({ + appId: 'hello', + masterKey: 'world', + port: 22345, + mountPath: '/1', + serverURL: 'http://localhost:12345/1', + liveQuery: { + classNames: ['Yolo'], + }, + startLiveQueryServer: true, + serverStartComplete: () => { + expect(parseServer.liveQueryServer).not.toBeUndefined(); + expect(parseServer.liveQueryServer.server).toBe(parseServer.server); + parseServer.server.close(done); + }, + }); }); - expect(parseServer.liveQueryServer).not.toBeUndefined(); - expect(parseServer.liveQueryServer.server).toBe(parseServer.server); - parseServer.server.close(done); + it('can be initialized through ParseServer with liveQueryServerOptions', function(done) { + const parseServer = ParseServer.start({ + appId: 'hello', + masterKey: 'world', + port: 22346, + mountPath: '/1', + serverURL: 'http://localhost:12345/1', + liveQuery: { + classNames: ['Yolo'], + }, + liveQueryServerOptions: { + port: 22347, + }, + serverStartComplete: () => { + expect(parseServer.liveQueryServer).not.toBeUndefined(); + expect(parseServer.liveQueryServer.server).not.toBe( + parseServer.server + ); + parseServer.liveQueryServer.server.close( + parseServer.server.close.bind(parseServer.server, done) + ); + }, + }); + }); }); - it('can be initialized through ParseServer with liveQueryServerOptions', function(done) { - var parseServer = ParseServer.start({ - appId: 'hello', - masterKey: 'world', - port: 22346, - mountPath: '/1', - serverURL: 'http://localhost:12345/1', - liveQuery: { - classNames: ['Yolo'] - }, - liveQueryServerOptions: { - port: 22347, + it('properly passes the CLP to afterSave/afterDelete hook', function(done) { + function setPermissionsOnClass(className, permissions, doPut) { + const request = require('request'); + let op = request.post; + if (doPut) { + op = request.put; } - }); + return new Promise((resolve, reject) => { + op( + { + url: Parse.serverURL + '/schemas/' + className, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + json: true, + body: { + classLevelPermissions: permissions, + }, + }, + (error, response, body) => { + if (error) { + return reject(error); + } + if (body.error) { + return reject(body); + } + return resolve(body); + } + ); + }); + } - expect(parseServer.liveQueryServer).not.toBeUndefined(); - expect(parseServer.liveQueryServer.server).not.toBe(parseServer.server); - parseServer.liveQueryServer.server.close(); - parseServer.server.close(done); + let saveSpy; + let deleteSpy; + reconfigureServer({ + liveQuery: { + classNames: ['Yolo'], + }, + }) + .then(parseServer => { + saveSpy = spyOn(parseServer.config.liveQueryController, 'onAfterSave'); + deleteSpy = spyOn( + parseServer.config.liveQueryController, + 'onAfterDelete' + ); + return setPermissionsOnClass('Yolo', { + create: { '*': true }, + delete: { '*': true }, + }); + }) + .then(() => { + const obj = new Parse.Object('Yolo'); + return obj.save(); + }) + .then(obj => { + return obj.destroy(); + }) + .then(() => { + expect(saveSpy).toHaveBeenCalled(); + const saveArgs = saveSpy.calls.mostRecent().args; + expect(saveArgs.length).toBe(4); + expect(saveArgs[0]).toBe('Yolo'); + expect(saveArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + + expect(deleteSpy).toHaveBeenCalled(); + const deleteArgs = deleteSpy.calls.mostRecent().args; + expect(deleteArgs.length).toBe(4); + expect(deleteArgs[0]).toBe('Yolo'); + expect(deleteArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + done(); + }) + .catch(done.fail); }); - it('can handle connect command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var parseWebSocket = { - clientId: -1 + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: -1, }; - parseLiveQueryServer._validateKeys = jasmine.createSpy('validateKeys').and.returnValue(true); - parseLiveQueryServer._handleConnect(parseWebSocket); + parseLiveQueryServer._validateKeys = jasmine + .createSpy('validateKeys') + .and.returnValue(true); + parseLiveQueryServer._handleConnect(parseWebSocket, { + sessionToken: 'token', + }); const clientKeys = parseLiveQueryServer.clients.keys(); expect(parseLiveQueryServer.clients.size).toBe(1); const firstKey = clientKeys.next().value; expect(parseWebSocket.clientId).toBe(firstKey); - var client = parseLiveQueryServer.clients.get(firstKey); + const client = parseLiveQueryServer.clients.get(firstKey); expect(client).not.toBeNull(); // Make sure we send connect response to the client expect(client.pushConnect).toHaveBeenCalled(); }); it('can handle subscribe command without clientId', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var incompleteParseConn = { - }; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const incompleteParseConn = {}; parseLiveQueryServer._handleSubscribe(incompleteParseConn, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); it('can handle subscribe command with new query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Handle mock subscription - var parseWebSocket = { - clientId: clientId + const parseWebSocket = { + clientId: clientId, }; - var query = { + const query = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] - } - var requestId = 2; - var request = { + fields: ['test'], + }; + const requestId = 2; + const request = { query: query, requestId: requestId, - sessionToken: 'sessionToken' - } + sessionToken: 'sessionToken', + }; parseLiveQueryServer._handleSubscribe(parseWebSocket, request); // Make sure we add the subscription to the server - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(1); expect(subscriptions.get(query.className)).not.toBeNull(); - var classSubscriptions = subscriptions.get(query.className); + const classSubscriptions = subscriptions.get(query.className); expect(classSubscriptions.size).toBe(1); expect(classSubscriptions.get('hash')).not.toBeNull(); // TODO(check subscription constructor to verify we pass the right argument) // Make sure we add clientInfo to the subscription - var subscription = classSubscriptions.get('hash'); - expect(subscription.addClientSubscription).toHaveBeenCalledWith(clientId, requestId); + const subscription = classSubscriptions.get('hash'); + expect(subscription.addClientSubscription).toHaveBeenCalledWith( + clientId, + requestId + ); // Make sure we add subscriptionInfo to the client - var args = client.addSubscriptionInfo.calls.first().args; + const args = client.addSubscriptionInfo.calls.first().args; expect(args[0]).toBe(requestId); expect(args[1].fields).toBe(query.fields); expect(args[1].sessionToken).toBe(request.sessionToken); @@ -220,50 +364,62 @@ describe('ParseLiveQueryServer', function() { }); it('can handle subscribe command with existing query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add two mock clients - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); - var clientIdAgain = 2; - var clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain); + const clientIdAgain = 2; + const clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain); // Add subscription for mock client 1 - var parseWebSocket = { - clientId: clientId + const parseWebSocket = { + clientId: clientId, }; - var requestId = 2; - var query = { + const requestId = 2; + const query = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] - } - addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + fields: ['test'], + }; + addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket, + query + ); // Add subscription for mock client 2 - var parseWebSocketAgain = { - clientId: clientIdAgain + const parseWebSocketAgain = { + clientId: clientIdAgain, }; - var queryAgain = { + const queryAgain = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'testAgain' ] - } - var requestIdAgain = 1; - addMockSubscription(parseLiveQueryServer, clientIdAgain, requestIdAgain, parseWebSocketAgain, queryAgain); + fields: ['testAgain'], + }; + const requestIdAgain = 1; + addMockSubscription( + parseLiveQueryServer, + clientIdAgain, + requestIdAgain, + parseWebSocketAgain, + queryAgain + ); // Make sure we only have one subscription - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(1); expect(subscriptions.get(query.className)).not.toBeNull(); - var classSubscriptions = subscriptions.get(query.className); + const classSubscriptions = subscriptions.get(query.className); expect(classSubscriptions.size).toBe(1); expect(classSubscriptions.get('hash')).not.toBeNull(); // Make sure we add clientInfo to the subscription - var subscription = classSubscriptions.get('hash'); + const subscription = classSubscriptions.get('hash'); // Make sure client 2 info has been added - var args = subscription.addClientSubscription.calls.mostRecent().args; + let args = subscription.addClientSubscription.calls.mostRecent().args; expect(args).toEqual([clientIdAgain, requestIdAgain]); // Make sure we add subscriptionInfo to the client 2 args = clientAgain.addSubscriptionInfo.calls.mostRecent().args; @@ -272,182 +428,226 @@ describe('ParseLiveQueryServer', function() { }); it('can handle unsubscribe command without clientId', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var incompleteParseConn = { - }; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const incompleteParseConn = {}; parseLiveQueryServer._handleUnsubscribe(incompleteParseConn, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); it('can handle unsubscribe command without not existed client', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var parseWebSocket = { - clientId: 1 + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: 1, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); it('can handle unsubscribe command without not existed query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); // Handle unsubscribe command - var parseWebSocket = { - clientId: 1 + const parseWebSocket = { + clientId: 1, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); it('can handle unsubscribe command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add subscription for mock client - var parseWebSocket = { - clientId: 1 + const parseWebSocket = { + clientId: 1, }; - var requestId = 2; - var subscription = addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket); + const requestId = 2; + const subscription = addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket + ); // Mock client.getSubscriptionInfo - var subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent().args[1]; + const subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent() + .args[1]; client.getSubscriptionInfo = function() { return subscriptionInfo; }; // Handle unsubscribe command - var requestAgain = { - requestId: requestId + const requestAgain = { + requestId: requestId, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, requestAgain); // Make sure we delete subscription from client expect(client.deleteSubscriptionInfo).toHaveBeenCalledWith(requestId); // Make sure we delete client from subscription - expect(subscription.deleteClientSubscription).toHaveBeenCalledWith(clientId, requestId); + expect(subscription.deleteClientSubscription).toHaveBeenCalledWith( + clientId, + requestId + ); // Make sure we clear subscription in the server - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(0); }); it('can set connect command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server parseLiveQueryServer._handleConnect = jasmine.createSpy('_handleSubscribe'); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check connect request - var connectRequest = { - op: 'connect' + const connectRequest = { + op: 'connect', + applicationId: '1', + installationId: '1234', }; // Trigger message event parseWebSocket.emit('message', connectRequest); // Make sure _handleConnect is called - var args = parseLiveQueryServer._handleConnect.calls.mostRecent().args; + const args = parseLiveQueryServer._handleConnect.calls.mostRecent().args; expect(args[0]).toBe(parseWebSocket); }); it('can set subscribe command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server - parseLiveQueryServer._handleSubscribe = jasmine.createSpy('_handleSubscribe'); + parseLiveQueryServer._handleSubscribe = jasmine.createSpy( + '_handleSubscribe' + ); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check subscribe request - var subscribeRequest = '{"op":"subscribe"}'; + const subscribeRequest = JSON.stringify({ + op: 'subscribe', + requestId: 1, + query: { className: 'Test', where: {} }, + }); // Trigger message event parseWebSocket.emit('message', subscribeRequest); // Make sure _handleSubscribe is called - var args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args; + const args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args; expect(args[0]).toBe(parseWebSocket); expect(JSON.stringify(args[1])).toBe(subscribeRequest); }); it('can set unsubscribe command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server - parseLiveQueryServer._handleUnsubscribe = jasmine.createSpy('_handleSubscribe'); + parseLiveQueryServer._handleUnsubscribe = jasmine.createSpy( + '_handleSubscribe' + ); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check unsubscribe request - var unsubscribeRequest = '{"op":"unsubscribe"}'; + const unsubscribeRequest = JSON.stringify({ + op: 'unsubscribe', + requestId: 1, + }); // Trigger message event parseWebSocket.emit('message', unsubscribeRequest); // Make sure _handleUnsubscribe is called - var args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; + const args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent() + .args; expect(args[0]).toBe(parseWebSocket); expect(JSON.stringify(args[1])).toBe(unsubscribeRequest); }); it('can set update command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server spyOn(parseLiveQueryServer, '_handleUpdateSubscription').and.callThrough(); spyOn(parseLiveQueryServer, '_handleUnsubscribe').and.callThrough(); spyOn(parseLiveQueryServer, '_handleSubscribe').and.callThrough(); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check updateRequest request - var updateRequest = '{"op":"update"}'; + const updateRequest = JSON.stringify({ + op: 'update', + requestId: 1, + query: { className: 'Test', where: {} }, + }); // Trigger message event parseWebSocket.emit('message', updateRequest); // Make sure _handleUnsubscribe is called - var args = parseLiveQueryServer._handleUpdateSubscription.calls.mostRecent().args; + const args = parseLiveQueryServer._handleUpdateSubscription.calls.mostRecent() + .args; expect(args[0]).toBe(parseWebSocket); expect(JSON.stringify(args[1])).toBe(updateRequest); expect(parseLiveQueryServer._handleUnsubscribe).toHaveBeenCalled(); - const unsubArgs = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; + const unsubArgs = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent() + .args; expect(unsubArgs.length).toBe(3); expect(unsubArgs[2]).toBe(false); expect(parseLiveQueryServer._handleSubscribe).toHaveBeenCalled(); }); + it('can set missing command message handler for a parseWebSocket', function() { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock parseWebsocket + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check invalid request + const invalidRequest = '{}'; + // Trigger message event + parseWebSocket.emit('message', invalidRequest); + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + it('can set unknown command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check unknown request - var unknownRequest = '{"op":"unknown"}'; + const unknownRequest = '{"op":"unknown"}'; // Trigger message event parseWebSocket.emit('message', unknownRequest); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); it('can set disconnect command message handler for a parseWebSocket which has not registered to the server', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); parseWebSocket.clientId = 1; // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); @@ -459,13 +659,13 @@ describe('ParseLiveQueryServer', function() { it('can forward event to cloud code', function() { const cloudCodeHandler = { - handler: () => {} - } + handler: () => {}, + }; const spy = spyOn(cloudCodeHandler, 'handler').and.callThrough(); Parse.Cloud.onLiveQueryEvent(cloudCodeHandler.handler); - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); parseWebSocket.clientId = 1; // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); @@ -474,49 +674,48 @@ describe('ParseLiveQueryServer', function() { // Trigger disconnect event parseWebSocket.emit('disconnect'); expect(spy).toHaveBeenCalled(); - // call for ws_connect, another for ws_disconnect + // call for ws_connect, another for ws_disconnect expect(spy.calls.count()).toBe(2); }); - // TODO: Test server can set disconnect command message handler for a parseWebSocket it('has no subscription and can handle object delete command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); // Make mock message - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; // Make sure we do not crash in this case parseLiveQueryServer._onAfterDelete(message, {}); }); it('can handle object delete command which does not match any subscription', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); // Make mock message - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; // Add mock client - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); - var client = parseLiveQueryServer.clients.get(clientId); + const client = parseLiveQueryServer.clients.get(clientId); // Mock _matchesSubscription to return not matching parseLiveQueryServer._matchesSubscription = function() { return false; @@ -531,30 +730,30 @@ describe('ParseLiveQueryServer', function() { }); it('can handle object delete command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); // Make mock message - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; // Add mock client - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); - var client = parseLiveQueryServer.clients.get(clientId); + const client = parseLiveQueryServer.clients.get(clientId); // Mock _matchesSubscription to return matching parseLiveQueryServer._matchesSubscription = function() { return true; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true); + return Promise.resolve(true); }; parseLiveQueryServer._onAfterDelete(message); @@ -567,35 +766,35 @@ describe('ParseLiveQueryServer', function() { }); it('has no subscription and can handle object save command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Make sure we do not crash in this case parseLiveQueryServer._onAfterSave(message); }); it('can handle object save command which does not match any subscription', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return not matching parseLiveQueryServer._matchesSubscription = function() { return false; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + return Promise.resolve(true); }; // Trigger onAfterSave parseLiveQueryServer._onAfterSave(message); // Make sure we do not send command to client - setTimeout(function(){ + setTimeout(function() { expect(client.pushCreate).not.toHaveBeenCalled(); expect(client.pushEnter).not.toHaveBeenCalled(); expect(client.pushUpdate).not.toHaveBeenCalled(); @@ -606,20 +805,20 @@ describe('ParseLiveQueryServer', function() { }); it('can handle object enter command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching // In order to mimic a enter, we need original match return false // and the current match return true - var counter = 0; - parseLiveQueryServer._matchesSubscription = function(parseObject){ + let counter = 0; + parseLiveQueryServer._matchesSubscription = function(parseObject) { if (!parseObject) { return false; } @@ -627,12 +826,12 @@ describe('ParseLiveQueryServer', function() { return counter % 2 === 0; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send enter command to client - setTimeout(function(){ + setTimeout(function() { expect(client.pushCreate).not.toHaveBeenCalled(); expect(client.pushEnter).toHaveBeenCalled(); expect(client.pushUpdate).not.toHaveBeenCalled(); @@ -643,29 +842,29 @@ describe('ParseLiveQueryServer', function() { }); it('can handle object update command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching - parseLiveQueryServer._matchesSubscription = function(parseObject){ + parseLiveQueryServer._matchesSubscription = function(parseObject) { if (!parseObject) { return false; } return true; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send update command to client - setTimeout(function(){ + setTimeout(function() { expect(client.pushCreate).not.toHaveBeenCalled(); expect(client.pushEnter).not.toHaveBeenCalled(); expect(client.pushUpdate).toHaveBeenCalled(); @@ -676,20 +875,20 @@ describe('ParseLiveQueryServer', function() { }); it('can handle object leave command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching // In order to mimic a leave, we need original match return true // and the current match return false - var counter = 0; - parseLiveQueryServer._matchesSubscription = function(parseObject){ + let counter = 0; + parseLiveQueryServer._matchesSubscription = function(parseObject) { if (!parseObject) { return false; } @@ -697,12 +896,12 @@ describe('ParseLiveQueryServer', function() { return counter % 2 !== 0; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send leave command to client - setTimeout(function(){ + setTimeout(function() { expect(client.pushCreate).not.toHaveBeenCalled(); expect(client.pushEnter).not.toHaveBeenCalled(); expect(client.pushUpdate).not.toHaveBeenCalled(); @@ -712,30 +911,80 @@ describe('ParseLiveQueryServer', function() { }, jasmine.ASYNC_TEST_WAIT_TIME); }); + it('can handle update command with original object', function(done) { + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + const Client = require('../lib/LiveQuery/Client').Client; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock request message + const message = generateMockMessage(true); + + const clientId = 1; + const parseWebSocket = { + clientId, + send: jasmine.createSpy('send'), + }; + const client = new Client(clientId, parseWebSocket); + spyOn(client, 'pushUpdate').and.callThrough(); + parseLiveQueryServer.clients.set(clientId, client); + + // Add mock subscription + const requestId = 2; + + addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket + ); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function(parseObject) { + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function() { + return Promise.resolve(true); + }; + + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send update command to client + setTimeout(function() { + expect(client.pushUpdate).toHaveBeenCalled(); + const args = parseWebSocket.send.calls.mostRecent().args; + const toSend = JSON.parse(args[0]); + + expect(toSend.object).toBeDefined(); + expect(toSend.original).toBeDefined(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + it('can handle object create command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; + const requestId = 2; addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching - parseLiveQueryServer._matchesSubscription = function(parseObject){ + parseLiveQueryServer._matchesSubscription = function(parseObject) { if (!parseObject) { return false; } return true; }; parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send create command to client - setTimeout(function(){ + setTimeout(function() { expect(client.pushCreate).toHaveBeenCalled(); expect(client.pushEnter).not.toHaveBeenCalled(); expect(client.pushUpdate).not.toHaveBeenCalled(); @@ -745,58 +994,120 @@ describe('ParseLiveQueryServer', function() { }, jasmine.ASYNC_TEST_WAIT_TIME); }); + it('can handle create command with fields', function(done) { + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + const Client = require('../lib/LiveQuery/Client').Client; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock request message + const message = generateMockMessage(); + + const clientId = 1; + const parseWebSocket = { + clientId, + send: jasmine.createSpy('send'), + }; + const client = new Client(clientId, parseWebSocket); + spyOn(client, 'pushCreate').and.callThrough(); + parseLiveQueryServer.clients.set(clientId, client); + + // Add mock subscription + const requestId = 2; + const query = { + className: testClassName, + where: { + key: 'value', + }, + fields: ['test'], + }; + addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket, + query + ); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function(parseObject) { + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function() { + return Promise.resolve(true); + }; + + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send create command to client + setTimeout(function() { + expect(client.pushCreate).toHaveBeenCalled(); + const args = parseWebSocket.send.calls.mostRecent().args; + const toSend = JSON.parse(args[0]); + expect(toSend.object).toBeDefined(); + expect(toSend.original).toBeUndefined(); + done(); + }, jasmine.ASYNC_TEST_WAIT_TIME); + }); + it('can match subscription for null or undefined parse object', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock subscription - var subscription = { - match: jasmine.createSpy('match') - } + const subscription = { + match: jasmine.createSpy('match'), + }; - expect(parseLiveQueryServer._matchesSubscription(null, subscription)).toBe(false); - expect(parseLiveQueryServer._matchesSubscription(undefined, subscription)).toBe(false); + expect(parseLiveQueryServer._matchesSubscription(null, subscription)).toBe( + false + ); + expect( + parseLiveQueryServer._matchesSubscription(undefined, subscription) + ).toBe(false); // Make sure subscription.match is not called expect(subscription.match).not.toHaveBeenCalled(); }); it('can match subscription', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock subscription - var subscription = { - query: {} - } - var parseObject = {}; - expect(parseLiveQueryServer._matchesSubscription(parseObject, subscription)).toBe(true); + const subscription = { + query: {}, + }; + const parseObject = {}; + expect( + parseLiveQueryServer._matchesSubscription(parseObject, subscription) + ).toBe(true); // Make sure matchesQuery is called - var matchesQuery = require('../src/LiveQuery/QueryTools').matchesQuery; + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; expect(matchesQuery).toHaveBeenCalledWith(parseObject, subscription.query); }); it('can inflate parse object', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request - var objectJSON = { - "className":"testClassName", - "createdAt":"2015-12-22T01:51:12.955Z", - "key":"value", - "objectId":"BfwxBCz6yW", - "updatedAt":"2016-01-05T00:46:45.659Z" - }; - var originalObjectJSON = { - "className":"testClassName", - "createdAt":"2015-12-22T01:51:12.955Z", - "key":"originalValue", - "objectId":"BfwxBCz6yW", - "updatedAt":"2016-01-05T00:46:45.659Z" - }; - var message = { + const objectJSON = { + className: 'testClassName', + createdAt: '2015-12-22T01:51:12.955Z', + key: 'value', + objectId: 'BfwxBCz6yW', + updatedAt: '2016-01-05T00:46:45.659Z', + }; + const originalObjectJSON = { + className: 'testClassName', + createdAt: '2015-12-22T01:51:12.955Z', + key: 'originalValue', + objectId: 'BfwxBCz6yW', + updatedAt: '2016-01-05T00:46:45.659Z', + }; + const message = { currentParseObject: objectJSON, - originalParseObject: originalObjectJSON + originalParseObject: originalObjectJSON, }; // Inflate the object parseLiveQueryServer._inflateParseObject(message); // Verify object - var object = message.currentParseObject; + const object = message.currentParseObject; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('key')).toEqual('value'); expect(object.className).toEqual('testClassName'); @@ -804,7 +1115,7 @@ describe('ParseLiveQueryServer', function() { expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); // Verify original object - var originalObject = message.originalParseObject; + const originalObject = message.originalParseObject; expect(originalObject instanceof Parse.Object).toBeTruthy(); expect(originalObject.get('key')).toEqual('originalValue'); expect(originalObject.className).toEqual('testClassName'); @@ -813,403 +1124,734 @@ describe('ParseLiveQueryServer', function() { expect(originalObject.updatedAt).not.toBeUndefined(); }); - it('can match undefined ACL', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var client = {}; - var requestId = 0; + it('can inflate user object', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const userJSON = { + username: 'test', + ACL: {}, + createdAt: '2018-12-21T23:09:51.784Z', + sessionToken: 'r:1234', + updatedAt: '2018-12-21T23:09:51.784Z', + objectId: 'NhF2u9n72W', + __type: 'Object', + className: '_User', + _hashed_password: '1234', + _email_verify_token: '1234', + }; - parseLiveQueryServer._matchesACL(undefined, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); - }); + const originalUserJSON = { + username: 'test', + ACL: {}, + createdAt: '2018-12-21T23:09:51.784Z', + sessionToken: 'r:1234', + updatedAt: '2018-12-21T23:09:51.784Z', + objectId: 'NhF2u9n72W', + __type: 'Object', + className: '_User', + _hashed_password: '12345', + _email_verify_token: '12345', + }; + + const message = { + currentParseObject: userJSON, + originalParseObject: originalUserJSON, + }; + parseLiveQueryServer._inflateParseObject(message); + + const object = message.currentParseObject; + expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('_hashed_password')).toBeUndefined(); + expect(object.get('_email_verify_token')).toBeUndefined(); + expect(object.className).toEqual('_User'); + expect(object.id).toBe('NhF2u9n72W'); + expect(object.createdAt).not.toBeUndefined(); + expect(object.updatedAt).not.toBeUndefined(); + + const originalObject = message.originalParseObject; + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('_hashed_password')).toBeUndefined(); + expect(originalObject.get('_email_verify_token')).toBeUndefined(); + expect(originalObject.className).toEqual('_User'); + expect(originalObject.id).toBe('NhF2u9n72W'); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + }); + + it('can match undefined ACL', function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const client = {}; + const requestId = 0; + + parseLiveQueryServer + ._matchesACL(undefined, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); }); it('can match ACL with none exist requestId', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue(undefined) + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue(undefined), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); it('can match ACL with public read access', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); }); it('can match ACL with valid subscription sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); }); it('can match ACL with valid client sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return false when sessionToken is undefined - var client = { + const client = { sessionToken: 'sessionToken', - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: undefined - }) + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: undefined, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); }); it('can match ACL with invalid subscription and client sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return false when sessionToken is undefined - var client = { + const client = { sessionToken: undefined, - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: undefined - }) + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: undefined, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); it('can match ACL with subscription sessionToken checking error', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return error when sessionToken is null, this is just // the behaviour of our mock sessionTokenCache, not real sessionTokenCache - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: null - }) + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: null, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); it('can match ACL with client sessionToken checking error', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return error when sessionToken is null - var client = { + const client = { sessionToken: null, - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: null - }) + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: null, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); - it('won\'t match ACL that doesn\'t have public read or any roles', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it("won't match ACL that doesn't have public read or any roles", function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; - - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + const requestId = 0; + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); - it('won\'t match non-public ACL with role when there is no user', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it("won't match non-public ACL with role when there is no user", function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - acl.setRoleReadAccess("livequery", true); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - }) + acl.setRoleReadAccess('livequery', true); + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({}), }; - var requestId = 0; - - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + const requestId = 0; + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }) + .catch(done.fail); }); - it('won\'t match ACL with role based read access set to false', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it("won't match ACL with role based read access set to false", function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - acl.setRoleReadAccess("liveQueryRead", false); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + acl.setRoleReadAccess('otherLiveQueryRead', true); + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - spyOn(Parse, "Query").and.callFake(function(){ + spyOn(Parse, 'Query').and.callFake(function() { + let shouldReturn = false; return { equalTo() { + shouldReturn = true; // Nothing to do here + return this; + }, + containedIn() { + shouldReturn = false; + return this; }, find() { + if (!shouldReturn) { + return Promise.resolve([]); + } //Return a role with the name "liveQueryRead" as that is what was set on the ACL - var liveQueryRole = new Parse.Role(); - liveQueryRole.set('name', 'liveQueryRead'); - return [ - liveQueryRole - ]; - } - } + const liveQueryRole = new Parse.Role( + 'liveQueryRead', + new Parse.ACL() + ); + liveQueryRole.id = 'abcdef1234'; + return Promise.resolve([liveQueryRole]); + }, + }; }); - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); }); - it('will match ACL with role based read access set to true', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('will match ACL with role based read access set to true', function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - acl.setRoleReadAccess("liveQueryRead", true); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + acl.setRoleReadAccess('liveQueryRead', true); + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - spyOn(Parse, "Query").and.callFake(function(){ + spyOn(Parse, 'Query').and.callFake(function() { + let shouldReturn = false; return { equalTo() { + shouldReturn = true; // Nothing to do here + return this; + }, + containedIn() { + shouldReturn = false; + return this; }, find() { + if (!shouldReturn) { + return Promise.resolve([]); + } //Return a role with the name "liveQueryRead" as that is what was set on the ACL - var liveQueryRole = new Parse.Role(); - liveQueryRole.set('name', 'liveQueryRead'); - return [ - liveQueryRole - ]; - } - } + const liveQueryRole = new Parse.Role( + 'liveQueryRead', + new Parse.ACL() + ); + liveQueryRole.id = 'abcdef1234'; + return Promise.resolve([liveQueryRole]); + }, + each(callback) { + //Return a role with the name "liveQueryRead" as that is what was set on the ACL + const liveQueryRole = new Parse.Role( + 'liveQueryRead', + new Parse.ACL() + ); + liveQueryRole.id = 'abcdef1234'; + callback(liveQueryRole); + return Promise.resolve(); + }, + }; }); - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + describe('class level permissions', () => { + it('matches CLP when find is closed', done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + parseLiveQueryServer + ._matchesCLP( + { + find: {}, + }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + .then(isMatched => { + expect(isMatched).toBe(false); + done(); + }); }); + it('matches CLP when find is open', done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + parseLiveQueryServer + ._matchesCLP( + { + find: { '*': true }, + }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + .then(isMatched => { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('matches CLP when find is restricted to userIds', done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: 'userId', + }), + }; + const requestId = 0; + + parseLiveQueryServer + ._matchesCLP( + { + find: { userId: true }, + }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + .then(isMatched => { + expect(isMatched).toBe(true); + done(); + }); + }); + + it('matches CLP when find is restricted to userIds', done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setReadAccess(testUserId, true); + // Mock sessionTokenCache will return false when sessionToken is undefined + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + parseLiveQueryServer + ._matchesCLP( + { + find: { userId: true }, + }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + .then(isMatched => { + expect(isMatched).toBe(false); + done(); + }); + }); }); it('can validate key when valid key is provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, } - }); - var request = { - clientKey: 'test' - } + ); + const request = { + clientKey: 'test', + }; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).toBeTruthy(); }); it('can validate key when invalid key is provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, } - }); - var request = { - clientKey: 'error' - } + ); + const request = { + clientKey: 'error', + }; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); it('can validate key when key is not provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, } - }); - var request = { - } + ); + const request = {}; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); it('can validate key when validKerPairs is empty', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, {}); - var request = { - } + const parseLiveQueryServer = new ParseLiveQueryServer({}, {}); + const request = {}; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).toBeTruthy(); }); it('can validate client has master key when valid', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - masterKey: 'test' + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, } - }); - var request = { - masterKey: 'test' + ); + const request = { + masterKey: 'test', }; - expect(parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + expect( + parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs) + ).toBeTruthy(); }); - it('can validate client doesn\'t have master key when invalid', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - masterKey: 'test' + it("can validate client doesn't have master key when invalid", function() { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, } - }); - var request = { - masterKey: 'notValid' + ); + const request = { + masterKey: 'notValid', }; - expect(parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); - it('can validate client doesn\'t have master key when not provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - masterKey: 'test' + it("can validate client doesn't have master key when not provided", function() { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, } - }); + ); - expect(parseLiveQueryServer._hasMasterKey({}, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._hasMasterKey({}, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); - it('can validate client doesn\'t have master key when validKeyPairs is empty', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, {}); - var request = { - masterKey: 'test' + it("can validate client doesn't have master key when validKeyPairs is empty", function() { + const parseLiveQueryServer = new ParseLiveQueryServer({}, {}); + const request = { + masterKey: 'test', }; - expect(parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); - it('will match non-public ACL when client has master key', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('will match non-public ACL when client has master key', function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - }), - hasMasterKey: true + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({}), + hasMasterKey: true, }; - var requestId = 0; - - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(true); - done(); - }); + const requestId = 0; + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(true); + done(); + }); }); - it('won\'t match non-public ACL when client has no master key', function(done){ - - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it("won't match non-public ACL when client has no master key", function(done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(false); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - }), - hasMasterKey: false + const client = { + getSubscriptionInfo: jasmine + .createSpy('getSubscriptionInfo') + .and.returnValue({}), + hasMasterKey: false, }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { - expect(isMatched).toBe(false); - done(); - }); + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function(isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + it('should properly pull auth from cache', () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('sessionToken'); + const secondPromise = parseLiveQueryServer.getAuthForSessionToken( + 'sessionToken' + ); + // should be in the cache + expect(parseLiveQueryServer.authCache.get('sessionToken')).toBe(promise); + // should be the same promise returned + expect(promise).toBe(secondPromise); + // the auth should be called only once + expect(auth.getAuthForSessionToken.calls.count()).toBe(1); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer'); - jasmine.restoreLibrary('../src/LiveQuery/Client', 'Client'); - jasmine.restoreLibrary('../src/LiveQuery/Subscription', 'Subscription'); - jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'queryHash'); - jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'matchesQuery'); - jasmine.restoreLibrary('tv4', 'validate'); - jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); - jasmine.restoreLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache'); + it('should delete from cache throwing auth calls', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('pleaseThrow'); + expect(parseLiveQueryServer.authCache.get('pleaseThrow')).toBe(promise); + // after the promise finishes, it should have removed it from the cache + expect(await promise).toEqual({}); + expect(parseLiveQueryServer.authCache.get('pleaseThrow')).toBe(undefined); + }); + + it('should keep a cache of invalid sessions', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('invalid'); + expect(parseLiveQueryServer.authCache.get('invalid')).toBe(promise); + // after the promise finishes, it should have removed it from the cache + await promise; + const finalResult = await parseLiveQueryServer.authCache.get('invalid'); + expect(finalResult.error).not.toBeUndefined(); + expect(parseLiveQueryServer.authCache.get('invalid')).not.toBe(undefined); + }); + + afterEach(function() { + jasmine.restoreLibrary( + '../lib/LiveQuery/ParseWebSocketServer', + 'ParseWebSocketServer' + ); + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + jasmine.restoreLibrary('../lib/LiveQuery/Subscription', 'Subscription'); + jasmine.restoreLibrary('../lib/LiveQuery/QueryTools', 'queryHash'); + jasmine.restoreLibrary('../lib/LiveQuery/QueryTools', 'matchesQuery'); + jasmine.restoreLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub'); }); // Helper functions to add mock client and subscription to a liveQueryServer function addMockClient(parseLiveQueryServer, clientId) { - var Client = require('../src/LiveQuery/Client').Client; - var client = new Client(clientId, {}); + const Client = require('../lib/LiveQuery/Client').Client; + const client = new Client(clientId, {}); parseLiveQueryServer.clients.set(clientId, client); return client; } - function addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query) { + function addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket, + query + ) { // If parseWebSocket is null, we use the default one if (!parseWebSocket) { - var EventEmitter = require('events'); + const EventEmitter = require('events'); parseWebSocket = new EventEmitter(); } parseWebSocket.clientId = clientId; @@ -1218,26 +1860,31 @@ describe('ParseLiveQueryServer', function() { query = { className: testClassName, where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] + fields: ['test'], }; } - var request = { + const request = { query: query, requestId: requestId, - sessionToken: 'sessionToken' + sessionToken: 'sessionToken', }; parseLiveQueryServer._handleSubscribe(parseWebSocket, request); // Make mock subscription - var subscription = parseLiveQueryServer.subscriptions.get(query.className).get(queryHashValue); + const subscription = parseLiveQueryServer.subscriptions + .get(query.className) + .get(queryHashValue); subscription.hasSubscribingClient = function() { return false; - } + }; subscription.className = query.className; subscription.hash = queryHashValue; - if (subscription.clientRequestIds && subscription.clientRequestIds.has(clientId)) { + if ( + subscription.clientRequestIds && + subscription.clientRequestIds.has(clientId) + ) { subscription.clientRequestIds.get(clientId).push(requestId); } else { subscription.clientRequestIds = new Map([[clientId, [requestId]]]); @@ -1247,22 +1894,162 @@ describe('ParseLiveQueryServer', function() { // Helper functiosn to generate request message function generateMockMessage(hasOriginalParseObject) { - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; if (hasOriginalParseObject) { - var originalParseObject = new Parse.Object(testClassName); + const originalParseObject = new Parse.Object(testClassName); originalParseObject._finishFetch({ key: 'originalValue', - className: testClassName + className: testClassName, }); message.originalParseObject = originalParseObject; } return message; } }); + +describe('LiveQueryController', () => { + it('properly passes the CLP to afterSave/afterDelete hook', function(done) { + function setPermissionsOnClass(className, permissions, doPut) { + const request = require('request'); + let op = request.post; + if (doPut) { + op = request.put; + } + return new Promise((resolve, reject) => { + op( + { + url: Parse.serverURL + '/schemas/' + className, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + json: true, + body: { + classLevelPermissions: permissions, + }, + }, + (error, response, body) => { + if (error) { + return reject(error); + } + if (body.error) { + return reject(body); + } + return resolve(body); + } + ); + }); + } + + let saveSpy; + let deleteSpy; + reconfigureServer({ + liveQuery: { + classNames: ['Yolo'], + }, + }) + .then(parseServer => { + saveSpy = spyOn( + parseServer.config.liveQueryController, + 'onAfterSave' + ).and.callThrough(); + deleteSpy = spyOn( + parseServer.config.liveQueryController, + 'onAfterDelete' + ).and.callThrough(); + return setPermissionsOnClass('Yolo', { + create: { '*': true }, + delete: { '*': true }, + }); + }) + .then(() => { + const obj = new Parse.Object('Yolo'); + return obj.save(); + }) + .then(obj => { + return obj.destroy(); + }) + .then(() => { + expect(saveSpy).toHaveBeenCalled(); + const saveArgs = saveSpy.calls.mostRecent().args; + expect(saveArgs.length).toBe(4); + expect(saveArgs[0]).toBe('Yolo'); + expect(saveArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + + expect(deleteSpy).toHaveBeenCalled(); + const deleteArgs = deleteSpy.calls.mostRecent().args; + expect(deleteArgs.length).toBe(4); + expect(deleteArgs[0]).toBe('Yolo'); + expect(deleteArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + done(); + }) + .catch(done.fail); + }); + + it('should properly pack message request on afterSave', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + const spy = spyOn(controller.liveQueryPublisher, 'onCloudCodeAfterSave'); + controller.onAfterSave('Yolo', { o: 1 }, { o: 2 }, { yolo: true }); + expect(spy).toHaveBeenCalled(); + const args = spy.calls.mostRecent().args; + expect(args.length).toBe(1); + expect(args[0]).toEqual({ + object: { o: 1 }, + original: { o: 2 }, + classLevelPermissions: { yolo: true }, + }); + }); + + it('should properly pack message request on afterDelete', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + const spy = spyOn(controller.liveQueryPublisher, 'onCloudCodeAfterDelete'); + controller.onAfterDelete('Yolo', { o: 1 }, { o: 2 }, { yolo: true }); + expect(spy).toHaveBeenCalled(); + const args = spy.calls.mostRecent().args; + expect(args.length).toBe(1); + expect(args[0]).toEqual({ + object: { o: 1 }, + original: { o: 2 }, + classLevelPermissions: { yolo: true }, + }); + }); + + it('should properly pack message request', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + expect(controller._makePublisherRequest({})).toEqual({ + object: {}, + original: undefined, + }); + }); +}); diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index cc449458ce..78df95e598 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -1,4 +1,4 @@ -"use strict"; +'use strict'; // This is a port of the test suite: // hungry/js/test/parse_object_test.js // @@ -13,303 +13,260 @@ // single-instance mode. describe('Parse.Object testing', () => { - it("create", function(done) { - create({ "test" : "test" }, function(model) { - ok(model.id, "Should have an objectId set"); - equal(model.get("test"), "test", "Should have the right attribute"); + it('create', function(done) { + create({ test: 'test' }, function(model) { + ok(model.id, 'Should have an objectId set'); + equal(model.get('test'), 'test', 'Should have the right attribute'); done(); }); }); - it("update", function(done) { - create({ "test" : "test" }, function(model) { - var t2 = new TestObject({ objectId: model.id }); - t2.set("test", "changed"); - t2.save(null, { - success: function(model) { - equal(model.get("test"), "changed", "Update should have succeeded"); - done(); - } + it('update', function(done) { + create({ test: 'test' }, function(model) { + const t2 = new TestObject({ objectId: model.id }); + t2.set('test', 'changed'); + t2.save().then(function(model) { + equal(model.get('test'), 'changed', 'Update should have succeeded'); + done(); }); }); }); - it("save without null", function(done) { - var object = new TestObject(); - object.set("favoritePony", "Rainbow Dash"); - object.save({ - success: function(objectAgain) { + it('save without null', function(done) { + const object = new TestObject(); + object.set('favoritePony', 'Rainbow Dash'); + object.save().then( + function(objectAgain) { equal(objectAgain, object); done(); }, - error: function(objectAgain, error) { - ok(null, "Error " + error.code + ": " + error.message); + function(objectAgain, error) { + ok(null, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); + }); + + it('save cycle', done => { + const a = new Parse.Object('TestObject'); + const b = new Parse.Object('TestObject'); + a.set('b', b); + a.save() + .then(function() { + b.set('a', a); + return b.save(); + }) + .then(function() { + ok(a.id); + ok(b.id); + strictEqual(a.get('b'), b); + strictEqual(b.get('a'), a); + }) + .then( + function() { + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); }); - it("save cycle", done => { - var a = new Parse.Object("TestObject"); - var b = new Parse.Object("TestObject"); - a.set("b", b); - a.save().then(function() { - b.set("a", a); - return b.save(); - - }).then(function() { - ok(a.id); - ok(b.id); - strictEqual(a.get("b"), b); - strictEqual(b.get("a"), a); - - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); + it('get', function(done) { + create({ test: 'test' }, function(model) { + const t2 = new TestObject({ objectId: model.id }); + t2.fetch().then(function(model2) { + equal(model2.get('test'), 'test', 'Update should have succeeded'); + ok(model2.id); + equal(model2.id, model.id, 'Ids should match'); + done(); + }); }); }); - it("get", function(done) { - create({ "test" : "test" }, function(model) { - var t2 = new TestObject({ objectId: model.id }); - t2.fetch({ - success: function(model2) { - equal(model2.get("test"), "test", "Update should have succeeded"); - ok(model2.id); - equal(model2.id, model.id, "Ids should match"); - done(); - } + it('delete', function(done) { + const t = new TestObject(); + t.set('test', 'test'); + t.save().then(function() { + t.destroy().then(function() { + const t2 = new TestObject({ objectId: t.id }); + t2.fetch().then(fail, () => done()); }); }); }); - it("delete", function(done) { - var t = new TestObject(); - t.set("test", "test"); - t.save(null, { - success: function() { - t.destroy({ - success: function() { - var t2 = new TestObject({ objectId: t.id }); - t2.fetch().then(fail, done); - } - }); - } + it('find', function(done) { + const t = new TestObject(); + t.set('foo', 'bar'); + t.save().then(function() { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.find().then(function(results) { + equal(results.length, 1); + done(); + }); }); }); - it("find", function(done) { - var t = new TestObject(); - t.set("foo", "bar"); - t.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.equalTo("foo", "bar"); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('relational fields', function(done) { + const item = new Item(); + item.set('property', 'x'); + const container = new Container(); + container.set('item', item); + + Parse.Object.saveAll([item, container]).then(function() { + const query = new Parse.Query(Container); + query.find().then(function(results) { + equal(results.length, 1); + const containerAgain = results[0]; + const itemAgain = containerAgain.get('item'); + itemAgain.fetch().then(function() { + equal(itemAgain.get('property'), 'x'); + done(); }); - } + }); }); }); - it("relational fields", function(done) { - var item = new Item(); - item.set("property", "x"); - var container = new Container(); - container.set("item", item); - - Parse.Object.saveAll([item, container], { - success: function() { - var query = new Parse.Query(Container); - query.find({ - success: function(results) { - equal(results.length, 1); - var containerAgain = results[0]; - var itemAgain = containerAgain.get("item"); - itemAgain.fetch({ - success: function() { - equal(itemAgain.get("property"), "x"); - done(); - } - }); - } - }); - } + it('save adds no data keys (other than createdAt and updatedAt)', function(done) { + const object = new TestObject(); + object.save().then(function() { + const keys = Object.keys(object.attributes).sort(); + equal(keys.length, 2); + done(); }); }); - it("save adds no data keys (other than createdAt and updatedAt)", - function(done) { - var object = new TestObject(); - object.save(null, { - success: function() { - var keys = Object.keys(object.attributes).sort(); - equal(keys.length, 2); - done(); - } - }); - }); + it('recursive save', function(done) { + const item = new Item(); + item.set('property', 'x'); + const container = new Container(); + container.set('item', item); - it("recursive save", function(done) { - var item = new Item(); - item.set("property", "x"); - var container = new Container(); - container.set("item", item); - - container.save(null, { - success: function() { - var query = new Parse.Query(Container); - query.find({ - success: function(results) { - equal(results.length, 1); - var containerAgain = results[0]; - var itemAgain = containerAgain.get("item"); - itemAgain.fetch({ - success: function() { - equal(itemAgain.get("property"), "x"); - done(); - } - }); - } + container.save().then(function() { + const query = new Parse.Query(Container); + query.find().then(function(results) { + equal(results.length, 1); + const containerAgain = results[0]; + const itemAgain = containerAgain.get('item'); + itemAgain.fetch().then(function() { + equal(itemAgain.get('property'), 'x'); + done(); }); - } + }); }); }); - it("fetch", function(done) { - var item = new Item({ foo: "bar" }); - item.save(null, { - success: function() { - var itemAgain = new Item(); - itemAgain.id = item.id; - itemAgain.fetch({ - success: function() { - itemAgain.save({ foo: "baz" }, { - success: function() { - item.fetch({ - success: function() { - equal(item.get("foo"), itemAgain.get("foo")); - done(); - } - }); - } - }); - } + it('fetch', function(done) { + const item = new Item({ foo: 'bar' }); + item.save().then(function() { + const itemAgain = new Item(); + itemAgain.id = item.id; + itemAgain.fetch().then(function() { + itemAgain.save({ foo: 'baz' }).then(function() { + item.fetch().then(function() { + equal(item.get('foo'), itemAgain.get('foo')); + done(); + }); }); - } + }); }); }); it("createdAt doesn't change", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - var objectAgain = new TestObject(); - objectAgain.id = object.id; - objectAgain.fetch({ - success: function() { - equal(object.createdAt.getTime(), objectAgain.createdAt.getTime()); - done(); - } - }); - } + const object = new TestObject({ foo: 'bar' }); + object.save().then(function() { + const objectAgain = new TestObject(); + objectAgain.id = object.id; + objectAgain.fetch().then(function() { + equal(object.createdAt.getTime(), objectAgain.createdAt.getTime()); + done(); + }); }); }); - it("createdAt and updatedAt exposed", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - notEqual(object.updatedAt, undefined); - notEqual(object.createdAt, undefined); - done(); - } + it('createdAt and updatedAt exposed', function(done) { + const object = new TestObject({ foo: 'bar' }); + object.save().then(function() { + notEqual(object.updatedAt, undefined); + notEqual(object.createdAt, undefined); + done(); }); }); - it("updatedAt gets updated", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - ok(object.updatedAt, "initial save should cause updatedAt to exist"); - var firstUpdatedAt = object.updatedAt; - object.save({ foo: "baz" }, { - success: function() { - ok(object.updatedAt, "two saves should cause updatedAt to exist"); - notEqual(firstUpdatedAt, object.updatedAt); - done(); - } - }); - } + it('updatedAt gets updated', function(done) { + const object = new TestObject({ foo: 'bar' }); + object.save().then(function() { + ok(object.updatedAt, 'initial save should cause updatedAt to exist'); + const firstUpdatedAt = object.updatedAt; + object.save({ foo: 'baz' }).then(function() { + ok(object.updatedAt, 'two saves should cause updatedAt to exist'); + notEqual(firstUpdatedAt, object.updatedAt); + done(); + }); }); }); - it("createdAt is reasonable", function(done) { - var startTime = new Date(); - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - var endTime = new Date(); - var startDiff = Math.abs(startTime.getTime() - - object.createdAt.getTime()); - ok(startDiff < 5000); + it('createdAt is reasonable', function(done) { + const startTime = new Date(); + const object = new TestObject({ foo: 'bar' }); + object.save().then(function() { + const endTime = new Date(); + const startDiff = Math.abs( + startTime.getTime() - object.createdAt.getTime() + ); + ok(startDiff < 5000); - var endDiff = Math.abs(endTime.getTime() - - object.createdAt.getTime()); - ok(endDiff < 5000); + const endDiff = Math.abs(endTime.getTime() - object.createdAt.getTime()); + ok(endDiff < 5000); - done(); - } + done(); }); }); - it_exclude_dbs(['postgres'])("can set null", function(done) { - var obj = new Parse.Object("TestObject"); - obj.set("foo", null); - obj.save(null, { - success: function(obj) { + it_exclude_dbs(['postgres'])('can set null', function(done) { + const obj = new Parse.Object('TestObject'); + obj.set('foo', null); + obj.save().then( + function(obj) { on_db('mongo', () => { - equal(obj.get("foo"), null); + equal(obj.get('foo'), null); }); on_db('postgres', () => { fail('should not succeed'); }); done(); }, - error: function() { + function() { fail('should not fail'); done(); } - }); + ); }); - it("can set boolean", function(done) { - var obj = new Parse.Object("TestObject"); - obj.set("yes", true); - obj.set("no", false); - obj.save(null, { - success: function(obj) { - equal(obj.get("yes"), true); - equal(obj.get("no"), false); + it('can set boolean', function(done) { + const obj = new Parse.Object('TestObject'); + obj.set('yes', true); + obj.set('no', false); + obj.save().then( + function(obj) { + equal(obj.get('yes'), true); + equal(obj.get('no'), false); done(); }, - error: function(obj, error) { + function(obj, error) { ok(false, error.message); done(); } - }); + ); }); - it('cannot set invalid date', function(done) { - var obj = new Parse.Object('TestObject'); + it('cannot set invalid date', async function(done) { + const obj = new Parse.Object('TestObject'); obj.set('when', new Date(Date.parse(null))); try { - obj.save(); + await obj.save(); } catch (e) { ok(true); done(); @@ -319,40 +276,60 @@ describe('Parse.Object testing', () => { done(); }); - it("invalid class name", function(done) { - var item = new Parse.Object("Foo^bar"); - item.save(null, { - success: function() { - ok(false, "The name should have been invalid."); + it('can set authData when not user class', async () => { + const obj = new Parse.Object('TestObject'); + obj.set('authData', 'random'); + await obj.save(); + expect(obj.get('authData')).toBe('random'); + const query = new Parse.Query('TestObject'); + const object = await query.get(obj.id, { useMasterKey: true }); + expect(object.get('authData')).toBe('random'); + }); + + it('invalid class name', function(done) { + const item = new Parse.Object('Foo^bar'); + item.save().then( + function() { + ok(false, 'The name should have been invalid.'); done(); }, - error: function() { + function() { // Because the class name is invalid, the router will not be able to route // it, so it will actually return a -1 error code. // equal(error.code, Parse.Error.INVALID_CLASS_NAME); done(); } - }); - }); - - it("invalid key name", function(done) { - var item = new Parse.Object("Item"); - ok(!item.set({"foo^bar": "baz"}), - 'Item should not be updated with invalid key.'); - item.save({ "foo^bar": "baz" }).then(fail, done); - }); - - it("invalid __type", function(done) { - var item = new Parse.Object("Item"); - var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes', 'Polygon']; - var tests = types.map(type => { - var test = new Parse.Object("Item"); + ); + }); + + it('invalid key name', function(done) { + const item = new Parse.Object('Item'); + ok( + !item.set({ 'foo^bar': 'baz' }), + 'Item should not be updated with invalid key.' + ); + item.save({ 'foo^bar': 'baz' }).then(fail, () => done()); + }); + + it('invalid __type', function(done) { + const item = new Parse.Object('Item'); + const types = [ + 'Pointer', + 'File', + 'Date', + 'GeoPoint', + 'Bytes', + 'Polygon', + 'Relation', + ]; + const tests = types.map(type => { + const test = new Parse.Object('Item'); test.set('foo', { - __type: type + __type: type, }); return test; }); - var next = function(index) { + const next = function(index) { if (index < tests.length) { tests[index].save().then(fail, error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); @@ -361,767 +338,689 @@ describe('Parse.Object testing', () => { } else { done(); } - } - item.save({ - "foo": { - __type: "IvalidName" - } - }).then(fail, () => next(0)); - }); - - it("simple field deletion", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.save({ - foo: "bar" - }, { - success: function(simple) { - simple.unset("foo"); - ok(!simple.has("foo"), "foo should have been unset."); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("foo"), "foo should have been unset."); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("foo"), "foo should have been removed."); - done(); - }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }); - - it("field deletion before first save", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.set("foo", "bar"); - simple.unset("foo"); - - ok(!simple.has("foo"), "foo should have been unset."); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("foo"), "foo should have been unset."); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("foo"), "foo should have been removed."); + }; + item + .save({ + foo: { + __type: 'IvalidName', + }, + }) + .then(fail, () => next(0)); + }); + + it('simple field deletion', function(done) { + const simple = new Parse.Object('SimpleObject'); + simple + .save({ + foo: 'bar', + }) + .then( + function(simple) { + simple.unset('foo'); + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function(simple) { + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function(simpleAgain) { + ok(!simpleAgain.has('foo'), 'foo should have been removed.'); + done(); + }, + function(simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function(simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function(simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }); + + it('field deletion before first save', function(done) { + const simple = new Parse.Object('SimpleObject'); + simple.set('foo', 'bar'); + simple.unset('foo'); + + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function(simple) { + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function(simpleAgain) { + ok(!simpleAgain.has('foo'), 'foo should have been removed.'); done(); }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); + function(simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function(simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); - }); - - it("relation deletion", function(done) { - var simple = new Parse.Object("SimpleObject"); - var child = new Parse.Object("Child"); - simple.save({ - child: child - }, { - success: function(simple) { - simple.unset("child"); - ok(!simple.has("child"), "child should have been unset."); - ok(simple.dirty("child"), "child should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("child"), "child should have been unset."); - ok(!simple.dirty("child"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("child"), "child should have been removed."); + ); + }); + + it('relation deletion', function(done) { + const simple = new Parse.Object('SimpleObject'); + const child = new Parse.Object('Child'); + simple + .save({ + child: child, + }) + .then( + function(simple) { + simple.unset('child'); + ok(!simple.has('child'), 'child should have been unset.'); + ok(simple.dirty('child'), 'child should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function(simple) { + ok(!simple.has('child'), 'child should have been unset.'); + ok(!simple.dirty('child'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function(simpleAgain) { + ok( + !simpleAgain.has('child'), + 'child should have been removed.' + ); + done(); + }, + function(simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function(simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function(simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }); + + it('deleted keys get cleared', function(done) { + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('foo', 'bar'); + simpleObject.unset('foo'); + simpleObject.save().then(function(simpleObject) { + simpleObject.set('foo', 'baz'); + simpleObject.save().then(function(simpleObject) { + const query = new Parse.Query('SimpleObject'); + query.get(simpleObject.id).then(function(simpleObjectAgain) { + equal(simpleObjectAgain.get('foo'), 'baz'); + done(); + }, done.fail); + }, done.fail); + }, done.fail); + }); + + it('setting after deleting', function(done) { + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('foo', 'bar'); + simpleObject.save().then( + function(simpleObject) { + simpleObject.unset('foo'); + simpleObject.set('foo', 'baz'); + simpleObject.save().then( + function(simpleObject) { + const query = new Parse.Query('SimpleObject'); + query.get(simpleObject.id).then( + function(simpleObjectAgain) { + equal(simpleObjectAgain.get('foo'), 'baz'); done(); }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); + function(error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function(error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function(error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); - }); - - it("deleted keys get cleared", function(done) { - var simpleObject = new Parse.Object("SimpleObject"); - simpleObject.set("foo", "bar"); - simpleObject.unset("foo"); - simpleObject.save(null, { - success: function(simpleObject) { - simpleObject.set("foo", "baz"); - simpleObject.save(null, { - success: function(simpleObject) { - var query = new Parse.Query("SimpleObject"); - query.get(simpleObject.id, { - success: function(simpleObjectAgain) { - equal(simpleObjectAgain.get("foo"), "baz"); - done(); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); + ); + }); + + it('increment', function(done) { + const simple = new Parse.Object('SimpleObject'); + simple + .save({ + foo: 5, + }) + .then(function(simple) { + simple.increment('foo'); + equal(simple.get('foo'), 6); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then(function(simple) { + equal(simple.get('foo'), 6); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then(function(simpleAgain) { + equal(simpleAgain.get('foo'), 6); done(); - } + }); }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); + }); }); - it("setting after deleting", function(done) { - var simpleObject = new Parse.Object("SimpleObject"); - simpleObject.set("foo", "bar"); - simpleObject.save(null, { - success: function(simpleObject) { - simpleObject.unset("foo"); - simpleObject.set("foo", "baz"); - simpleObject.save(null, { - success: function(simpleObject) { - var query = new Parse.Query("SimpleObject"); - query.get(simpleObject.id, { - success: function(simpleObjectAgain) { - equal(simpleObjectAgain.get("foo"), "baz"); - done(); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); + it('addUnique', function(done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, 2]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.addUnique('stuff', 2); + x2.addUnique('stuff', 4); + expect(x2.get('stuff')).toEqual([2, 4]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + const stuff = x3.get('stuff'); + const expected = [1, 2, 4]; + expect(stuff.length).toBe(expected.length); + for (const i of stuff) { + expect(expected.indexOf(i) >= 0).toBe(true); } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }); - - it("increment", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.save({ - foo: 5 - }, { - success: function(simple) { - simple.increment("foo"); - equal(simple.get("foo"), 6); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - equal(simple.get("foo"), 6); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - equal(simpleAgain.get("foo"), 6); - done(); + done(); + }, + error => { + on_db('mongo', () => { + jfail(error); + }); + on_db('postgres', () => { + expect(error.message).toEqual( + 'Postgres does not support AddUnique operator.' + ); + }); + done(); + } + ); + }); + + it('addUnique with object', function(done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, { hello: 'world' }, { foo: 'bar' }]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.addUnique('stuff', { hello: 'world' }); + x2.addUnique('stuff', { bar: 'baz' }); + expect(x2.get('stuff')).toEqual([{ hello: 'world' }, { bar: 'baz' }]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + const stuff = x3.get('stuff'); + const target = [ + 1, + { hello: 'world' }, + { foo: 'bar' }, + { bar: 'baz' }, + ]; + expect(stuff.length).toEqual(target.length); + let found = 0; + for (const thing in target) { + for (const st in stuff) { + if (st == thing) { + found++; } - }); - } - }); - } - }); - }); - - it("addUnique", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [1, 2]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.addUnique('stuff', 2); - x2.addUnique('stuff', 4); - expect(x2.get('stuff')).toEqual([2, 4]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - const stuff = x3.get('stuff'); - const expected = [1, 2, 4]; - expect(stuff.length).toBe(expected.length); - for (var i of stuff) { - expect(expected.indexOf(i) >= 0).toBe(true); - } - done(); - }, (error) => { - on_db('mongo', () => { - jfail(error); - }); - on_db('postgres', () => { - expect(error.message).toEqual("Postgres does not support AddUnique operator."); - }); - done(); - }); - }); - - it("addUnique with object", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [ 1, {'hello': 'world'}, {'foo': 'bar'}]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.addUnique('stuff', {'hello': 'world'}); - x2.addUnique('stuff', {'bar': 'baz'}); - expect(x2.get('stuff')).toEqual([{'hello': 'world'}, {'bar': 'baz'}]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - const stuff = x3.get('stuff'); - const target = [1, {'hello': 'world'}, {'foo': 'bar'}, {'bar': 'baz'}]; - expect(stuff.length).toEqual(target.length); - let found = 0; - for (const thing in target) { - for (const st in stuff) { - if (st == thing) { - found++; + } } + expect(found).toBe(target.length); + done(); + }, + error => { + jfail(error); + done(); } - } - expect(found).toBe(target.length); - done(); - }, (error) => { - jfail(error); - done(); - }); - }); - - it("removes with object", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [ 1, {'hello': 'world'}, {'foo': 'bar'}]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.remove('stuff', {'hello': 'world'}); - expect(x2.get('stuff')).toEqual([]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - expect(x3.get('stuff')).toEqual([1, {'foo': 'bar'}]); - done(); - }, (error) => { - jfail(error); - done(); - }); + ); + }); + + it('removes with object', function(done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, { hello: 'world' }, { foo: 'bar' }]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.remove('stuff', { hello: 'world' }); + expect(x2.get('stuff')).toEqual([]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + expect(x3.get('stuff')).toEqual([1, { foo: 'bar' }]); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it("dirty attributes", function(done) { - var object = new Parse.Object("TestObject"); - object.set("cat", "good"); - object.set("dog", "bad"); - object.save({ - success: function(object) { + it('dirty attributes', function(done) { + const object = new Parse.Object('TestObject'); + object.set('cat', 'good'); + object.set('dog', 'bad'); + object.save().then( + function(object) { ok(!object.dirty()); - ok(!object.dirty("cat")); - ok(!object.dirty("dog")); + ok(!object.dirty('cat')); + ok(!object.dirty('dog')); - object.set("dog", "okay"); + object.set('dog', 'okay'); ok(object.dirty()); - ok(!object.dirty("cat")); - ok(object.dirty("dog")); + ok(!object.dirty('cat')); + ok(object.dirty('dog')); done(); }, - error: function() { - ok(false, "This should have saved."); + function() { + ok(false, 'This should have saved.'); done(); } - }); + ); }); - it("dirty keys", function(done) { - var object = new Parse.Object("TestObject"); - object.set("gogo", "good"); - object.set("sito", "sexy"); + it('dirty keys', function(done) { + const object = new Parse.Object('TestObject'); + object.set('gogo', 'good'); + object.set('sito', 'sexy'); ok(object.dirty()); - var dirtyKeys = object.dirtyKeys(); + let dirtyKeys = object.dirtyKeys(); equal(dirtyKeys.length, 2); - ok(arrayContains(dirtyKeys, "gogo")); - ok(arrayContains(dirtyKeys, "sito")); - - object.save().then(function(obj) { - ok(!obj.dirty()); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 0); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(!arrayContains(dirtyKeys, "sito")); - - // try removing keys - obj.unset("sito"); - ok(obj.dirty()); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 1); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(arrayContains(dirtyKeys, "sito")); - - return obj.save(); - }).then(function(obj) { - ok(!obj.dirty()); - equal(obj.get("gogo"), "good"); - equal(obj.get("sito"), undefined); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 0); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(!arrayContains(dirtyKeys, "sito")); + ok(arrayContains(dirtyKeys, 'gogo')); + ok(arrayContains(dirtyKeys, 'sito')); + + object + .save() + .then(function(obj) { + ok(!obj.dirty()); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 0); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(!arrayContains(dirtyKeys, 'sito')); + + // try removing keys + obj.unset('sito'); + ok(obj.dirty()); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 1); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(arrayContains(dirtyKeys, 'sito')); + + return obj.save(); + }) + .then(function(obj) { + ok(!obj.dirty()); + equal(obj.get('gogo'), 'good'); + equal(obj.get('sito'), undefined); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 0); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(!arrayContains(dirtyKeys, 'sito')); - done(); - }); + done(); + }); }); - it("length attribute", function(done) { - Parse.User.signUp("bob", "password", null, { - success: function(user) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject({ - length: 5, - ACL: new Parse.ACL(user) // ACLs cause things like validation to run - }); - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - obj.save(null, { - success: function(obj) { - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(obj) { - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - obj = results[0]; - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - done(); - }, - error: function(error) { - ok(false, error.code + ": " + error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.code + ": " + error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.code + ": " + error.message); + it('length attribute', function(done) { + Parse.User.signUp('bob', 'password').then(function(user) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject({ + length: 5, + ACL: new Parse.ACL(user), // ACLs cause things like validation to run + }); + equal(obj.get('length'), 5); + ok(obj.get('ACL') instanceof Parse.ACL); + + obj.save().then(function(obj) { + equal(obj.get('length'), 5); + ok(obj.get('ACL') instanceof Parse.ACL); + + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(obj) { + equal(obj.get('length'), 5); + ok(obj.get('ACL') instanceof Parse.ACL); + + const query = new Parse.Query(TestObject); + query.find().then(function(results) { + obj = results[0]; + equal(obj.get('length'), 5); + ok(obj.get('ACL') instanceof Parse.ACL); + done(); - } + }); }); - }, - error: function(user, error) { - ok(false, error.code + ": " + error.message); - done(); - } + }); }); }); - it("old attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute unset then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function() { + obj.unset('x'); + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('new attribute unset then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); + obj.unset('x'); + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('unknown attribute unset then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.unset('x'); + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("old attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.unset("x"); - obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute unset then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function() { + obj.unset('x'); + obj.clear(); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); - obj.unset("x"); + it('new attribute unset then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); + obj.unset('x'); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.unset("x"); + it('unknown attribute unset then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.unset('x'); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("old attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute clear then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function() { + obj.clear(); + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); + it('new attribute clear then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); + it('unknown attribute clear then unset', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.unset('x'); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("old attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.clear(); - obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute clear then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function() { + obj.clear(); + obj.clear(); + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); + it('new attribute clear then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); obj.clear(); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); + it('unknown attribute clear then clear', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); obj.clear(); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function() { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("saving children in an array", function(done) { - var Parent = Parse.Object.extend("Parent"); - var Child = Parse.Object.extend("Child"); + it('saving children in an array', function(done) { + const Parent = Parse.Object.extend('Parent'); + const Child = Parse.Object.extend('Child'); - var child1 = new Child(); - var child2 = new Child(); - var parent = new Parent(); + const child1 = new Child(); + const child2 = new Child(); + const parent = new Parent(); child1.set('name', 'jamie'); child2.set('name', 'cersei'); parent.set('children', [child1, child2]); - parent.save(null, { - success: function() { - var query = new Parse.Query(Child); - query.ascending('name'); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'cersei'); - equal(results[1].get('name'), 'jamie'); - done(); - } - }); - }, - error: function(error) { - fail(error); + parent.save().then(function() { + const query = new Parse.Query(Child); + query.ascending('name'); + query.find().then(function(results) { + equal(results.length, 2); + equal(results[0].get('name'), 'cersei'); + equal(results[1].get('name'), 'jamie'); done(); - } - }); + }); + }, done.fail); }); - it("two saves at the same time", function(done) { - - var object = new Parse.Object("TestObject"); - var firstSave = true; + it('two saves at the same time', function(done) { + const object = new Parse.Object('TestObject'); + let firstSave = true; - var success = function() { + const success = function() { if (firstSave) { firstSave = false; return; } - var query = new Parse.Query("TestObject"); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get("cat"), "meow"); - equal(results[0].get("dog"), "bark"); - done(); - } + const query = new Parse.Query('TestObject'); + query.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('cat'), 'meow'); + equal(results[0].get('dog'), 'bark'); + done(); }); }; - var options = { success: success, error: fail }; - - object.save({ cat: "meow" }, options); - object.save({ dog: "bark" }, options); + object.save({ cat: 'meow' }).then(success, fail); + object.save({ dog: 'bark' }).then(success, fail); }); // The schema-checking parts of this are working. @@ -1131,74 +1030,78 @@ describe('Parse.Object testing', () => { // If this fails, it's probably a schema issue. it('many saves after a failure', function(done) { // Make a class with a number in the schema. - var o1 = new Parse.Object('TestObject'); + const o1 = new Parse.Object('TestObject'); o1.set('number', 1); - var object = null; - o1.save().then(() => { - object = new Parse.Object('TestObject'); - object.set('number', 'two'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - - object.set('other', 'foo'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - - object.set('other', 'bar'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + let object = null; + o1.save() + .then(() => { + object = new Parse.Object('TestObject'); + object.set('number', 'two'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + + object.set('other', 'foo'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + + object.set('other', 'bar'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - done(); - }); + done(); + }); }); - it("is not dirty after save", function(done) { - var obj = new Parse.Object("TestObject"); - obj.save(expectSuccess({ - success: function() { - obj.set({ "content": "x" }); - obj.fetch(expectSuccess({ - success: function(){ - equal(false, obj.dirty("content")); - done(); - } - })); - } - })); + it('is not dirty after save', function(done) { + const obj = new Parse.Object('TestObject'); + obj.save().then(function() { + obj.set({ content: 'x' }); + obj.fetch().then(function() { + equal(false, obj.dirty('content')); + done(); + }); + }); }); - it("add with an object", function(done) { - var child = new Parse.Object("Person"); - var parent = new Parse.Object("Person"); - - Parse.Promise.as().then(function() { - return child.save(); - - }).then(function() { - parent.add("children", child); - return parent.save(); - - }).then(function() { - var query = new Parse.Query("Person"); - return query.get(parent.id); - - }).then(function(parentAgain) { - equal(parentAgain.get("children")[0].id, child.id); - - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); - }); + it('add with an object', function(done) { + const child = new Parse.Object('Person'); + const parent = new Parse.Object('Person'); + + Promise.resolve() + .then(function() { + return child.save(); + }) + .then(function() { + parent.add('children', child); + return parent.save(); + }) + .then(function() { + const query = new Parse.Query('Person'); + return query.get(parent.id); + }) + .then(function(parentAgain) { + equal(parentAgain.get('children')[0].id, child.id); + }) + .then( + function() { + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); }); - it("toJSON saved object", function(done) { - create({ "foo" : "bar" }, function(model) { - var objJSON = model.toJSON(); + it('toJSON saved object', function(done) { + create({ foo: 'bar' }, function(model) { + const objJSON = model.toJSON(); ok(objJSON.foo, "expected json to contain key 'foo'"); ok(objJSON.objectId, "expected json to contain key 'objectId'"); ok(objJSON.createdAt, "expected json to contain key 'createdAt'"); @@ -1207,777 +1110,994 @@ describe('Parse.Object testing', () => { }); }); - it("remove object from array", function(done) { - var obj = new TestObject(); - obj.save(null, expectSuccess({ - success: function() { - var container = new TestObject(); - container.add("array", obj); - equal(container.get("array").length, 1); - container.save(null, expectSuccess({ - success: function() { - var objAgain = new TestObject(); - objAgain.id = obj.id; - container.remove("array", objAgain); - equal(container.get("array").length, 0); - done(); - } - })); - } - })); + it('remove object from array', function(done) { + const obj = new TestObject(); + obj.save().then(function() { + const container = new TestObject(); + container.add('array', obj); + equal(container.get('array').length, 1); + container.save(null).then(function() { + const objAgain = new TestObject(); + objAgain.id = obj.id; + container.remove('array', objAgain); + equal(container.get('array').length, 0); + done(); + }); + }); }); - it("async methods", function(done) { - var obj = new TestObject(); - obj.set("time", "adventure"); - - obj.save().then(function(obj) { - ok(obj.id, "objectId should not be null."); - var objAgain = new TestObject(); - objAgain.id = obj.id; - return objAgain.fetch(); - - }).then(function(objAgain) { - equal(objAgain.get("time"), "adventure"); - return objAgain.destroy(); - - }).then(function() { - var query = new Parse.Query(TestObject); - return query.find(); - - }).then(function(results) { - equal(results.length, 0); - - }).then(function() { - done(); - - }); + it('async methods', function(done) { + const obj = new TestObject(); + obj.set('time', 'adventure'); + + obj + .save() + .then(function(obj) { + ok(obj.id, 'objectId should not be null.'); + const objAgain = new TestObject(); + objAgain.id = obj.id; + return objAgain.fetch(); + }) + .then(function(objAgain) { + equal(objAgain.get('time'), 'adventure'); + return objAgain.destroy(); + }) + .then(function() { + const query = new Parse.Query(TestObject); + return query.find(); + }) + .then(function(results) { + equal(results.length, 0); + }) + .then(function() { + done(); + }); }); - it("fail validation with promise", function(done) { - var PickyEater = Parse.Object.extend("PickyEater", { + it('fail validation with promise', function(done) { + const PickyEater = Parse.Object.extend('PickyEater', { validate: function(attrs) { - if (attrs.meal === "tomatoes") { - return "Ew. Tomatoes are gross."; + if (attrs.meal === 'tomatoes') { + return 'Ew. Tomatoes are gross.'; } return Parse.Object.prototype.validate.apply(this, arguments); - } + }, }); - var bryan = new PickyEater(); - bryan.save({ - meal: "burrito" - }).then(function() { - return bryan.save({ - meal: "tomatoes" - }); - }, function() { - ok(false, "Save should have succeeded."); - }).then(function() { - ok(false, "Save should have failed."); - }, function(error) { - equal(error, "Ew. Tomatoes are gross."); - done(); - }); + const bryan = new PickyEater(); + bryan + .save({ + meal: 'burrito', + }) + .then( + function() { + return bryan.save({ + meal: 'tomatoes', + }); + }, + function() { + ok(false, 'Save should have succeeded.'); + } + ) + .then( + function() { + ok(false, 'Save should have failed.'); + }, + function(error) { + equal(error, 'Ew. Tomatoes are gross.'); + done(); + } + ); }); it("beforeSave doesn't make object dirty with new field", function(done) { - var restController = Parse.CoreManager.getRESTController(); - var r = restController.request; + const restController = Parse.CoreManager.getRESTController(); + const r = restController.request; restController.request = function() { return r.apply(this, arguments).then(function(result) { - result.aDate = {"__type":"Date", "iso":"2014-06-24T06:06:06.452Z"}; + result.aDate = { __type: 'Date', iso: '2014-06-24T06:06:06.452Z' }; return result; }); }; - var obj = new Parse.Object("Thing"); - obj.save().then(function() { - ok(!obj.dirty(), "The object should not be dirty"); - ok(obj.get('aDate')); - - }).always(function() { - restController.request = r; - done(); - }); + const obj = new Parse.Object('Thing'); + obj + .save() + .then(function() { + ok(!obj.dirty(), 'The object should not be dirty'); + ok(obj.get('aDate')); + }) + .then(function() { + restController.request = r; + done(); + }); }); - it("beforeSave doesn't make object dirty with existing field", function(done) { - var restController = Parse.CoreManager.getRESTController(); - var r = restController.request; + xit("beforeSave doesn't make object dirty with existing field", function(done) { + const restController = Parse.CoreManager.getRESTController(); + const r = restController.request; restController.request = function() { - return r.apply(this, arguments).then(function(result) { - result.aDate = {"__type":"Date", "iso":"2014-06-24T06:06:06.452Z"}; + return r.apply(restController, arguments).then(function(result) { + result.aDate = { __type: 'Date', iso: '2014-06-24T06:06:06.452Z' }; return result; }); }; - var now = new Date(); + const now = new Date(); - var obj = new Parse.Object("Thing"); - var promise = obj.save(); + const obj = new Parse.Object('Thing'); + const promise = obj.save(); obj.set('aDate', now); - promise.then(function() { - ok(obj.dirty(), "The object should be dirty"); - equal(now, obj.get('aDate')); - - }).always(function() { - restController.request = r; - done(); - }); + promise + .then(function() { + ok(obj.dirty(), 'The object should be dirty'); + equal(now, obj.get('aDate')); + }) + .then(function() { + restController.request = r; + done(); + }); }); - it("bytes work", function(done) { - Parse.Promise.as().then(function() { - var obj = new TestObject(); - obj.set("bytes", { __type: "Bytes", base64: "ZnJveW8=" }); - return obj.save(); - - }).then(function(obj) { - var query = new Parse.Query(TestObject); - return query.get(obj.id); - - }).then(function(obj) { - equal(obj.get("bytes").__type, "Bytes"); - equal(obj.get("bytes").base64, "ZnJveW8="); - done(); - - }, function(error) { - ok(false, JSON.stringify(error)); - done(); - - }); + it('bytes work', function(done) { + Promise.resolve() + .then(function() { + const obj = new TestObject(); + obj.set('bytes', { __type: 'Bytes', base64: 'ZnJveW8=' }); + return obj.save(); + }) + .then(function(obj) { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then( + function(obj) { + equal(obj.get('bytes').__type, 'Bytes'); + equal(obj.get('bytes').base64, 'ZnJveW8='); + done(); + }, + function(error) { + ok(false, JSON.stringify(error)); + done(); + } + ); }); - it("destroyAll no objects", function(done) { - Parse.Object.destroyAll([], function(success, error) { - ok(success && !error, "Should be able to destroy no objects"); - done(); - }); + it('destroyAll no objects', function(done) { + Parse.Object.destroyAll([]) + .then(function(success) { + ok(success, 'Should be able to destroy no objects'); + done(); + }) + .catch(done.fail); }); - it("destroyAll new objects only", function(done) { - - var objects = [new TestObject(), new TestObject()]; - Parse.Object.destroyAll(objects, function(success, error) { - ok(success && !error, "Should be able to destroy only new objects"); - done(); - }); + it('destroyAll new objects only', function(done) { + const objects = [new TestObject(), new TestObject()]; + Parse.Object.destroyAll(objects) + .then(function(success) { + ok(success, 'Should be able to destroy only new objects'); + done(); + }) + .catch(done.fail); }); - it("fetchAll", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAll', function(done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); + Parse.Object.saveAll(items) + .then(function() { + container.set('items', items); + return container.save(); + }) + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); + done(); + return; + } + equal(itemsAgain.length, numItems, 'Should get the array back'); + itemsAgain.forEach(function(item, i) { + const newValue = i * 2; + item.set('x', newValue); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function() { + return Parse.Object.fetchAll(items); + }) + .then(function(fetchedItemsAgain) { + equal( + fetchedItemsAgain.length, + numItems, + 'Number of items fetched should not change' + ); + fetchedItemsAgain.forEach(function(item, i) { + equal(item.get('x'), i * 2); + }); done(); - return; - } - equal(itemsAgain.length, numItems, "Should get the array back"); - itemsAgain.forEach(function(item, i) { - var newValue = i * 2; - item.set("x", newValue); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAll(items); - }).then(function(fetchedItemsAgain) { - equal(fetchedItemsAgain.length, numItems, - "Number of items fetched should not change"); - fetchedItemsAgain.forEach(function(item, i) { - equal(item.get("x"), i * 2); }); - done(); - }); }); - it("fetchAll no objects", function(done) { - Parse.Object.fetchAll([], function(success, error) { - ok(success && !error, "Should be able to fetchAll no objects"); - done(); - }); - }); - - it("fetchAll updates dates", function(done) { - var updatedObject; - var object = new TestObject(); - object.set("x", 7); - object.save().then(function() { - var query = new Parse.Query(TestObject); - return query.find(object.id); - }).then(function(results) { - updatedObject = results[0]; - updatedObject.set("x", 11); - return updatedObject.save(); - }).then(function() { - return Parse.Object.fetchAll([object]); - }).then(function() { - equal(object.createdAt.getTime(), updatedObject.createdAt.getTime()); - equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime()); - done(); - }); + it('fetchAll no objects', function(done) { + Parse.Object.fetchAll([]) + .then(function(success) { + ok(Array.isArray(success), 'Should be able to fetchAll no objects'); + done(); + }) + .catch(done.fail); + }); + + it('fetchAll updates dates', function(done) { + let updatedObject; + const object = new TestObject(); + object.set('x', 7); + object + .save() + .then(function() { + const query = new Parse.Query(TestObject); + return query.find(object.id); + }) + .then(function(results) { + updatedObject = results[0]; + updatedObject.set('x', 11); + return updatedObject.save(); + }) + .then(function() { + return Parse.Object.fetchAll([object]); + }) + .then(function() { + equal(object.createdAt.getTime(), updatedObject.createdAt.getTime()); + equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime()); + done(); + }); }); - it("fetchAll backbone-style callbacks", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + xit('fetchAll backbone-style callbacks', function(done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); - done(); - return; - } - equal(itemsAgain.length, numItems, "Should get the array back"); - itemsAgain.forEach(function(item, i) { - var newValue = i * 2; - item.set("x", newValue); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAll(items, { - success: function(fetchedItemsAgain) { - equal(fetchedItemsAgain.length, numItems, - "Number of items fetched should not change"); - fetchedItemsAgain.forEach(function(item, i) { - equal(item.get("x"), i * 2); - }); - done(); - }, - error: function() { - ok(false, "Failed to fetchAll"); + Parse.Object.saveAll(items) + .then(function() { + container.set('items', items); + return container.save(); + }) + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); done(); + return; } + equal(itemsAgain.length, numItems, 'Should get the array back'); + itemsAgain.forEach(function(item, i) { + const newValue = i * 2; + item.set('x', newValue); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function() { + return Parse.Object.fetchAll(items).then( + function(fetchedItemsAgain) { + equal( + fetchedItemsAgain.length, + numItems, + 'Number of items fetched should not change' + ); + fetchedItemsAgain.forEach(function(item, i) { + equal(item.get('x'), i * 2); + }); + done(); + }, + function() { + ok(false, 'Failed to fetchAll'); + done(); + } + ); }); - }); }); - it("fetchAll error on multiple classes", function(done) { - var container = new Container(); - container.set("item", new Item()); - container.set("subcontainer", new Container()); - return container.save().then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var subContainerAgain = containerAgain.get("subcontainer"); - var itemAgain = containerAgain.get("item"); - var multiClassArray = [subContainerAgain, itemAgain]; - return Parse.Object.fetchAll( - multiClassArray, - expectError(Parse.Error.INVALID_CLASS_NAME, done)); - }); + it('fetchAll error on multiple classes', function(done) { + const container = new Container(); + container.set('item', new Item()); + container.set('subcontainer', new Container()); + return container + .save() + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const subContainerAgain = containerAgain.get('subcontainer'); + const itemAgain = containerAgain.get('item'); + const multiClassArray = [subContainerAgain, itemAgain]; + return Parse.Object.fetchAll(multiClassArray).catch(e => { + expect(e.code).toBe(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); }); - it("fetchAll error on unsaved object", function(done) { - var unsavedObjectArray = [new TestObject()]; - Parse.Object.fetchAll(unsavedObjectArray, - expectError(Parse.Error.MISSING_OBJECT_ID, done)); + it('fetchAll error on unsaved object', async function(done) { + const unsavedObjectArray = [new TestObject()]; + await Parse.Object.fetchAll(unsavedObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.MISSING_OBJECT_ID); + done(); + }); }); - it("fetchAll error on deleted object", function(done) { - var numItems = 11; - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAll error on deleted object', function(done) { + const numItems = 11; + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(Item); - return query.get(items[0].id); - }).then(function(objectToDelete) { - return objectToDelete.destroy(); - }).then(function(deletedObject) { - var nonExistentObject = new Item({ objectId: deletedObject.id }); - var nonExistentObjectArray = [nonExistentObject, items[1]]; - return Parse.Object.fetchAll( - nonExistentObjectArray, - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - }); + Parse.Object.saveAll(items) + .then(function() { + const query = new Parse.Query(Item); + return query.get(items[0].id); + }) + .then(function(objectToDelete) { + return objectToDelete.destroy(); + }) + .then(function(deletedObject) { + const nonExistentObject = new Item({ objectId: deletedObject.id }); + const nonExistentObjectArray = [nonExistentObject, items[1]]; + return Parse.Object.fetchAll(nonExistentObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + }); }); // TODO: Verify that with Sessions, this test is wrong... A fetch on // user should not bring down a session token. - xit("fetchAll User attributes get merged", function(done) { - var sameUser; - var user = new Parse.User(); - user.set("username", "asdf"); - user.set("password", "zxcv"); - user.set("foo", "bar"); - user.signUp().then(function() { - Parse.User.logOut(); - var query = new Parse.Query(Parse.User); - return query.get(user.id); - }).then(function(userAgain) { - user = userAgain; - sameUser = new Parse.User(); - sameUser.set("username", "asdf"); - sameUser.set("password", "zxcv"); - return sameUser.logIn(); - }).then(function() { - ok(!user.getSessionToken(), "user should not have a sessionToken"); - ok(sameUser.getSessionToken(), "sameUser should have a sessionToken"); - sameUser.set("baz", "qux"); - return sameUser.save(); - }).then(function() { - return Parse.Object.fetchAll([user]); - }).then(function() { - equal(user.getSessionToken(), sameUser.getSessionToken()); - equal(user.createdAt.getTime(), sameUser.createdAt.getTime()); - equal(user.updatedAt.getTime(), sameUser.updatedAt.getTime()); - Parse.User.logOut(); - done(); - }); + xit('fetchAll User attributes get merged', function(done) { + let sameUser; + let user = new Parse.User(); + user.set('username', 'asdf'); + user.set('password', 'zxcv'); + user.set('foo', 'bar'); + user + .signUp() + .then(function() { + Parse.User.logOut(); + const query = new Parse.Query(Parse.User); + return query.get(user.id); + }) + .then(function(userAgain) { + user = userAgain; + sameUser = new Parse.User(); + sameUser.set('username', 'asdf'); + sameUser.set('password', 'zxcv'); + return sameUser.logIn(); + }) + .then(function() { + ok(!user.getSessionToken(), 'user should not have a sessionToken'); + ok(sameUser.getSessionToken(), 'sameUser should have a sessionToken'); + sameUser.set('baz', 'qux'); + return sameUser.save(); + }) + .then(function() { + return Parse.Object.fetchAll([user]); + }) + .then(function() { + equal(user.getSessionToken(), sameUser.getSessionToken()); + equal(user.createdAt.getTime(), sameUser.createdAt.getTime()); + equal(user.updatedAt.getTime(), sameUser.updatedAt.getTime()); + Parse.User.logOut(); + done(); + }); }); - it("fetchAllIfNeeded", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAllIfNeeded', function(done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); + Parse.Object.saveAll(items) + .then(function() { + container.set('items', items); + return container.save(); + }) + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); + done(); + return; + } + itemsAgain.forEach(function(item, i) { + item.set('x', i * 2); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function() { + return Parse.Object.fetchAllIfNeeded(items); + }) + .then(function(fetchedItems) { + equal( + fetchedItems.length, + numItems, + 'Number of items should not change' + ); + fetchedItems.forEach(function(item, i) { + equal(item.get('x'), i); + }); done(); - return; - } - itemsAgain.forEach(function(item, i) { - item.set("x", i * 2); }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAllIfNeeded(items); - }).then(function(fetchedItems) { - equal(fetchedItems.length, numItems, - "Number of items should not change"); - fetchedItems.forEach(function(item, i) { - equal(item.get("x"), i); - }); - done(); - }); }); - it("fetchAllIfNeeded backbone-style callbacks", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + xit('fetchAllIfNeeded backbone-style callbacks', function(done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); - done(); - return; - } - itemsAgain.forEach(function(item, i) { - item.set("x", i * 2); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - var items = container.get("items"); - return Parse.Object.fetchAllIfNeeded(items, { - success: function(fetchedItems) { - equal(fetchedItems.length, numItems, - "Number of items should not change"); - fetchedItems.forEach(function(item, j) { - equal(item.get("x"), j); - }); - done(); - }, - - error: function() { - ok(false, "Failed to fetchAll"); + Parse.Object.saveAll(items) + .then(function() { + container.set('items', items); + return container.save(); + }) + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); done(); + return; } + itemsAgain.forEach(function(item, i) { + item.set('x', i * 2); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function() { + const items = container.get('items'); + return Parse.Object.fetchAllIfNeeded(items).then( + function(fetchedItems) { + equal( + fetchedItems.length, + numItems, + 'Number of items should not change' + ); + fetchedItems.forEach(function(item, j) { + equal(item.get('x'), j); + }); + done(); + }, + function() { + ok(false, 'Failed to fetchAll'); + done(); + } + ); }); - }); }); - it("fetchAllIfNeeded no objects", function(done) { - Parse.Object.fetchAllIfNeeded([], function(success, error) { - ok(success && !error, "Should be able to fetchAll no objects"); + it('fetchAllIfNeeded no objects', function(done) { + Parse.Object.fetchAllIfNeeded([]) + .then(function(success) { + ok(Array.isArray(success), 'Should be able to fetchAll no objects'); + done(); + }) + .catch(done.fail); + }); + + it('fetchAllIfNeeded unsaved object', async function(done) { + const unsavedObjectArray = [new TestObject()]; + await Parse.Object.fetchAllIfNeeded(unsavedObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.MISSING_OBJECT_ID); done(); }); }); - it("fetchAllIfNeeded unsaved object", function(done) { - var unsavedObjectArray = [new TestObject()]; - Parse.Object.fetchAllIfNeeded( - unsavedObjectArray, - expectError(Parse.Error.MISSING_OBJECT_ID, done)); - }); - - it("fetchAllIfNeeded error on multiple classes", function(done) { - var container = new Container(); - container.set("item", new Item()); - container.set("subcontainer", new Container()); - return container.save().then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var subContainerAgain = containerAgain.get("subcontainer"); - var itemAgain = containerAgain.get("item"); - var multiClassArray = [subContainerAgain, itemAgain]; - return Parse.Object.fetchAllIfNeeded( - multiClassArray, - expectError(Parse.Error.INVALID_CLASS_NAME, done)); - }); + it('fetchAllIfNeeded error on multiple classes', function(done) { + const container = new Container(); + container.set('item', new Item()); + container.set('subcontainer', new Container()); + return container + .save() + .then(function() { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function(containerAgain) { + const subContainerAgain = containerAgain.get('subcontainer'); + const itemAgain = containerAgain.get('item'); + const multiClassArray = [subContainerAgain, itemAgain]; + return Parse.Object.fetchAllIfNeeded(multiClassArray).catch(e => { + expect(e.code).toBe(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); }); - it("Objects with className User", function(done) { + it('Objects with className User', function(done) { equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true); - var User1 = Parse.Object.extend({ - className: "User" + const User1 = Parse.Object.extend({ + className: 'User', }); - equal(User1.className, "_User", - "className is rewritten by default"); + equal(User1.className, '_User', 'className is rewritten by default'); Parse.User.allowCustomUserClass(true); equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), false); - var User2 = Parse.Object.extend({ - className: "User" + const User2 = Parse.Object.extend({ + className: 'User', }); - equal(User2.className, "User", - "className is not rewritten when allowCustomUserClass(true)"); + equal( + User2.className, + 'User', + 'className is not rewritten when allowCustomUserClass(true)' + ); // Set back to default so as not to break other tests. Parse.User.allowCustomUserClass(false); - equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true, "PERFORM_USER_REWRITE is reset"); - - var user = new User2(); - user.set("name", "Me"); - user.save({height: 181}, expectSuccess({ - success: function(user) { - equal(user.get("name"), "Me"); - equal(user.get("height"), 181); - - var query = new Parse.Query(User2); - query.get(user.id, expectSuccess({ - success: function(user) { - equal(user.className, "User"); - equal(user.get("name"), "Me"); - equal(user.get("height"), 181); - - done(); - } - })); - } - })); - }); - - it("create without data", function(done) { - var t1 = new TestObject({ "test" : "test" }); - t1.save().then(function(t1) { - var t2 = TestObject.createWithoutData(t1.id); - return t2.fetch(); - }).then(function(t2) { - equal(t2.get("test"), "test", "Fetch should have grabbed " + - "'test' property."); - var t3 = TestObject.createWithoutData(t2.id); - t3.set("test", "not test"); - return t3.fetch(); - }).then(function(t3) { - equal(t3.get("test"), "test", - "Fetch should have grabbed server 'test' property."); - done(); - }, function(error) { - ok(false, error); - done(); + equal( + Parse.CoreManager.get('PERFORM_USER_REWRITE'), + true, + 'PERFORM_USER_REWRITE is reset' + ); + + const user = new User2(); + user.set('name', 'Me'); + user.save({ height: 181 }).then(function(user) { + equal(user.get('name'), 'Me'); + equal(user.get('height'), 181); + + const query = new Parse.Query(User2); + query.get(user.id).then(function(user) { + equal(user.className, 'User'); + equal(user.get('name'), 'Me'); + equal(user.get('height'), 181); + done(); + }); }); }); - it("remove from new field creates array key", (done) => { - var obj = new TestObject(); + it('create without data', function(done) { + const t1 = new TestObject({ test: 'test' }); + t1.save() + .then(function(t1) { + const t2 = TestObject.createWithoutData(t1.id); + return t2.fetch(); + }) + .then(function(t2) { + equal( + t2.get('test'), + 'test', + 'Fetch should have grabbed ' + "'test' property." + ); + const t3 = TestObject.createWithoutData(t2.id); + t3.set('test', 'not test'); + return t3.fetch(); + }) + .then( + function(t3) { + equal( + t3.get('test'), + 'test', + "Fetch should have grabbed server 'test' property." + ); + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); + }); + + it('remove from new field creates array key', done => { + const obj = new TestObject(); obj.remove('shouldBeArray', 'foo'); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - return query.get(obj.id); - }).then((objAgain) => { - var arr = objAgain.get('shouldBeArray'); - ok(Array.isArray(arr), 'Should have created array key'); - ok(!arr || arr.length === 0, 'Should have an empty array.'); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + return query.get(obj.id); + }) + .then(objAgain => { + const arr = objAgain.get('shouldBeArray'); + ok(Array.isArray(arr), 'Should have created array key'); + ok(!arr || arr.length === 0, 'Should have an empty array.'); + done(); + }); }); - it("increment with type conflict fails", (done) => { - var obj = new TestObject(); + it('increment with type conflict fails', done => { + const obj = new TestObject(); obj.set('astring', 'foo'); - obj.save().then(() => { - var obj2 = new TestObject(); - obj2.increment('astring'); - return obj2.save(); - }).then(() => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(() => { + const obj2 = new TestObject(); + obj2.increment('astring'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it("increment with empty field solidifies type", (done) => { - var obj = new TestObject(); + it('increment with empty field solidifies type', done => { + const obj = new TestObject(); obj.increment('aninc'); - obj.save().then(() => { - var obj2 = new TestObject(); - obj2.set('aninc', 'foo'); - return obj2.save(); - }).then(() => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(() => { + const obj2 = new TestObject(); + obj2.set('aninc', 'foo'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it("increment update with type conflict fails", (done) => { - var obj = new TestObject(); + it('increment update with type conflict fails', done => { + const obj = new TestObject(); obj.set('someString', 'foo'); - obj.save().then((objAgain) => { - var obj2 = new TestObject(); - obj2.id = objAgain.id; - obj2.increment('someString'); - return obj2.save(); - }).then(() => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(objAgain => { + const obj2 = new TestObject(); + obj2.id = objAgain.id; + obj2.increment('someString'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it('dictionary fetched pointers do not lose data on fetch', (done) => { - var parent = new Parse.Object('Parent'); - var dict = {}; - for (var i = 0; i < 5; i++) { - var proc = (iter) => { - var child = new Parse.Object('Child'); + it('dictionary fetched pointers do not lose data on fetch', done => { + const parent = new Parse.Object('Parent'); + const dict = {}; + for (let i = 0; i < 5; i++) { + const proc = iter => { + const child = new Parse.Object('Child'); child.set('name', 'testname' + i); dict[iter] = child; }; proc(i); } parent.set('childDict', dict); - parent.save().then(() => { - return parent.fetch(); - }).then((parentAgain) => { - var dictAgain = parentAgain.get('childDict'); - if (!dictAgain) { - fail('Should have been a dictionary.'); - return done(); - } - expect(typeof dictAgain).toEqual('object'); - expect(typeof dictAgain['0']).toEqual('object'); - expect(typeof dictAgain['1']).toEqual('object'); - expect(typeof dictAgain['2']).toEqual('object'); - expect(typeof dictAgain['3']).toEqual('object'); - expect(typeof dictAgain['4']).toEqual('object'); - done(); - }); + parent + .save() + .then(() => { + return parent.fetch(); + }) + .then(parentAgain => { + const dictAgain = parentAgain.get('childDict'); + if (!dictAgain) { + fail('Should have been a dictionary.'); + return done(); + } + expect(typeof dictAgain).toEqual('object'); + expect(typeof dictAgain['0']).toEqual('object'); + expect(typeof dictAgain['1']).toEqual('object'); + expect(typeof dictAgain['2']).toEqual('object'); + expect(typeof dictAgain['3']).toEqual('object'); + expect(typeof dictAgain['4']).toEqual('object'); + done(); + }); }); - - it("should create nested keys with _", done => { - const object = new Parse.Object("AnObject"); - object.set("foo", { - "_bar": "_", - "baz_bar": 1, - "__foo_bar": true, - "_0": "underscore_zero", - "_more": { - "_nested": "key" - } - }); - object.save().then(res => { - ok(res); - return res.fetch(); - }).then(res => { - const foo = res.get("foo"); - expect(foo["_bar"]).toEqual("_"); - expect(foo["baz_bar"]).toEqual(1); - expect(foo["__foo_bar"]).toBe(true); - expect(foo["_0"]).toEqual("underscore_zero"); - expect(foo["_more"]["_nested"]).toEqual("key"); - done(); - }).fail(err => { - jfail(err); - fail("should not fail"); - done(); + it('should create nested keys with _', done => { + const object = new Parse.Object('AnObject'); + object.set('foo', { + _bar: '_', + baz_bar: 1, + __foo_bar: true, + _0: 'underscore_zero', + _more: { + _nested: 'key', + }, }); + object + .save() + .then(res => { + ok(res); + return res.fetch(); + }) + .then(res => { + const foo = res.get('foo'); + expect(foo['_bar']).toEqual('_'); + expect(foo['baz_bar']).toEqual(1); + expect(foo['__foo_bar']).toBe(true); + expect(foo['_0']).toEqual('underscore_zero'); + expect(foo['_more']['_nested']).toEqual('key'); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); }); - it('should have undefined includes when object is missing', (done) => { - const obj1 = new Parse.Object("AnObject"); - const obj2 = new Parse.Object("AnObject"); - - Parse.Object.saveAll([obj1, obj2]).then(() => { - obj1.set("obj", obj2); - // Save the pointer, delete the pointee - return obj1.save().then(() => { return obj2.destroy() }); - }).then(() => { - const query = new Parse.Query("AnObject"); - query.include("obj"); - return query.find(); - }).then((res) => { - expect(res.length).toBe(1); - if (res[0]) { - expect(res[0].get("obj")).toBe(undefined); - } - const query = new Parse.Query("AnObject"); - return query.find(); - }).then((res) => { - expect(res.length).toBe(1); - if (res[0]) { - expect(res[0].get("obj")).not.toBe(undefined); - return res[0].get("obj").fetch(); - } else { + it('should have undefined includes when object is missing', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + obj1.set('obj', obj2); + // Save the pointer, delete the pointee + return obj1.save().then(() => { + return obj2.destroy(); + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('obj'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + if (res[0]) { + expect(res[0].get('obj')).toBe(undefined); + } + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + if (res[0]) { + expect(res[0].get('obj')).not.toBe(undefined); + return res[0].get('obj').fetch(); + } else { + done(); + } + }) + .then( + () => { + fail('Should not fetch a deleted object'); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('should have undefined includes when object is missing on deeper path', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + const obj3 = new Parse.Object('AnObject'); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + obj1.set('obj', obj2); + obj2.set('obj', obj3); + // Save the pointer, delete the pointee + return Parse.Object.saveAll([obj1, obj2]).then(() => { + return obj3.destroy(); + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('obj.obj'); + return query.get(obj1.id); + }) + .then(res => { + expect(res.get('obj')).not.toBe(undefined); + expect(res.get('obj').get('obj')).toBe(undefined); done(); - } - }).then(() => { - fail("Should not fetch a deleted object"); - }, (err) => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - done(); - }) - }); - - it('should have undefined includes when object is missing on deeper path', (done) => { - const obj1 = new Parse.Object("AnObject"); - const obj2 = new Parse.Object("AnObject"); - const obj3 = new Parse.Object("AnObject"); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - obj1.set("obj", obj2); - obj2.set("obj", obj3); - // Save the pointer, delete the pointee - return Parse.Object.saveAll([obj1, obj2]).then(() => { return obj3.destroy() }); - }).then(() => { - const query = new Parse.Query("AnObject"); - query.include("obj.obj"); - return query.get(obj1.id); - }).then((res) => { - expect(res.get("obj")).not.toBe(undefined); - expect(res.get("obj").get("obj")).toBe(undefined); - done(); - }).catch(err => { - jfail(err); - done(); - }) + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('should handle includes on null arrays #2752', (done) => { - const obj1 = new Parse.Object("AnObject"); - const obj2 = new Parse.Object("AnotherObject"); - const obj3 = new Parse.Object("NestedObject"); + it('should handle includes on null arrays #2752', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnotherObject'); + const obj3 = new Parse.Object('NestedObject'); obj3.set({ - "foo": "bar" - }) + foo: 'bar', + }); obj2.set({ - "key": obj3 - }) - - Parse.Object.saveAll([obj1, obj2]).then(() => { - obj1.set("objects", [null, null, obj2]); - return obj1.save(); - }).then(() => { - const query = new Parse.Query("AnObject"); - query.include("objects.key"); - return query.find(); - }).then((res) => { - const obj = res[0]; - expect(obj.get("objects")).not.toBe(undefined); - const array = obj.get("objects"); - expect(Array.isArray(array)).toBe(true); - expect(array[0]).toBe(null); - expect(array[1]).toBe(null); - expect(array[2].get("key").get("foo")).toEqual("bar"); - done(); - }).catch(err => { - jfail(err); - done(); - }) + key: obj3, + }); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + obj1.set('objects', [null, null, obj2]); + return obj1.save(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('objects.key'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + expect(obj.get('objects')).not.toBe(undefined); + const array = obj.get('objects'); + expect(Array.isArray(array)).toBe(true); + expect(array[0]).toBe(null); + expect(array[1]).toBe(null); + expect(array[2].get('key').get('foo')).toEqual('bar'); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should handle select and include #2786', done => { + const score = new Parse.Object('GameScore'); + const player = new Parse.Object('Player'); + score.set({ + score: 1234, + }); + + score + .save() + .then(() => { + player.set('gameScore', score); + player.set('other', 'value'); + return player.save(); + }) + .then(() => { + const query = new Parse.Query('Player'); + query.include('gameScore'); + query.select('gameScore'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + const gameScore = obj.get('gameScore'); + const other = obj.get('other'); + expect(other).toBeUndefined(); + expect(gameScore).not.toBeUndefined(); + expect(gameScore.get('score')).toBe(1234); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('should handle select and include #2786', (done) => { - const score = new Parse.Object("GameScore"); - const player = new Parse.Object("Player"); + it('should include ACLs with select', done => { + const score = new Parse.Object('GameScore'); + const player = new Parse.Object('Player'); score.set({ - "score": 1234 + score: 1234, + }); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + + score + .save() + .then(() => { + player.set('gameScore', score); + player.set('other', 'value'); + player.setACL(acl); + return player.save(); + }) + .then(() => { + const query = new Parse.Query('Player'); + query.include('gameScore'); + query.select('gameScore'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + const gameScore = obj.get('gameScore'); + const other = obj.get('other'); + expect(other).toBeUndefined(); + expect(gameScore).not.toBeUndefined(); + expect(gameScore.get('score')).toBe(1234); + expect(obj.getACL().getPublicReadAccess()).toBe(true); + expect(obj.getACL().getPublicWriteAccess()).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('Update object field should store exactly same sent object', async done => { + let object = new TestObject(); + + // Set initial data + object.set('jsonData', { a: 'b' }); + object = await object.save(); + equal(object.get('jsonData'), { a: 'b' }); + + // Set empty JSON + object.set('jsonData', {}); + object = await object.save(); + equal(object.get('jsonData'), {}); + + // Set new JSON data + object.unset('jsonData'); + object.set('jsonData', { c: 'd' }); + object = await object.save(); + equal(object.get('jsonData'), { c: 'd' }); + + // Fetch object from server + object = await object.fetch(); + equal(object.get('jsonData'), { c: 'd' }); + + done(); + }); + + it('isNew in cloud code', async () => { + Parse.Cloud.beforeSave('CloudCodeIsNew', req => { + expect(req.object.isNew()).toBeTruthy(); + expect(req.object.id).toBeUndefined(); }); - score.save().then(() => { - player.set("gameScore", score); - player.set("other", "value"); - return player.save(); - }).then(() => { - const query = new Parse.Query("Player"); - query.include("gameScore"); - query.select("gameScore"); - return query.find(); - }).then((res) => { - const obj = res[0]; - const gameScore = obj.get("gameScore"); - const other = obj.get("other"); - expect(other).toBeUndefined(); - expect(gameScore).not.toBeUndefined(); - expect(gameScore.get("score")).toBe(1234); - done(); - }).catch(err => { - jfail(err); - done(); - }) + Parse.Cloud.afterSave('CloudCodeIsNew', req => { + expect(req.object.isNew()).toBeFalsy(); + expect(req.object.id).toBeDefined(); + }); + + const object = new Parse.Object('CloudCodeIsNew'); + await object.save(); }); }); diff --git a/spec/ParsePolygon.spec.js b/spec/ParsePolygon.spec.js index f4ca455ae7..cc04c0956c 100644 --- a/spec/ParsePolygon.spec.js +++ b/spec/ParsePolygon.spec.js @@ -1,264 +1,416 @@ const TestObject = Parse.Object.extend('TestObject'); -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; -const rp = require('request-promise'); +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const mongoURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const request = require('../lib/request'); const defaultHeaders = { 'X-Parse-Application-Id': 'test', - 'X-Parse-Rest-API-Key': 'rest' -} + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', +}; describe('Parse.Polygon testing', () => { - it('polygon save open path', (done) => { - const coords = [[0,0],[0,1],[1,1],[1,0]]; - const closed = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + beforeAll(() => require('../lib/TestUtils').destroyAllDataPermanently()); + + it('polygon save open path', done => { + const coords = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const closed = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]; const obj = new TestObject(); obj.set('polygon', new Parse.Polygon(coords)); - return obj.save().then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then((result) => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closed); - done(); - }, done.fail); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closed); + done(); + }, done.fail); }); - it('polygon save closed path', (done) => { - const coords = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + it('polygon save closed path', done => { + const coords = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]; const obj = new TestObject(); obj.set('polygon', new Parse.Polygon(coords)); - return obj.save().then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then((result) => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, coords); - done(); - }, done.fail); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, coords); + done(); + }, done.fail); }); - it('polygon equalTo (open/closed) path', (done) => { - const openPoints = [[0,0],[0,1],[1,1],[1,0]]; - const closedPoints = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + it('polygon equalTo (open/closed) path', done => { + const openPoints = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const closedPoints = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]; const openPolygon = new Parse.Polygon(openPoints); const closedPolygon = new Parse.Polygon(closedPoints); const obj = new TestObject(); obj.set('polygon', openPolygon); - return obj.save().then(() => { - const query = new Parse.Query(TestObject); - query.equalTo('polygon', openPolygon); - return query.find(); - }).then((results) => { - const polygon = results[0].get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closedPoints); - const query = new Parse.Query(TestObject); - query.equalTo('polygon', closedPolygon); - return query.find(); - }).then((results) => { - const polygon = results[0].get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closedPoints); - done(); - }, done.fail); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('polygon', openPolygon); + return query.find(); + }) + .then(results => { + const polygon = results[0].get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closedPoints); + const query = new Parse.Query(TestObject); + query.equalTo('polygon', closedPolygon); + return query.find(); + }) + .then(results => { + const polygon = results[0].get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closedPoints); + done(); + }, done.fail); }); - it('polygon update', (done) => { - const oldCoords = [[0,0],[0,1],[1,1],[1,0]]; + it('polygon update', done => { + const oldCoords = [[0, 0], [0, 1], [1, 1], [1, 0]]; const oldPolygon = new Parse.Polygon(oldCoords); - const newCoords = [[2,2],[2,3],[3,3],[3,2]]; + const newCoords = [[2, 2], [2, 3], [3, 3], [3, 2]]; const newPolygon = new Parse.Polygon(newCoords); const obj = new TestObject(); obj.set('polygon', oldPolygon); - return obj.save().then(() => { - obj.set('polygon', newPolygon); - return obj.save(); - }).then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then((result) => { - const polygon = result.get('polygon'); - newCoords.push(newCoords[0]); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, newCoords); - done(); - }, done.fail); + return obj + .save() + .then(() => { + obj.set('polygon', newPolygon); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + newCoords.push(newCoords[0]); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, newCoords); + done(); + }, done.fail); }); - it('polygon invalid value', (done) => { - const coords = [['foo','bar'],[0,1],[1,0],[1,1],[0,0]]; + it('polygon invalid value', done => { + const coords = [['foo', 'bar'], [0, 1], [1, 0], [1, 1], [0, 0]]; const obj = new TestObject(); - obj.set('polygon', {__type: 'Polygon', coordinates: coords}); - return obj.save().then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then(done.fail, done); + obj.set('polygon', { __type: 'Polygon', coordinates: coords }); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(done.fail, () => done()); }); - it('polygon three points minimum', (done) => { - const coords = [[0,0]]; + it('polygon three points minimum', done => { + const coords = [[0, 0]]; const obj = new TestObject(); // use raw so we test the server validates properly - obj.set('polygon', {__type: 'Polygon', coordinates: coords}); - obj.save().then(done.fail, done); + obj.set('polygon', { __type: 'Polygon', coordinates: coords }); + obj.save().then(done.fail, () => done()); }); - it('polygon three different points minimum', (done) => { - const coords = [[0,0],[0,1],[0,0]]; + it('polygon three different points minimum', done => { + const coords = [[0, 0], [0, 1], [0, 0]]; const obj = new TestObject(); obj.set('polygon', new Parse.Polygon(coords)); - obj.save().then(done.fail, done); + obj.save().then(done.fail, () => done()); }); - it('polygon counterclockwise', (done) => { - const coords = [[1,1],[0,1],[0,0],[1,0]]; - const closed = [[1,1],[0,1],[0,0],[1,0],[1,1]]; + it('polygon counterclockwise', done => { + const coords = [[1, 1], [0, 1], [0, 0], [1, 0]]; + const closed = [[1, 1], [0, 1], [0, 0], [1, 0], [1, 1]]; const obj = new TestObject(); obj.set('polygon', new Parse.Polygon(coords)); - obj.save().then(() => { - const query = new Parse.Query(TestObject); - return query.get(obj.id); - }).then((result) => { - const polygon = result.get('polygon'); - equal(polygon instanceof Parse.Polygon, true); - equal(polygon.coordinates, closed); - done(); - }, done.fail); + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closed); + done(); + }, done.fail); }); - it('polygonContain query', (done) => { - const points1 = [[0,0],[0,1],[1,1],[1,0]]; - const points2 = [[0,0],[0,2],[2,2],[2,0]]; - const points3 = [[10,10],[10,15],[15,15],[15,10],[10,10]]; - const polygon1 = new Parse.Polygon(points1); - const polygon2 = new Parse.Polygon(points2); - const polygon3 = new Parse.Polygon(points3); - const obj1 = new TestObject({location: polygon1}); - const obj2 = new TestObject({location: polygon2}); - const obj3 = new TestObject({location: polygon3}); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const where = { - location: { - $geoIntersects: { - $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 0.5 } - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(2); - done(); - }, done.fail); - }); + describe('with location', () => { + beforeAll(() => require('../lib/TestUtils').destroyAllDataPermanently()); - it('polygonContain invalid input', (done) => { - const points = [[0,0],[0,1],[1,1],[1,0]]; - const polygon = new Parse.Polygon(points); - const obj = new TestObject({location: polygon}); - obj.save().then(() => { - const where = { - location: { - $geoIntersects: { - $point: { __type: 'GeoPoint', latitude: 181, longitude: 181 } - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } - }); - }).then(done.fail, done); - }); + it('polygonContain query', done => { + const points1 = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const points2 = [[0, 0], [0, 2], [2, 2], [2, 0]]; + const points3 = [[10, 10], [10, 15], [15, 15], [15, 10], [10, 10]]; + const polygon1 = new Parse.Polygon(points1); + const polygon2 = new Parse.Polygon(points2); + const polygon3 = new Parse.Polygon(points3); + const obj1 = new TestObject({ location: polygon1 }); + const obj2 = new TestObject({ location: polygon2 }); + const obj3 = new TestObject({ location: polygon3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 0.5 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); - it('polygonContain invalid geoPoint', (done) => { - const points = [[0,0],[0,1],[1,1],[1,0]]; - const polygon = new Parse.Polygon(points); - const obj = new TestObject({location: polygon}); - obj.save().then(() => { - const where = { - location: { - $geoIntersects: { - $point: [] - } - } - }; - return rp.post({ - url: Parse.serverURL + '/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } - }); - }).then(done.fail, done); + it('polygonContain query no reverse input (Regression test for #4608)', done => { + const points1 = [[0.25, 0], [0.25, 1.25], [0.75, 1.25], [0.75, 0]]; + const points2 = [[0, 0], [0, 2], [2, 2], [2, 0]]; + const points3 = [[10, 10], [10, 15], [15, 15], [15, 10], [10, 10]]; + const polygon1 = new Parse.Polygon(points1); + const polygon2 = new Parse.Polygon(points2); + const polygon3 = new Parse.Polygon(points3); + const obj1 = new TestObject({ location: polygon1 }); + const obj2 = new TestObject({ location: polygon2 }); + const obj3 = new TestObject({ location: polygon3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 1.0 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('polygonContain query real data (Regression test for #4608)', done => { + const detroit = [ + [42.631655189280224, -83.78406753121705], + [42.633047793854814, -83.75333640366955], + [42.61625254348911, -83.75149921669944], + [42.61526926650296, -83.78161794858735], + [42.631655189280224, -83.78406753121705], + ]; + const polygon = new Parse.Polygon(detroit); + const obj = new TestObject({ location: polygon }); + obj + .save() + .then(() => { + const where = { + location: { + $geoIntersects: { + $point: { + __type: 'GeoPoint', + latitude: 42.624599, + longitude: -83.770162, + }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(1); + done(); + }, done.fail); + }); + + it('polygonContain invalid input', done => { + const points = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const polygon = new Parse.Polygon(points); + const obj = new TestObject({ location: polygon }); + obj + .save() + .then(() => { + const where = { + location: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 181, longitude: 181 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, + }); + }) + .then(done.fail, () => done()); + }); + + it('polygonContain invalid geoPoint', done => { + const points = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const polygon = new Parse.Polygon(points); + const obj = new TestObject({ location: polygon }); + obj + .save() + .then(() => { + const where = { + location: { + $geoIntersects: { + $point: [], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, + }); + }) + .then(done.fail, () => done()); + }); }); }); describe_only_db('mongo')('Parse.Polygon testing', () => { - it('support 2d and 2dsphere', (done) => { - const coords = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + beforeEach(() => require('../lib/TestUtils').destroyAllDataPermanently()); + it('support 2d and 2dsphere', done => { + const coords = [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]; // testings against REST API, use raw formats - const polygon = {__type: 'Polygon', coordinates: coords}; - const location = {__type: 'GeoPoint', latitude:10, longitude:10}; + const polygon = { __type: 'Polygon', coordinates: coords }; + const location = { __type: 'GeoPoint', latitude: 10, longitude: 10 }; const databaseAdapter = new MongoStorageAdapter({ uri: mongoURI }); return reconfigureServer({ appId: 'test', restAPIKey: 'rest', publicServerURL: 'http://localhost:8378/1', - databaseAdapter - }).then(() => { - return databaseAdapter.createIndex('TestObject', {location: '2d'}); - }).then(() => { - return databaseAdapter.createIndex('TestObject', {polygon: '2dsphere'}); - }).then(() => { - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { - '_method': 'POST', - location, - polygon, - polygon2: polygon - }, - headers: defaultHeaders - }); - }).then((resp) => { - return rp.post({ - url: `http://localhost:8378/1/classes/TestObject/${resp.objectId}`, - json: {'_method': 'GET'}, - headers: defaultHeaders + databaseAdapter, + }) + .then(() => { + return databaseAdapter.createIndex('TestObject', { location: '2d' }); + }) + .then(() => { + return databaseAdapter.createIndex('TestObject', { + polygon: '2dsphere', + }); + }) + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { + _method: 'POST', + location, + polygon, + polygon2: polygon, + }, + headers: defaultHeaders, + }); + }) + .then(resp => { + return request({ + method: 'POST', + url: `http://localhost:8378/1/classes/TestObject/${ + resp.data.objectId + }`, + body: { _method: 'GET' }, + headers: defaultHeaders, + }); + }) + .then(resp => { + equal(resp.data.location, location); + equal(resp.data.polygon, polygon); + equal(resp.data.polygon2, polygon); + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + equal(indexes.length, 4); + equal(indexes[0].key, { _id: 1 }); + equal(indexes[1].key, { location: '2d' }); + equal(indexes[2].key, { polygon: '2dsphere' }); + equal(indexes[3].key, { polygon2: '2dsphere' }); + done(); + }, done.fail); + }); + + it('polygon coordinates reverse input', done => { + const Config = require('../lib/Config'); + const config = Config.get('test'); + + // When stored the first point should be the last point + const input = [[12, 11], [14, 13], [16, 15], [18, 17]]; + const output = [[[11, 12], [13, 14], [15, 16], [17, 18], [11, 12]]]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(input)); + obj + .save() + .then(() => { + return config.database.adapter._rawFind('TestObject', { _id: obj.id }); + }) + .then(results => { + expect(results.length).toBe(1); + expect(results[0].polygon.coordinates).toEqual(output); + done(); }); - }).then((resp) => { - equal(resp.location, location); - equal(resp.polygon, polygon); - equal(resp.polygon2, polygon); - return databaseAdapter.getIndexes('TestObject'); - }).then((indexes) => { - equal(indexes.length, 4); - equal(indexes[0].key, {_id: 1}); - equal(indexes[1].key, {location: '2d'}); - equal(indexes[2].key, {polygon: '2dsphere'}); - equal(indexes[3].key, {polygon2: '2dsphere'}); - done(); - }, done.fail); }); - it('polygon loop is not valid', (done) => { - const coords = [[0,0],[0,1],[1,0],[1,1]]; + it('polygon loop is not valid', done => { + const coords = [[0, 0], [0, 1], [1, 0], [1, 1]]; const obj = new TestObject(); obj.set('polygon', new Parse.Polygon(coords)); - obj.save().then(done.fail, done); + obj.save().then(done.fail, () => done()); }); }); diff --git a/spec/ParsePubSub.spec.js b/spec/ParsePubSub.spec.js index 17d523b16a..3159fc7f78 100644 --- a/spec/ParsePubSub.spec.js +++ b/spec/ParsePubSub.spec.js @@ -1,80 +1,105 @@ -var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; +const ParsePubSub = require('../lib/LiveQuery/ParsePubSub').ParsePubSub; describe('ParsePubSub', function() { - beforeEach(function(done) { // Mock RedisPubSub - var mockRedisPubSub = { + const mockRedisPubSub = { createPublisher: jasmine.createSpy('createPublisherRedis'), - createSubscriber: jasmine.createSpy('createSubscriberRedis') + createSubscriber: jasmine.createSpy('createSubscriberRedis'), }; - jasmine.mockLibrary('../src/Adapters/PubSub/RedisPubSub', 'RedisPubSub', mockRedisPubSub); + jasmine.mockLibrary( + '../lib/Adapters/PubSub/RedisPubSub', + 'RedisPubSub', + mockRedisPubSub + ); // Mock EventEmitterPubSub - var mockEventEmitterPubSub = { + const mockEventEmitterPubSub = { createPublisher: jasmine.createSpy('createPublisherEventEmitter'), - createSubscriber: jasmine.createSpy('createSubscriberEventEmitter') + createSubscriber: jasmine.createSpy('createSubscriberEventEmitter'), }; - jasmine.mockLibrary('../src/Adapters/PubSub/EventEmitterPubSub', 'EventEmitterPubSub', mockEventEmitterPubSub); + jasmine.mockLibrary( + '../lib/Adapters/PubSub/EventEmitterPubSub', + 'EventEmitterPubSub', + mockEventEmitterPubSub + ); done(); }); it('can create redis publisher', function() { ParsePubSub.createPublisher({ - redisURL: 'redisURL' + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, }); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; - expect(RedisPubSub.createPublisher).toHaveBeenCalledWith({redisURL: 'redisURL'}); + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createPublisher).toHaveBeenCalledWith({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, + }); expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); }); it('can create event emitter publisher', function() { ParsePubSub.createPublisher({}); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createPublisher).toHaveBeenCalled(); }); it('can create redis subscriber', function() { ParsePubSub.createSubscriber({ - redisURL: 'redisURL' + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, }); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; - expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith({redisURL: 'redisURL'}); + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, + }); expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); }); it('can create event emitter subscriber', function() { ParsePubSub.createSubscriber({}); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createSubscriber).toHaveBeenCalled(); }); it('can create publisher/sub with custom adapter', function() { - const adapter = { + const adapter = { createPublisher: jasmine.createSpy('createPublisher'), - createSubscriber: jasmine.createSpy('createSubscriber') - } + createSubscriber: jasmine.createSpy('createSubscriber'), + }; ParsePubSub.createPublisher({ - pubSubAdapter: adapter + pubSubAdapter: adapter, }); expect(adapter.createPublisher).toHaveBeenCalled(); ParsePubSub.createSubscriber({ - pubSubAdapter: adapter + pubSubAdapter: adapter, }); expect(adapter.createSubscriber).toHaveBeenCalled(); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); @@ -82,34 +107,39 @@ describe('ParsePubSub', function() { }); it('can create publisher/sub with custom function adapter', function() { - const adapter = { + const adapter = { createPublisher: jasmine.createSpy('createPublisher'), - createSubscriber: jasmine.createSpy('createSubscriber') - } + createSubscriber: jasmine.createSpy('createSubscriber'), + }; ParsePubSub.createPublisher({ pubSubAdapter: function() { return adapter; - } + }, }); expect(adapter.createPublisher).toHaveBeenCalled(); ParsePubSub.createSubscriber({ pubSubAdapter: function() { return adapter; - } + }, }); expect(adapter.createSubscriber).toHaveBeenCalled(); - var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub') + .RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/Adapters/PubSub/RedisPubSub', 'RedisPubSub'); - jasmine.restoreLibrary('../src/Adapters/PubSub/EventEmitterPubSub', 'EventEmitterPubSub'); + afterEach(function() { + jasmine.restoreLibrary('../lib/Adapters/PubSub/RedisPubSub', 'RedisPubSub'); + jasmine.restoreLibrary( + '../lib/Adapters/PubSub/EventEmitterPubSub', + 'EventEmitterPubSub' + ); }); }); diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js new file mode 100644 index 0000000000..f68d76a489 --- /dev/null +++ b/spec/ParseQuery.Aggregate.spec.js @@ -0,0 +1,1431 @@ +'use strict'; +const Parse = require('parse/node'); +const request = require('../lib/request'); + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +const PointerObject = Parse.Object.extend({ + className: 'PointerObject', +}); + +const loadTestData = () => { + const data1 = { + score: 10, + name: 'foo', + sender: { group: 'A' }, + views: 900, + size: ['S', 'M'], + }; + const data2 = { + score: 10, + name: 'foo', + sender: { group: 'A' }, + views: 800, + size: ['M', 'L'], + }; + const data3 = { + score: 10, + name: 'bar', + sender: { group: 'B' }, + views: 700, + size: ['S'], + }; + const data4 = { + score: 20, + name: 'dpl', + sender: { group: 'B' }, + views: 700, + size: ['S'], + }; + const obj1 = new TestObject(data1); + const obj2 = new TestObject(data2); + const obj3 = new TestObject(data3); + const obj4 = new TestObject(data4); + return Parse.Object.saveAll([obj1, obj2, obj3, obj4]); +}; + +const get = function(url, options) { + options.qs = options.body; + delete options.body; + Object.keys(options.qs).forEach(key => { + options.qs[key] = JSON.stringify(options.qs[key]); + }); + return request(Object.assign({}, { url }, options)) + .then(response => response.data) + .catch(response => { + throw { error: response.data }; + }); +}; + +describe('Parse.Query Aggregate testing', () => { + beforeEach(done => { + loadTestData().then(done, done); + }); + + it('should only query aggregate with master key', done => { + Parse._request('GET', `aggregate/someClass`, {}).then( + () => {}, + error => { + expect(error.message).toEqual('unauthorized: master key is required'); + done(); + } + ); + }); + + it('invalid query invalid key', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + unknown: {}, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options).catch(error => { + expect(error.error.code).toEqual(Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it('invalid query group _id', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { _id: null }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options).catch(error => { + expect(error.error.code).toEqual(Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it('invalid query group objectId required', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: {}, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options).catch(error => { + expect(error.error.code).toEqual(Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it('group by field', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: '$name' }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId') + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(resp.results[2], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).not.toBe(undefined); + expect(resp.results[1].objectId).not.toBe(undefined); + expect(resp.results[2].objectId).not.toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('group by pipeline operator', async () => { + const options = Object.assign({}, masterKeyOptions, { + body: { + pipeline: { + group: { objectId: '$name' }, + }, + }, + }); + const resp = await get(Parse.serverURL + '/aggregate/TestObject', options); + expect(resp.results.length).toBe(3); + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId') + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(resp.results[2], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).not.toBe(undefined); + expect(resp.results[1].objectId).not.toBe(undefined); + expect(resp.results[2].objectId).not.toBe(undefined); + }); + + it('group by empty object', done => { + const obj = new TestObject(); + const pipeline = [ + { + group: { objectId: {} }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it('group by empty string', done => { + const obj = new TestObject(); + const pipeline = [ + { + group: { objectId: '' }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it('group by empty array', done => { + const obj = new TestObject(); + const pipeline = [ + { + group: { objectId: [] }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it('group by multiple columns ', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + group: { + objectId: { + score: '$score', + views: '$views', + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(5); + done(); + }); + }); + + it('group by date object', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + group: { + objectId: { + day: { $dayOfMonth: '$_updated_at' }, + month: { $month: '$_created_at' }, + year: { $year: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const createdAt = new Date(obj1.createdAt); + expect(results[0].objectId.day).toEqual(createdAt.getUTCDate()); + expect(results[0].objectId.month).toEqual(createdAt.getUTCMonth() + 1); + expect(results[0].objectId.year).toEqual(createdAt.getUTCFullYear()); + done(); + }); + }); + + it('group by date object transform', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + group: { + objectId: { + day: { $dayOfMonth: '$updatedAt' }, + month: { $month: '$createdAt' }, + year: { $year: '$createdAt' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const createdAt = new Date(obj1.createdAt); + expect(results[0].objectId.day).toEqual(createdAt.getUTCDate()); + expect(results[0].objectId.month).toEqual(createdAt.getUTCMonth() + 1); + expect(results[0].objectId.year).toEqual(createdAt.getUTCFullYear()); + done(); + }); + }); + + it('group by number', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: '$score' }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId') + ).toBe(true); + expect( + resp.results.sort((a, b) => (a.objectId > b.objectId ? 1 : -1)) + ).toEqual([{ objectId: 10 }, { objectId: 20 }]); + done(); + }) + .catch(done.fail); + }); + + it_exclude_dbs(['postgres'])('group and multiply transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + group: { + objectId: null, + total: { $sum: { $multiply: ['$quantity', '$price'] } }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].total).toEqual(45); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('project and multiply transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + match: { quantity: { $exists: true } }, + }, + { + project: { + name: 1, + total: { $multiply: ['$quantity', '$price'] }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + if (results[0].name === 'item a') { + expect(results[0].total).toEqual(20); + expect(results[1].total).toEqual(25); + } else { + expect(results[0].total).toEqual(25); + expect(results[1].total).toEqual(20); + } + done(); + }); + }); + + it_exclude_dbs(['postgres'])('project without objectId transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + match: { quantity: { $exists: true } }, + }, + { + project: { + objectId: 0, + total: { $multiply: ['$quantity', '$price'] }, + }, + }, + { + sort: { total: 1 }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].total).toEqual(20); + expect(results[0].objectId).toEqual(undefined); + expect(results[1].total).toEqual(25); + expect(results[1].objectId).toEqual(undefined); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('project updatedAt only transform', done => { + const pipeline = [ + { + project: { objectId: 0, updatedAt: 1 }, + }, + ]; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then(results => { + expect(results.length).toEqual(4); + for (let i = 0; i < results.length; i++) { + const item = results[i]; + expect(Object.prototype.hasOwnProperty.call(item, 'updatedAt')).toEqual( + true + ); + expect(Object.prototype.hasOwnProperty.call(item, 'objectId')).toEqual( + false + ); + } + done(); + }); + }); + + it_exclude_dbs(['postgres'])( + 'can group by any date field (it does not work if you have dirty data)', // rows in your collection with non date data in the field that is supposed to be a date + done => { + const obj1 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const obj2 = new TestObject({ dateField2019: new Date(1990, 5, 1) }); + const obj3 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const pipeline = [ + { + match: { + dateField2019: { $exists: true }, + }, + }, + { + group: { + objectId: { + day: { $dayOfMonth: '$dateField2019' }, + month: { $month: '$dateField2019' }, + year: { $year: '$dateField2019' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const counts = results.map(result => result.count); + expect(counts.length).toBe(2); + expect(counts.sort()).toEqual([1, 2]); + done(); + }) + .catch(done.fail); + } + ); + + it_only_db('postgres')( + 'can group by any date field (it does not work if you have dirty data)', // rows in your collection with non date data in the field that is supposed to be a date + done => { + const obj1 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const obj2 = new TestObject({ dateField2019: new Date(1990, 5, 1) }); + const obj3 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const pipeline = [ + { + group: { + objectId: { + day: { $dayOfMonth: '$dateField2019' }, + month: { $month: '$dateField2019' }, + year: { $year: '$dateField2019' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const counts = results.map(result => result.count); + expect(counts.length).toBe(3); + expect(counts.sort()).toEqual([1, 2, 4]); + done(); + }) + .catch(done.fail); + } + ); + + it('group by pointer', done => { + const pointer1 = new TestObject(); + const pointer2 = new TestObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + const pipeline = [{ group: { objectId: '$pointer' } }]; + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(3); + expect(results.some(result => result.objectId === pointer1.id)).toEqual( + true + ); + expect(results.some(result => result.objectId === pointer2.id)).toEqual( + true + ); + expect(results.some(result => result.objectId === null)).toEqual(true); + done(); + }); + }); + + it('group sum query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, total: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].total).toBe(50); + done(); + }) + .catch(done.fail); + }); + + it('group count query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, total: { $sum: 1 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].total).toBe(4); + done(); + }) + .catch(done.fail); + }); + + it('group min query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, minScore: { $min: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].minScore).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it('group max query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, maxScore: { $max: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].maxScore).toBe(20); + done(); + }) + .catch(done.fail); + }); + + it('group avg query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, avgScore: { $avg: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect( + Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId') + ).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].avgScore).toBe(12.5); + done(); + }) + .catch(done.fail); + }); + + it('limit query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + limit: 2, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + done(); + }) + .catch(done.fail); + }); + + it('sort ascending query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + sort: { name: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(4); + expect(resp.results[0].name).toBe('bar'); + expect(resp.results[1].name).toBe('dpl'); + expect(resp.results[2].name).toBe('foo'); + expect(resp.results[3].name).toBe('foo'); + done(); + }) + .catch(done.fail); + }); + + it('sort decending query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + sort: { name: -1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(4); + expect(resp.results[0].name).toBe('foo'); + expect(resp.results[1].name).toBe('foo'); + expect(resp.results[2].name).toBe('dpl'); + expect(resp.results[3].name).toBe('bar'); + done(); + }) + .catch(done.fail); + }); + + it('skip query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + skip: 2, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + done(); + }) + .catch(done.fail); + }); + + it('match comparison date query', done => { + const today = new Date(); + const yesterday = new Date(); + const tomorrow = new Date(); + yesterday.setDate(today.getDate() - 1); + tomorrow.setDate(today.getDate() + 1); + const obj1 = new TestObject({ dateField: yesterday }); + const obj2 = new TestObject({ dateField: today }); + const obj3 = new TestObject({ dateField: tomorrow }); + const pipeline = [{ match: { dateField: { $lt: tomorrow } } }]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toBe(2); + done(); + }); + }); + + it('match comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { score: { $gt: 15 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(20); + done(); + }) + .catch(done.fail); + }); + + it('match multiple comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { score: { $gt: 5, $lt: 15 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect(resp.results[0].score).toBe(10); + expect(resp.results[1].score).toBe(10); + expect(resp.results[2].score).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it('match complex comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { score: { $gt: 5, $lt: 15 }, views: { $gt: 850, $lt: 1000 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(10); + expect(resp.results[0].views).toBe(900); + done(); + }) + .catch(done.fail); + }); + + it('match comparison and equality query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { score: { $gt: 5, $lt: 15 }, views: 900 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(10); + expect(resp.results[0].views).toBe(900); + done(); + }) + .catch(done.fail); + }); + + it('match $or query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { + $or: [ + { score: { $gt: 15, $lt: 25 } }, + { views: { $gt: 750, $lt: 850 } }, + ], + }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + // Match score { $gt: 15, $lt: 25 } + expect(resp.results.some(result => result.score === 20)).toEqual(true); + expect(resp.results.some(result => result.views === 700)).toEqual(true); + + // Match view { $gt: 750, $lt: 850 } + expect(resp.results.some(result => result.score === 10)).toEqual(true); + expect(resp.results.some(result => result.views === 800)).toEqual(true); + done(); + }) + .catch(done.fail); + }); + + it('match objectId query', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const pipeline = [{ match: { objectId: obj1.id } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].objectId).toEqual(obj1.id); + done(); + }); + }); + + it('match field query', done => { + const obj1 = new TestObject({ name: 'TestObject1' }); + const obj2 = new TestObject({ name: 'TestObject2' }); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const pipeline = [{ match: { name: 'TestObject1' } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].objectId).toEqual(obj1.id); + done(); + }); + }); + + it('match pointer query', done => { + const pointer1 = new PointerObject(); + const pointer2 = new PointerObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const pipeline = [{ match: { pointer: pointer1.id } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].pointer.objectId).toEqual(pointer1.id); + expect(results[1].pointer.objectId).toEqual(pointer1.id); + expect(results.some(result => result.objectId === obj1.id)).toEqual( + true + ); + expect(results.some(result => result.objectId === obj3.id)).toEqual( + true + ); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('match exists query', done => { + const pipeline = [{ match: { score: { $exists: true } } }]; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then(results => { + expect(results.length).toEqual(4); + done(); + }); + }); + + it('match date query - createdAt', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const today = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + const pipeline = [{ match: { createdAt: { $gte: today } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + // Four objects were created initially, we added two more. + expect(results.length).toEqual(6); + done(); + }); + }); + + it('match date query - updatedAt', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const today = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + const pipeline = [{ match: { updatedAt: { $gte: today } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + // Four objects were added initially, we added two more. + expect(results.length).toEqual(6); + done(); + }); + }); + + it('match date query - empty', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const future = new Date( + now.getFullYear(), + now.getMonth() + 1, + now.getDate() + ); + const pipeline = [{ match: { createdAt: future } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(0); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('match pointer with operator query', done => { + const pointer = new PointerObject(); + + const obj1 = new TestObject({ pointer }); + const obj2 = new TestObject({ pointer }); + const obj3 = new TestObject(); + + Parse.Object.saveAll([pointer, obj1, obj2, obj3]) + .then(() => { + const pipeline = [{ match: { pointer: { $exists: true } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].pointer.objectId).toEqual(pointer.id); + expect(results[1].pointer.objectId).toEqual(pointer.id); + expect(results.some(result => result.objectId === obj1.id)).toEqual( + true + ); + expect(results.some(result => result.objectId === obj2.id)).toEqual( + true + ); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('match null values', async () => { + const obj1 = new Parse.Object('MyCollection'); + obj1.set('language', 'en'); + obj1.set('otherField', 1); + const obj2 = new Parse.Object('MyCollection'); + obj2.set('language', 'en'); + obj2.set('otherField', 2); + const obj3 = new Parse.Object('MyCollection'); + obj3.set('language', null); + obj3.set('otherField', 3); + const obj4 = new Parse.Object('MyCollection'); + obj4.set('language', null); + obj4.set('otherField', 4); + const obj5 = new Parse.Object('MyCollection'); + obj5.set('language', 'pt'); + obj5.set('otherField', 5); + const obj6 = new Parse.Object('MyCollection'); + obj6.set('language', 'pt'); + obj6.set('otherField', 6); + await Parse.Object.saveAll([obj1, obj2, obj3, obj4, obj5, obj6]); + + expect( + ( + await new Parse.Query('MyCollection').aggregate([ + { + match: { + language: { $in: [null, 'en'] }, + }, + }, + ]) + ) + .map(value => value.otherField) + .sort() + ).toEqual([1, 2, 3, 4]); + + expect( + ( + await new Parse.Query('MyCollection').aggregate([ + { + match: { + $or: [{ language: 'en' }, { language: null }], + }, + }, + ]) + ) + .map(value => value.otherField) + .sort() + ).toEqual([1, 2, 3, 4]); + }); + + it('project query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + project: { name: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + resp.results.forEach(result => { + expect(result.objectId).not.toBe(undefined); + expect(result.name).not.toBe(undefined); + expect(result.sender).toBe(undefined); + expect(result.size).toBe(undefined); + expect(result.score).toBe(undefined); + }); + done(); + }) + .catch(done.fail); + }); + + it('multiple project query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + project: { name: 1, score: 1, sender: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + resp.results.forEach(result => { + expect(result.objectId).not.toBe(undefined); + expect(result.name).not.toBe(undefined); + expect(result.score).not.toBe(undefined); + expect(result.sender).not.toBe(undefined); + expect(result.size).toBe(undefined); + }); + done(); + }) + .catch(done.fail); + }); + + it('project pointer query', done => { + const pointer = new PointerObject(); + const obj = new TestObject({ pointer, name: 'hello' }); + + obj + .save() + .then(() => { + const pipeline = [ + { match: { objectId: obj.id } }, + { project: { pointer: 1, name: 1, createdAt: 1 } }, + ]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].name).toEqual('hello'); + expect(results[0].createdAt).not.toBe(undefined); + expect(results[0].pointer.objectId).toEqual(pointer.id); + done(); + }); + }); + + it('project with group query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + project: { score: 1 }, + group: { objectId: '$score', score: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + resp.results.forEach(result => { + expect(Object.prototype.hasOwnProperty.call(result, 'objectId')).toBe( + true + ); + expect(result.name).toBe(undefined); + expect(result.sender).toBe(undefined); + expect(result.size).toBe(undefined); + expect(result.score).not.toBe(undefined); + if (result.objectId === 10) { + expect(result.score).toBe(30); + } + if (result.objectId === 20) { + expect(result.score).toBe(20); + } + }); + done(); + }) + .catch(done.fail); + }); + + it('class does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, total: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('field does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + group: { objectId: null, total: { $sum: '$unknownfield' } }, + }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('distinct query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'score' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect(resp.results.includes(10)).toBe(true); + expect(resp.results.includes(20)).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it('distinct query with where', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + distinct: 'score', + where: { + name: 'bar', + }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results[0]).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it('distinct query with where string', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + distinct: 'score', + where: JSON.stringify({ name: 'bar' }), + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results[0]).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it('distinct nested', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'sender.group' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect(resp.results.includes('A')).toBe(true); + expect(resp.results.includes('B')).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it('distinct pointer', done => { + const pointer1 = new PointerObject(); + const pointer2 = new PointerObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.distinct('pointer'); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results.some(result => result.objectId === pointer1.id)).toEqual( + true + ); + expect(results.some(result => result.objectId === pointer2.id)).toEqual( + true + ); + done(); + }); + }); + + it('distinct class does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'unknown' }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('distinct field does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'unknown' }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return get(Parse.serverURL + '/aggregate/TestObject', options); + }) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('distinct array', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'size' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect(resp.results.includes('S')).toBe(true); + expect(resp.results.includes('M')).toBe(true); + expect(resp.results.includes('L')).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it('distinct objectId', async () => { + const query = new Parse.Query(TestObject); + const results = await query.distinct('objectId'); + expect(results.length).toBe(4); + }); + + it('distinct createdAt', async () => { + const object1 = new TestObject({ createdAt_test: true }); + await object1.save(); + const object2 = new TestObject({ createdAt_test: true }); + await object2.save(); + const query = new Parse.Query(TestObject); + query.equalTo('createdAt_test', true); + const results = await query.distinct('createdAt'); + expect(results.length).toBe(2); + }); + + it('distinct updatedAt', async () => { + const object1 = new TestObject({ updatedAt_test: true }); + await object1.save(); + const object2 = new TestObject(); + await object2.save(); + object2.set('updatedAt_test', true); + await object2.save(); + const query = new Parse.Query(TestObject); + query.equalTo('updatedAt_test', true); + const results = await query.distinct('updatedAt'); + expect(results.length).toBe(2); + }); + + it('distinct null field', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'distinctField' }, + }); + const user1 = new Parse.User(); + user1.setUsername('distinct_1'); + user1.setPassword('password'); + user1.set('distinctField', 'one'); + + const user2 = new Parse.User(); + user2.setUsername('distinct_2'); + user2.setPassword('password'); + user2.set('distinctField', null); + user1 + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + return get(Parse.serverURL + '/aggregate/_User', options); + }) + .then(resp => { + expect(resp.results.length).toEqual(1); + expect(resp.results).toEqual(['one']); + done(); + }) + .catch(done.fail); + }); + + it('does not return sensitive hidden properties', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + match: { + score: { + $gt: 5, + }, + }, + }, + }); + + const username = 'leaky_user'; + const score = 10; + + const user = new Parse.User(); + user.setUsername(username); + user.setPassword('password'); + user.set('score', score); + user + .signUp() + .then(function() { + return get(Parse.serverURL + '/aggregate/_User', options); + }) + .then(function(resp) { + expect(resp.results.length).toBe(1); + const result = resp.results[0]; + + // verify server-side keys are not present... + expect(result._hashed_password).toBe(undefined); + expect(result._wperm).toBe(undefined); + expect(result._rperm).toBe(undefined); + expect(result._acl).toBe(undefined); + expect(result._created_at).toBe(undefined); + expect(result._updated_at).toBe(undefined); + + // verify createdAt, updatedAt and others are present + expect(result.createdAt).not.toBe(undefined); + expect(result.updatedAt).not.toBe(undefined); + expect(result.objectId).not.toBe(undefined); + expect(result.username).toBe(username); + expect(result.score).toBe(score); + + done(); + }) + .catch(function(err) { + fail(err); + }); + }); + + it_exclude_dbs(['postgres'])( + 'aggregate allow multiple of same stage', + done => { + const pointer1 = new TestObject({ value: 1 }); + const pointer2 = new TestObject({ value: 2 }); + const pointer3 = new TestObject({ value: 3 }); + + const obj1 = new TestObject({ pointer: pointer1, name: 'Hello' }); + const obj2 = new TestObject({ pointer: pointer2, name: 'Hello' }); + const obj3 = new TestObject({ pointer: pointer3, name: 'World' }); + + const options = Object.assign({}, masterKeyOptions, { + body: { + pipeline: [ + { + match: { name: 'Hello' }, + }, + { + // Transform className$objectId to objectId and store in new field tempPointer + project: { + tempPointer: { $substr: ['$_p_pointer', 11, -1] }, // Remove TestObject$ + }, + }, + { + // Left Join, replace objectId stored in tempPointer with an actual object + lookup: { + from: 'test_TestObject', + localField: 'tempPointer', + foreignField: '_id', + as: 'tempPointer', + }, + }, + { + // lookup returns an array, Deconstructs an array field to objects + unwind: { + path: '$tempPointer', + }, + }, + { + match: { 'tempPointer.value': 2 }, + }, + ], + }, + }); + Parse.Object.saveAll([pointer1, pointer2, pointer3, obj1, obj2, obj3]) + .then(() => { + return get(Parse.serverURL + '/aggregate/TestObject', options); + }) + .then(resp => { + expect(resp.results.length).toEqual(1); + expect(resp.results[0].tempPointer.value).toEqual(2); + done(); + }); + } + ); +}); diff --git a/spec/ParseQuery.FullTextSearch.spec.js b/spec/ParseQuery.FullTextSearch.spec.js index acb67c73f2..6f2bebc71a 100644 --- a/spec/ParseQuery.FullTextSearch.spec.js +++ b/spec/ParseQuery.FullTextSearch.spec.js @@ -1,11 +1,15 @@ 'use strict'; -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; -const PostgresStorageAdapter = require('../src/Adapters/Storage/Postgres/PostgresStorageAdapter'); -const postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const mongoURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const postgresURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; const Parse = require('parse/node'); -const rp = require('request-promise'); +const request = require('../lib/request'); let databaseAdapter; const fullTextHelper = () => { @@ -29,11 +33,12 @@ const fullTextHelper = () => { const requests = []; for (const i in subjects) { const request = { - method: "POST", + method: 'POST', body: { - subject: subjects[i] + subject: subjects[i], + comment: subjects[i], }, - path: "/1/classes/TestObject" + path: '/1/classes/TestObject', }; requests.push(request); } @@ -41,416 +46,570 @@ const fullTextHelper = () => { appId: 'test', restAPIKey: 'test', publicServerURL: 'http://localhost:8378/1', - databaseAdapter + databaseAdapter, }).then(() => { - return rp.post({ + return request({ + method: 'POST', url: 'http://localhost:8378/1/batch', body: { - requests + requests, }, - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, }); }); -} +}; describe('Parse.Query Full Text Search testing', () => { - it('fullTextSearch: $search', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'coffee' - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(3); - done(); - }, done.fail); + it('fullTextSearch: $search', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'coffee', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then( + resp => { + expect(resp.data.results.length).toBe(3); + done(); + }, + e => done.fail(e) + ); }); - it('fullTextSearch: $search, sort', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'coffee' - } - } - } - }; - const order = '$score'; - const keys = '$score'; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, order, keys, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(3); - expect(resp.results[0].score); - expect(resp.results[1].score); - expect(resp.results[2].score); - done(); - }, done.fail); + it('fullTextSearch: $search, sort', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'coffee', + }, + }, + }, + }; + const order = '$score'; + const keys = '$score'; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, order, keys, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(response => { + const resp = response.data; + expect(resp.results.length).toBe(3); + expect(resp.results[0].score); + expect(resp.results[1].score); + expect(resp.results[2].score); + done(); + }, done.fail); }); - it('fullTextSearch: $language', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'leche', - $language: 'spanish' - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(2); - done(); - }, done.fail); + it('fullTextSearch: $language', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'leche', + $language: 'spanish', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); }); - it('fullTextSearch: $diacriticSensitive', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: true - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(1); - done(); - }, done.fail); + it('fullTextSearch: $diacriticSensitive', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'CAFÉ', + $diacriticSensitive: true, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(1); + done(); + }, done.fail); }); - it('fullTextSearch: $search, invalid input', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: true - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } + it('fullTextSearch: $search, invalid input', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: true, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); - it('fullTextSearch: $language, invalid input', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'leche', - $language: true - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } + it('fullTextSearch: $language, invalid input', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'leche', + $language: true, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); - it('fullTextSearch: $caseSensitive, invalid input', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'Coffee', - $caseSensitive: 'string' - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } + it('fullTextSearch: $caseSensitive, invalid input', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'Coffee', + $caseSensitive: 'string', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); - it('fullTextSearch: $diacriticSensitive, invalid input', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: 'string' - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } + it('fullTextSearch: $diacriticSensitive, invalid input', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'CAFÉ', + $diacriticSensitive: 'string', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }).then((resp) => { - fail(`no request should succeed: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); - }); }); }); -describe_only_db('mongo')('Parse.Query Full Text Search testing', () => { - it('fullTextSearch: $search, only one text index', (done) => { - return reconfigureServer({ - appId: 'test', - restAPIKey: 'test', - publicServerURL: 'http://localhost:8378/1', - databaseAdapter: new MongoStorageAdapter({ uri: mongoURI }) - }).then(() => { - return rp.post({ - url: 'http://localhost:8378/1/batch', - body: { - requests: [ - { - method: "POST", - body: { - subject: "coffee is java" +describe_only_db('mongo')( + '[mongodb] Parse.Query Full Text Search testing', + () => { + it('fullTextSearch: does not create text index if compound index exist', done => { + fullTextHelper() + .then(() => { + return databaseAdapter.dropAllIndexes('TestObject'); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(1); + return databaseAdapter.createIndex('TestObject', { + subject: 'text', + comment: 'text', + }); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + const where = { + subject: { + $text: { + $search: { + $term: 'coffee', + }, }, - path: "/1/classes/TestObject" }, - { - method: "POST", - body: { - subject: "java is coffee" + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toEqual(3); + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + }).then(response => { + const body = response.data; + expect(body.indexes._id_).toBeDefined(); + expect(body.indexes._id_._id).toEqual(1); + expect(body.indexes.subject_text_comment_text).toBeDefined(); + expect(body.indexes.subject_text_comment_text.subject).toEqual( + 'text' + ); + expect(body.indexes.subject_text_comment_text.comment).toEqual( + 'text' + ); + done(); + }); + }) + .catch(done.fail); + }); + + it('fullTextSearch: does not create text index if schema compound index exist', done => { + fullTextHelper() + .then(() => { + return databaseAdapter.dropAllIndexes('TestObject'); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(1); + return request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + indexes: { + text_test: { subject: 'text', comment: 'text' }, }, - path: "/1/classes/TestObject" - } - ] - }, - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then(() => { - return databaseAdapter.createIndex('TestObject', {random: 'text'}); - }).then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'coffee' - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - fail(`Should not be more than one text index: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INTERNAL_SERVER_ERROR); - done(); + }, + }); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + const where = { + subject: { + $text: { + $search: { + $term: 'coffee', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toEqual(3); + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + }).then(response => { + const body = response.data; + expect(body.indexes._id_).toBeDefined(); + expect(body.indexes._id_._id).toEqual(1); + expect(body.indexes.text_test).toBeDefined(); + expect(body.indexes.text_test.subject).toEqual('text'); + expect(body.indexes.text_test.comment).toEqual('text'); + done(); + }); + }) + .catch(done.fail); }); - }); - it('fullTextSearch: $diacriticSensitive - false', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: false - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(2); - done(); - }, done.fail); - }); + it('fullTextSearch: $diacriticSensitive - false', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'CAFÉ', + $diacriticSensitive: false, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); - it('fullTextSearch: $caseSensitive', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'Coffee', - $caseSensitive: true - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - expect(resp.results.length).toBe(1); - done(); - }, done.fail); - }); -}); + it('fullTextSearch: $caseSensitive', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'Coffee', + $caseSensitive: true, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(1); + done(); + }, done.fail); + }); + } +); -describe_only_db('postgres')('Parse.Query Full Text Search testing', () => { - it('fullTextSearch: $diacriticSensitive - false', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'CAFÉ', - $diacriticSensitive: false - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - fail(`$diacriticSensitive - false should not supported: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); +describe_only_db('postgres')( + '[postgres] Parse.Query Full Text Search testing', + () => { + it('fullTextSearch: $diacriticSensitive - false', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'CAFÉ', + $diacriticSensitive: false, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail( + `$diacriticSensitive - false should not supported: ${JSON.stringify( + resp + )}` + ); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); }); - }); - it('fullTextSearch: $caseSensitive', (done) => { - fullTextHelper().then(() => { - const where = { - subject: { - $text: { - $search: { - $term: 'Coffee', - $caseSensitive: true - } - } - } - }; - return rp.post({ - url: 'http://localhost:8378/1/classes/TestObject', - json: { where, '_method': 'GET' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test' - } - }); - }).then((resp) => { - fail(`$caseSensitive should not supported: ${JSON.stringify(resp)}`); - done(); - }).catch((err) => { - expect(err.error.code).toEqual(Parse.Error.INVALID_JSON); - done(); + it('fullTextSearch: $caseSensitive', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'Coffee', + $caseSensitive: true, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`$caseSensitive should not supported: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); }); - }); -}); + } +); diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js new file mode 100644 index 0000000000..43f1c4e9a7 --- /dev/null +++ b/spec/ParseQuery.hint.spec.js @@ -0,0 +1,170 @@ +'use strict'; + +const Config = require('../lib/Config'); +const TestUtils = require('../lib/TestUtils'); +const request = require('../lib/request'); + +let config; + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +describe_only_db('mongo')('Parse.Query hint', () => { + beforeEach(() => { + config = Config.get('test'); + }); + + afterEach(async () => { + await config.database.schemaCache.clear(); + await TestUtils.destroyAllDataPermanently(false); + }); + + it('query find with hint string', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let explain = await collection._rawFind( + { _id: object.id }, + { explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind( + { _id: object.id }, + { hint: '_id_', explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); + }); + + it('query find with hint object', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let explain = await collection._rawFind( + { _id: object.id }, + { explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind( + { _id: object.id }, + { hint: { _id: 1 }, explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ + _id: 1, + }); + }); + + it('query aggregate with hint string', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let { queryPlanner } = result[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: '_id_', + explain: true, + }); + queryPlanner = result[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); + }); + + it('query aggregate with hint object', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let { queryPlanner } = result[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: { _id: 1 }, + explain: true, + }); + queryPlanner = result[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ _id: 1 }); + }); + + it('query find with hint (rest)', async () => { + const object = new TestObject(); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + }, + }); + let response = await request(options); + let explain = response.data.results; + expect(explain.queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN'); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + hint: '_id_', + }, + }); + response = await request(options); + explain = response.data.results; + expect( + explain.queryPlanner.winningPlan.inputStage.inputStage.indexName + ).toBe('_id_'); + }); + + it('query aggregate with hint (rest)', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + group: JSON.stringify({ objectId: '$foo' }), + }, + }); + let response = await request(options); + let { queryPlanner } = response.data.results[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + hint: '_id_', + group: JSON.stringify({ objectId: '$foo' }), + }, + }); + response = await request(options); + queryPlanner = response.data.results[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ _id: 1 }); + }); +}); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 15884a1cd1..ee4727404a 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -5,1499 +5,2238 @@ 'use strict'; const Parse = require('parse/node'); +const request = require('../lib/request'); + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, +}; describe('Parse.Query testing', () => { - it("basic query", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); + it('basic query', function(done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + Parse.Object.saveAll([baz, qux]).then(function() { + const query = new Parse.Query(TestObject); query.equalTo('foo', 'baz'); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('foo'), 'baz'); - done(); - } + query.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('foo'), 'baz'); + done(); }); }); }); - it("searching for null", function(done) { - var baz = new TestObject({ foo: null }); - var qux = new TestObject({ foo: 'qux' }); - var qux2 = new TestObject({ }); - Parse.Object.saveAll([baz, qux, qux2], function() { - var query = new Parse.Query(TestObject); + it('searching for null', function(done) { + const baz = new TestObject({ foo: null }); + const qux = new TestObject({ foo: 'qux' }); + const qux2 = new TestObject({}); + Parse.Object.saveAll([baz, qux, qux2]).then(function() { + const query = new Parse.Query(TestObject); query.equalTo('foo', null); - query.find({ - success: function(results) { - equal(results.length, 2); - qux.set('foo', null); - qux.save({ - success: function () { - query.find({ - success: function (results) { - equal(results.length, 3); - done(); - } - }); - } + query.find().then(function(results) { + equal(results.length, 2); + qux.set('foo', null); + qux.save().then(function() { + query.find().then(function(results) { + equal(results.length, 3); + done(); }); - } + }); }); }); }); - it("searching for not null", function(done) { - var baz = new TestObject({ foo: null }); - var qux = new TestObject({ foo: 'qux' }); - var qux2 = new TestObject({ }); - Parse.Object.saveAll([baz, qux, qux2], function() { - var query = new Parse.Query(TestObject); + it('searching for not null', function(done) { + const baz = new TestObject({ foo: null }); + const qux = new TestObject({ foo: 'qux' }); + const qux2 = new TestObject({}); + Parse.Object.saveAll([baz, qux, qux2]).then(function() { + const query = new Parse.Query(TestObject); query.notEqualTo('foo', null); - query.find({ - success: function(results) { - equal(results.length, 1); - qux.set('foo', null); - qux.save({ - success: function () { - query.find({ - success: function (results) { - equal(results.length, 0); - done(); - } - }); - }, - error: function (error) { console.log(error); } + query.find().then(function(results) { + equal(results.length, 1); + qux.set('foo', null); + qux.save().then(function() { + query.find().then(function(results) { + equal(results.length, 0); + done(); }); - }, - error: function (error) { console.log(error); } + }); }); }); }); - it("notEqualTo with Relation is working", function(done) { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - - var user1 = new Parse.User(); - user1.setPassword("asdf"); - user1.setUsername("qwerty"); + it('notEqualTo with Relation is working', function(done) { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); - var user2 = new Parse.User(); - user2.setPassword("asdf"); - user2.setUsername("asdf"); + const user1 = new Parse.User(); + user1.setPassword('asdf'); + user1.setUsername('qwerty'); - var Cake = Parse.Object.extend("Cake"); - var cake1 = new Cake(); - var cake2 = new Cake(); - var cake3 = new Cake(); + const user2 = new Parse.User(); + user2.setPassword('asdf'); + user2.setUsername('asdf'); + const Cake = Parse.Object.extend('Cake'); + const cake1 = new Cake(); + const cake2 = new Cake(); + const cake3 = new Cake(); - user.signUp().then(function(){ - return user1.signUp(); - }).then(function(){ - return user2.signUp(); - }).then(function(){ - var relLike1 = cake1.relation("liker"); - relLike1.add([user, user1]); + user + .signUp() + .then(function() { + return user1.signUp(); + }) + .then(function() { + return user2.signUp(); + }) + .then(function() { + const relLike1 = cake1.relation('liker'); + relLike1.add([user, user1]); - var relDislike1 = cake1.relation("hater"); - relDislike1.add(user2); + const relDislike1 = cake1.relation('hater'); + relDislike1.add(user2); - return cake1.save(); - }).then(function(){ - var rellike2 = cake2.relation("liker"); - rellike2.add([user, user1]); + return cake1.save(); + }) + .then(function() { + const rellike2 = cake2.relation('liker'); + rellike2.add([user, user1]); - var relDislike2 = cake2.relation("hater"); - relDislike2.add(user2); + const relDislike2 = cake2.relation('hater'); + relDislike2.add(user2); - var relSomething = cake2.relation("something"); - relSomething.add(user); + const relSomething = cake2.relation('something'); + relSomething.add(user); - return cake2.save(); - }).then(function(){ - var rellike3 = cake3.relation("liker"); - rellike3.add(user); + return cake2.save(); + }) + .then(function() { + const rellike3 = cake3.relation('liker'); + rellike3.add(user); - var relDislike3 = cake3.relation("hater"); - relDislike3.add([user1, user2]); - return cake3.save(); - }).then(function(){ - var query = new Parse.Query(Cake); - // User2 likes nothing so we should receive 0 - query.equalTo("liker", user2); - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // User1 likes two of three cakes - query.equalTo("liker", user1); - return query.find().then(function(results){ - // It should return 2 -> cake 1 and cake 2 - equal(results.length, 2); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // We want to know which cake the user1 is not appreciating -> cake3 - query.notEqualTo("liker", user1); - return query.find().then(function(results){ - // Should return 1 -> the cake 3 - equal(results.length, 1); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // User2 is a hater of everything so we should receive 0 - query.notEqualTo("hater", user2); - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // Only cake3 is liked by user - query.notContainedIn("liker", [user1]); - return query.find().then(function(results){ - equal(results.length, 1); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // All the users - query.containedIn("liker", [user, user1, user2]); - // Exclude user 1 - query.notEqualTo("liker", user1); - // Only cake3 is liked only by user1 - return query.find().then(function(results){ - equal(results.length, 1); - const cake = results[0]; - expect(cake.id).toBe(cake3.id); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // Exclude user1 - query.notEqualTo("liker", user1); - // Only cake1 - query.equalTo("objectId", cake1.id) - // user1 likes cake1 so this should return no results - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - query.notEqualTo("hater", user2); - query.notEqualTo("liker", user2); - // user2 doesn't like any cake so this should be 0 - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - query.equalTo("hater", user); - query.equalTo("liker", user); - // user doesn't hate any cake so this should be 0 - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - query.equalTo("hater", null); - query.equalTo("liker", null); - // user doesn't hate any cake so this should be 0 - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - query.equalTo("something", null); - // user doesn't hate any cake so this should be 0 - return query.find().then(function(results){ - equal(results.length, 0); + const relDislike3 = cake3.relation('hater'); + relDislike3.add([user1, user2]); + return cake3.save(); + }) + .then(function() { + const query = new Parse.Query(Cake); + // User2 likes nothing so we should receive 0 + query.equalTo('liker', user2); + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // User1 likes two of three cakes + query.equalTo('liker', user1); + return query.find().then(function(results) { + // It should return 2 -> cake 1 and cake 2 + equal(results.length, 2); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // We want to know which cake the user1 is not appreciating -> cake3 + query.notEqualTo('liker', user1); + return query.find().then(function(results) { + // Should return 1 -> the cake 3 + equal(results.length, 1); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // User2 is a hater of everything so we should receive 0 + query.notEqualTo('hater', user2); + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // Only cake3 is liked by user + query.notContainedIn('liker', [user1]); + return query.find().then(function(results) { + equal(results.length, 1); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // All the users + query.containedIn('liker', [user, user1, user2]); + // Exclude user 1 + query.notEqualTo('liker', user1); + // Only cake3 is liked only by user1 + return query.find().then(function(results) { + equal(results.length, 1); + const cake = results[0]; + expect(cake.id).toBe(cake3.id); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + // Exclude user1 + query.notEqualTo('liker', user1); + // Only cake1 + query.equalTo('objectId', cake1.id); + // user1 likes cake1 so this should return no results + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + query.notEqualTo('hater', user2); + query.notEqualTo('liker', user2); + // user2 doesn't like any cake so this should be 0 + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + query.equalTo('hater', user); + query.equalTo('liker', user); + // user doesn't hate any cake so this should be 0 + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + query.equalTo('hater', null); + query.equalTo('liker', null); + // user doesn't hate any cake so this should be 0 + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + const query = new Parse.Query(Cake); + query.equalTo('something', null); + // user doesn't hate any cake so this should be 0 + return query.find().then(function(results) { + equal(results.length, 0); + }); + }) + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + done(); }); - }).then(function(){ - done(); - }).catch((err) => { - jfail(err); - done(); - }) }); - it("query with limit", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); + it('query notContainedIn on empty array', async () => { + const object = new TestObject(); + object.set('value', 100); + await object.save(); + + const query = new Parse.Query(TestObject); + query.notContainedIn('value', []); + + const results = await query.find(); + equal(results.length, 1); + }); + + it('query containedIn on empty array', async () => { + const object = new TestObject(); + object.set('value', 100); + await object.save(); + + const query = new Parse.Query(TestObject); + query.containedIn('value', []); + + const results = await query.find(); + equal(results.length, 0); + }); + + it('query with limit', function(done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + Parse.Object.saveAll([baz, qux]).then(function() { + const query = new Parse.Query(TestObject); query.limit(1); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - it("query with limit equal to maxlimit", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - reconfigureServer({ maxLimit: 1 }) - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); + it('query with limit equal to maxlimit', function(done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + reconfigureServer({ maxLimit: 1 }); + Parse.Object.saveAll([baz, qux]).then(function() { + const query = new Parse.Query(TestObject); query.limit(1); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - it("query with limit exceeding maxlimit", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - reconfigureServer({ maxLimit: 1 }) - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); + it('query with limit exceeding maxlimit', function(done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + reconfigureServer({ maxLimit: 1 }); + Parse.Object.saveAll([baz, qux]).then(function() { + const query = new Parse.Query(TestObject); query.limit(2); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - it("containedIn object array queries", function(done) { - var messageList = []; - for (var i = 0; i < 4; ++i) { - var message = new TestObject({}); + it('containedIn object array queries', function(done) { + const messageList = []; + for (let i = 0; i < 4; ++i) { + const message = new TestObject({}); if (i > 0) { message.set('prior', messageList[i - 1]); } messageList.push(message); } - Parse.Object.saveAll(messageList, function() { - equal(messageList.length, 4); + Parse.Object.saveAll(messageList).then( + function() { + equal(messageList.length, 4); - var inList = []; - inList.push(messageList[0]); - inList.push(messageList[2]); + const inList = []; + inList.push(messageList[0]); + inList.push(messageList[2]); - var query = new Parse.Query(TestObject); - query.containedIn('prior', inList); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - }, - error: function(e) { - jfail(e); - done(); - } - }); - }, (e) => { - jfail(e); - done(); - }); + const query = new Parse.Query(TestObject); + query.containedIn('prior', inList); + query.find().then( + function(results) { + equal(results.length, 2); + done(); + }, + function(e) { + jfail(e); + done(); + } + ); + }, + e => { + jfail(e); + done(); + } + ); }); - it('containedIn null array', (done) => { + it('containedIn null array', done => { const emails = ['contact@xyz.com', 'contact@zyx.com', null]; const user = new Parse.User(); user.setUsername(emails[0]); user.setPassword('asdf'); - user.signUp().then(() => { - const query = new Parse.Query(Parse.User); - query.containedIn('username', emails); - return query.find({ useMasterKey: true }); - }).then((results) => { - equal(results.length, 1); - done(); - }, done.fail); + user + .signUp() + .then(() => { + const query = new Parse.Query(Parse.User); + query.containedIn('username', emails); + return query.find({ useMasterKey: true }); + }) + .then(results => { + equal(results.length, 1); + done(); + }, done.fail); }); - it('nested containedIn string', (done) => { + it('nested equalTo string with single quote', async () => { + const obj = new TestObject({ nested: { foo: "single'quote" } }); + await obj.save(); + const query = new Parse.Query(TestObject); + query.equalTo('nested.foo', "single'quote"); + const result = await query.get(obj.id); + equal(result.get('nested').foo, "single'quote"); + }); + + it('nested containedIn string with single quote', async () => { + const obj = new TestObject({ nested: { foo: ["single'quote"] } }); + await obj.save(); + const query = new Parse.Query(TestObject); + query.containedIn('nested.foo', ["single'quote"]); + const result = await query.get(obj.id); + equal(result.get('nested').foo[0], "single'quote"); + }); + + it('nested containedIn string', done => { const sender1 = { group: ['A', 'B'] }; const sender2 = { group: ['A', 'C'] }; const sender3 = { group: ['B', 'C'] }; const obj1 = new TestObject({ sender: sender1 }); const obj2 = new TestObject({ sender: sender2 }); const obj3 = new TestObject({ sender: sender3 }); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const query = new Parse.Query(TestObject); - query.containedIn('sender.group', ['A']); - return query.find(); - }).then((results) => { - equal(results.length, 2); - done(); - }, done.fail); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.containedIn('sender.group', ['A']); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); }); - it('nested containedIn number', (done) => { + it('nested containedIn number', done => { const sender1 = { group: [1, 2] }; const sender2 = { group: [1, 3] }; const sender3 = { group: [2, 3] }; const obj1 = new TestObject({ sender: sender1 }); const obj2 = new TestObject({ sender: sender2 }); const obj3 = new TestObject({ sender: sender3 }); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const query = new Parse.Query(TestObject); - query.containedIn('sender.group', [1]); - return query.find(); - }).then((results) => { - equal(results.length, 2); - done(); - }, done.fail); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.containedIn('sender.group', [1]); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); }); - it("containsAll number array queries", function(done) { - var NumberSet = Parse.Object.extend({ className: "NumberSet" }); + it('containsAll number array queries', function(done) { + const NumberSet = Parse.Object.extend({ className: 'NumberSet' }); - var objectsList = []; - objectsList.push(new NumberSet({ "numbers" : [1, 2, 3, 4, 5] })); - objectsList.push(new NumberSet({ "numbers" : [1, 3, 4, 5] })); + const objectsList = []; + objectsList.push(new NumberSet({ numbers: [1, 2, 3, 4, 5] })); + objectsList.push(new NumberSet({ numbers: [1, 3, 4, 5] })); - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(NumberSet); - query.containsAll("numbers", [1, 2, 3]); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - }, - error: function(err) { - jfail(err); - done(); - }, + Parse.Object.saveAll(objectsList) + .then(function() { + const query = new Parse.Query(NumberSet); + query.containsAll('numbers', [1, 2, 3]); + query.find().then( + function(results) { + equal(results.length, 1); + done(); + }, + function(err) { + jfail(err); + done(); + } + ); + }) + .catch(err => { + jfail(err); + done(); }); - }).catch((err) => { - jfail(err); - done(); - }); }); - it("containsAll string array queries", function(done) { - var StringSet = Parse.Object.extend({ className: "StringSet" }); + it('containsAll string array queries', function(done) { + const StringSet = Parse.Object.extend({ className: 'StringSet' }); - var objectsList = []; - objectsList.push(new StringSet({ "strings" : ["a", "b", "c", "d", "e"] })); - objectsList.push(new StringSet({ "strings" : ["a", "c", "d", "e"] })); + const objectsList = []; + objectsList.push(new StringSet({ strings: ['a', 'b', 'c', 'd', 'e'] })); + objectsList.push(new StringSet({ strings: ['a', 'c', 'd', 'e'] })); - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(StringSet); - query.containsAll("strings", ["a", "b", "c"]); - query.find({ - success: function(results) { + Parse.Object.saveAll(objectsList) + .then(function() { + const query = new Parse.Query(StringSet); + query.containsAll('strings', ['a', 'b', 'c']); + query.find().then(function(results) { equal(results.length, 1); done(); - } + }); + }) + .catch(err => { + jfail(err); + done(); }); - }).catch((err) => { - jfail(err); - done(); - }); }); - it("containsAll date array queries", function(done) { - var DateSet = Parse.Object.extend({ className: "DateSet" }); + it('containsAll date array queries', function(done) { + const DateSet = Parse.Object.extend({ className: 'DateSet' }); function parseDate(iso8601) { - var regexp = new RegExp( - '^([0-9]{1,4})-([0-9]{1,2})-([0-9]{1,2})' + 'T' + + const regexp = new RegExp( + '^([0-9]{1,4})-([0-9]{1,2})-([0-9]{1,2})' + + 'T' + '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})' + - '(.([0-9]+))?' + 'Z$'); - var match = regexp.exec(iso8601); + '(.([0-9]+))?' + + 'Z$' + ); + const match = regexp.exec(iso8601); if (!match) { return null; } - var year = match[1] || 0; - var month = (match[2] || 1) - 1; - var day = match[3] || 0; - var hour = match[4] || 0; - var minute = match[5] || 0; - var second = match[6] || 0; - var milli = match[8] || 0; + const year = match[1] || 0; + const month = (match[2] || 1) - 1; + const day = match[3] || 0; + const hour = match[4] || 0; + const minute = match[5] || 0; + const second = match[6] || 0; + const milli = match[8] || 0; return new Date(Date.UTC(year, month, day, hour, minute, second, milli)); } - var makeDates = function(stringArray) { + const makeDates = function(stringArray) { return stringArray.map(function(dateStr) { - return parseDate(dateStr + "T00:00:00Z"); + return parseDate(dateStr + 'T00:00:00Z'); }); }; - var objectsList = []; - objectsList.push(new DateSet({ - "dates" : makeDates(["2013-02-01", "2013-02-02", "2013-02-03", - "2013-02-04"]) - })); - objectsList.push(new DateSet({ - "dates" : makeDates(["2013-02-01", "2013-02-03", "2013-02-04"]) - })); - - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(DateSet); - query.containsAll("dates", makeDates( - ["2013-02-01", "2013-02-02", "2013-02-03"])); - query.find({ - success: function(results) { + const objectsList = []; + objectsList.push( + new DateSet({ + dates: makeDates([ + '2013-02-01', + '2013-02-02', + '2013-02-03', + '2013-02-04', + ]), + }) + ); + objectsList.push( + new DateSet({ + dates: makeDates(['2013-02-01', '2013-02-03', '2013-02-04']), + }) + ); + + Parse.Object.saveAll(objectsList).then(function() { + const query = new Parse.Query(DateSet); + query.containsAll( + 'dates', + makeDates(['2013-02-01', '2013-02-02', '2013-02-03']) + ); + query.find().then( + function(results) { equal(results.length, 1); done(); }, - error: function(e) { + function(e) { jfail(e); done(); - }, - }); + } + ); }); }); - it("containsAll object array queries", function(done) { - - var MessageSet = Parse.Object.extend({ className: "MessageSet" }); + it('containsAll object array queries', function(done) { + const MessageSet = Parse.Object.extend({ className: 'MessageSet' }); - var messageList = []; - for (var i = 0; i < 4; ++i) { - messageList.push(new TestObject({ 'i' : i })); + const messageList = []; + for (let i = 0; i < 4; ++i) { + messageList.push(new TestObject({ i: i })); } - Parse.Object.saveAll(messageList, function() { + Parse.Object.saveAll(messageList).then(function() { equal(messageList.length, 4); - var messageSetList = []; - messageSetList.push(new MessageSet({ 'messages' : messageList })); + const messageSetList = []; + messageSetList.push(new MessageSet({ messages: messageList })); - var someList = []; + const someList = []; someList.push(messageList[0]); someList.push(messageList[1]); someList.push(messageList[3]); - messageSetList.push(new MessageSet({ 'messages' : someList })); + messageSetList.push(new MessageSet({ messages: someList })); - Parse.Object.saveAll(messageSetList, function() { - var inList = []; + Parse.Object.saveAll(messageSetList).then(function() { + const inList = []; inList.push(messageList[0]); inList.push(messageList[2]); - var query = new Parse.Query(MessageSet); + const query = new Parse.Query(MessageSet); query.containsAll('messages', inList); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); }); - var BoxedNumber = Parse.Object.extend({ - className: "BoxedNumber" - }); + it('containsAllStartingWith should match all strings that starts with string', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + const object2 = new Parse.Object('Object'); + object2.set('strings', ['the', 'brown', 'fox', 'jumps']); + const object3 = new Parse.Object('Object'); + object3.set('strings', ['over', 'the', 'lazy', 'dog']); + + const objectList = [object, object2, object3]; + + Parse.Object.saveAll(objectList).then(results => { + equal(objectList.length, results.length); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [ + { $regex: '^\\Qthe\\E' }, + { $regex: '^\\Qfox\\E' }, + { $regex: '^\\Qlazy\\E' }, + ], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }) + .then(function(response) { + const results = response.data; + equal(results.results.length, 1); + arrayContains(results.results, object); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qthe\\E' }, { $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(function(response) { + const results = response.data; + equal(results.results.length, 2); + arrayContains(results.results, object); + arrayContains(results.results, object3); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qhe\\E' }, { $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(function(response) { + const results = response.data; + equal(results.results.length, 0); - it("equalTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', 3); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + done(); }); - }); + }); }); - it("equalTo undefined", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', undefined); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 0); - done(); - } - })); - }); - }); + it('containsAllStartingWith values must be all of type starting with regex', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); - it("lessThan queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThan('number', 7); - query.find({ - success: function(results) { - equal(results.length, 7); - done(); - } + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [ + { $regex: '^\\Qthe\\E' }, + { $regex: '^\\Qlazy\\E' }, + { $regex: '^\\Qfox\\E' }, + { $unknown: /unknown/ }, + ], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, }); + }) + .then(done.fail, function() { + done(); }); }); - it("lessThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 8); - done(); - } - }); - }); - }); + it('containsAllStartingWith empty array values should return empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); - it("greaterThan queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThan('number', 7); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, }); - }); + }) + .then( + function(response) { + const results = response.data; + equal(results.results.length, 0); + done(); + }, + function() {} + ); }); - it("greaterThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + it('containsAllStartingWith single empty value returns empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{}], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then( + function(response) { + const results = response.data; + equal(results.results.length, 0); + done(); + }, + function() {} + ); + }); + + it('containsAllStartingWith single regex value should return corresponding matching results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + const object2 = new Parse.Object('Object'); + object2.set('strings', ['the', 'brown', 'fox', 'jumps']); + const object3 = new Parse.Object('Object'); + object3.set('strings', ['over', 'the', 'lazy', 'dog']); + + const objectList = [object, object2, object3]; + + Parse.Object.saveAll(objectList) + .then(results => { + equal(objectList.length, results.length); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then( + function(response) { + const results = response.data; + equal(results.results.length, 2); + done(); + }, + function() {} + ); + }); + + it('containsAllStartingWith single invalid regex returns empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $unknown: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, }); + }) + .then( + function(response) { + const results = response.data; + equal(results.results.length, 0); + done(); + }, + function() {} + ); + }); + + it('containedBy pointer array', done => { + const objects = Array.from(Array(10).keys()).map(idx => { + const obj = new Parse.Object('Object'); + obj.set('key', idx); + return obj; + }); + + const parent = new Parse.Object('Parent'); + const parent2 = new Parse.Object('Parent'); + const parent3 = new Parse.Object('Parent'); + + Parse.Object.saveAll(objects) + .then(() => { + // [0, 1, 2] + parent.set('objects', objects.slice(0, 3)); + + const shift = objects.shift(); + // [2, 0] + parent2.set('objects', [objects[1], shift]); + + // [1, 2, 3, 4] + parent3.set('objects', objects.slice(1, 4)); + + return Parse.Object.saveAll([parent, parent2, parent3]); + }) + .then(() => { + // [1, 2, 3, 4, 5, 6, 7, 8, 9] + const pointers = objects.map(object => object.toPointer()); + + // Return all Parent where all parent.objects are contained in objects + return request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + where: JSON.stringify({ + objects: { + $containedBy: pointers, + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(response => { + const results = response.data; + expect(results.results[0].objectId).not.toBeUndefined(); + expect(results.results[0].objectId).toBe(parent3.id); + expect(results.results.length).toBe(1); + done(); }); }); - it("lessThanOrEqualTo greaterThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { + it('containedBy number array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ + numbers: { $containedBy: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }), + }, + }); + const obj1 = new TestObject({ numbers: [0, 1, 2] }); + const obj2 = new TestObject({ numbers: [2, 0] }); + const obj3 = new TestObject({ numbers: [1, 2, 3, 4] }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(response => { + const results = response.data; + expect(results.results[0].objectId).not.toBeUndefined(); + expect(results.results[0].objectId).toBe(obj3.id); + expect(results.results.length).toBe(1); + done(); + }); + }); + + it('containedBy empty array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ numbers: { $containedBy: [] } }), + }, + }); + const obj1 = new TestObject({ numbers: [0, 1, 2] }); + const obj2 = new TestObject({ numbers: [2, 0] }); + const obj3 = new TestObject({ numbers: [1, 2, 3, 4] }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(response => { + const results = response.data; + expect(results.results.length).toBe(0); + done(); + }); + }); + + it('containedBy invalid query', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objects: { $containedBy: 1234 } }), + }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_JSON); + equal(response.data.error, 'bad $containedBy: should be an array'); + done(); + }); + }); + + const BoxedNumber = Parse.Object.extend({ + className: 'BoxedNumber', + }); + + it('equalTo queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThanOrEqualTo('number', 7); - query.greaterThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 3); + query.find().then(function(results) { + equal(results.length, 1); + done(); }); + }); }); - it("lessThan greaterThan queries", function(done) { - var makeBoxedNumber = function(i) { + it('equalTo undefined', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThan('number', 9); - query.greaterThan('number', 3); - query.find({ - success: function(results) { - equal(results.length, 5); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', undefined); + query.find().then(function(results) { + equal(results.length, 0); + done(); }); + }); }); - it("notEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThan queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.notEqualTo('number', 5); - query.find({ - success: function(results) { - equal(results.length, 9); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 7); + query.find().then(function(results) { + equal(results.length, 7); + done(); }); + }); }); - it("containedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThanOrEqualTo queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.containedIn('number', [3,5,7,9,11]); - query.find({ - success: function(results) { - equal(results.length, 4); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 7); + query.find().then(function(results) { + equal(results.length, 8); + done(); + }); + }); + }); + + it('lessThan zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 3); + done(); + }); + }); + + it('lessThanOrEqualTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 4); + done(); }); }); - it("notContainedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('greaterThan queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.notContainedIn('number', [3,5,7,9,11]); - query.find({ - success: function(results) { - equal(results.length, 6); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 7); + query.find().then(function(results) { + equal(results.length, 2); + done(); }); + }); }); + it('greaterThanOrEqualTo queries', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.greaterThanOrEqualTo('number', 7); + query.find().then(function(results) { + equal(results.length, 3); + done(); + }); + }); + }); - it("objectId containedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('greaterThan zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); + }); + }); + + it('greaterThanOrEqualTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.greaterThanOrEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }); + }); + + it('lessThanOrEqualTo greaterThanOrEqualTo queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.containedIn('objectId', - [list[2].id, list[3].id, list[0].id, - "NONSENSE"]); - query.ascending('number'); - query.find({ - success: function(results) { - if (results.length != 3) { - fail('expected 3 results'); - } else { - equal(results[0].get('number'), 0); - equal(results[1].get('number'), 2); - equal(results[2].get('number'), 3); - } - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 7); + query.greaterThanOrEqualTo('number', 7); + query.find().then(function(results) { + equal(results.length, 1); + done(); }); + }); }); - it("objectId equalTo queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThan greaterThan queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.equalTo('objectId', list[4].id); - query.find({ - success: function(results) { - if (results.length != 1) { - fail('expected 1 result') - done(); - } else { - equal(results[0].get('number'), 4); - } - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 9); + query.greaterThan('number', 3); + query.find().then(function(results) { + equal(results.length, 5); + done(); }); + }); }); - it("find no elements", function(done) { - var makeBoxedNumber = function(i) { + it('notEqualTo queries', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', 17); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 0); - done(); - } - })); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.notEqualTo('number', 5); + query.find().then(function(results) { + equal(results.length, 9); + done(); }); + }); }); - it("find with error", function(done) { - var query = new Parse.Query(BoxedNumber); - query.equalTo('$foo', 'bar'); - query.find(expectError(Parse.Error.INVALID_KEY_NAME, done)); + it('notEqualTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.notEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 4); + done(); + }); }); - it("get", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { - ok(items[0]); - var objectId = items[0].id; - var query = new Parse.Query(TestObject); - query.get(objectId, { - success: function(result) { - ok(result); - equal(result.id, objectId); - equal(result.get('foo'), 'bar'); - ok(result.createdAt instanceof Date); - ok(result.updatedAt instanceof Date); + it('equalTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); + }); + }); + + it('number equalTo boolean queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', false); + return query.find(); + }) + .then(results => { + equal(results.length, 0); + done(); + }); + }); + + it('equalTo false queries', done => { + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: true }); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('field', false); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); + }); + }); + + it('where $eq false queries (rest)', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ field: { $eq: false } }), + }, + }); + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: true }); + Parse.Object.saveAll([obj1, obj2]).then(() => { + request( + Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options) + ).then(resp => { + equal(resp.data.results.length, 1); + done(); + }); + }); + }); + + it('where $eq null queries (rest)', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ field: { $eq: null } }), + }, + }); + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: null }); + Parse.Object.saveAll([obj1, obj2]).then(() => { + return request( + Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options) + ).then(resp => { + equal(resp.data.results.length, 1); + done(); + }); + }); + }); + + it('containedIn queries', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.containedIn('number', [3, 5, 7, 9, 11]); + query.find().then(function(results) { + equal(results.length, 4); + done(); + }); + }); + }); + + it('containedIn false queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.containedIn('number', false); + return query.find(); + }) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.INVALID_JSON); + equal(error.message, 'bad $in value'); + done(); + }); + }); + + it('notContainedIn false queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.notContainedIn('number', false); + return query.find(); + }) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.INVALID_JSON); + equal(error.message, 'bad $nin value'); + done(); + }); + }); + + it('notContainedIn queries', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.notContainedIn('number', [3, 5, 7, 9, 11]); + query.find().then(function(results) { + equal(results.length, 6); + done(); + }); + }); + }); + + it('objectId containedIn queries', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function(list) { + const query = new Parse.Query(BoxedNumber); + query.containedIn('objectId', [ + list[2].id, + list[3].id, + list[0].id, + 'NONSENSE', + ]); + query.ascending('number'); + query.find().then(function(results) { + if (results.length != 3) { + fail('expected 3 results'); + } else { + equal(results[0].get('number'), 0); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + } + done(); + }); + }); + }); + + it('objectId equalTo queries', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function(list) { + const query = new Parse.Query(BoxedNumber); + query.equalTo('objectId', list[4].id); + query.find().then(function(results) { + if (results.length != 1) { + fail('expected 1 result'); done(); + } else { + equal(results[0].get('number'), 4); } + done(); + }); + }); + }); + + it('find no elements', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 17); + query.find().then(function(results) { + equal(results.length, 0); + done(); }); }); }); - it("get undefined", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { + it('find with error', function(done) { + const query = new Parse.Query(BoxedNumber); + query.equalTo('$foo', 'bar'); + query + .find() + .then(done.fail) + .catch(error => expect(error.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); + }); + + it('get', function(done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function( + items + ) { ok(items[0]); - var query = new Parse.Query(TestObject); - query.get(undefined, { - success: fail, - error: done, + const objectId = items[0].id; + const query = new Parse.Query(TestObject); + query.get(objectId).then(function(result) { + ok(result); + equal(result.id, objectId); + equal(result.get('foo'), 'bar'); + ok(result.createdAt instanceof Date); + ok(result.updatedAt instanceof Date); + done(); }); }); }); - it("get error", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { + it('get undefined', function(done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function( + items + ) { ok(items[0]); - var query = new Parse.Query(TestObject); - query.get("InvalidObjectID", { - success: function() { - ok(false, "The get should have failed."); + const query = new Parse.Query(TestObject); + query.get(undefined).then(fail, () => done()); + }); + }); + + it('get error', function(done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function( + items + ) { + ok(items[0]); + const query = new Parse.Query(TestObject); + query.get('InvalidObjectID').then( + function() { + ok(false, 'The get should have failed.'); done(); }, - error: function(object, error) { + function(error) { equal(error.code, Parse.Error.OBJECT_NOT_FOUND); done(); } - }); + ); }); }); - it("first", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); + it('first', function(done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function() { + const query = new Parse.Query(TestObject); query.equalTo('foo', 'bar'); - query.first({ - success: function(result) { - equal(result.get('foo'), 'bar'); - done(); - } + query.first().then(function(result) { + equal(result.get('foo'), 'bar'); + done(); }); }); }); - it("first no result", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); + it('first no result', function(done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function() { + const query = new Parse.Query(TestObject); query.equalTo('foo', 'baz'); - query.first({ - success: function(result) { - equal(result, undefined); - done(); - } + query.first().then(function(result) { + equal(result, undefined); + done(); }); }); }); - it("first with two results", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'}), - new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); + it('first with two results', function(done) { + Parse.Object.saveAll([ + new TestObject({ foo: 'bar' }), + new TestObject({ foo: 'bar' }), + ]).then(function() { + const query = new Parse.Query(TestObject); query.equalTo('foo', 'bar'); - query.first({ - success: function(result) { - equal(result.get('foo'), 'bar'); - done(); - } + query.first().then(function(result) { + equal(result.get('foo'), 'bar'); + done(); }); }); }); - it("first with error", function(done) { - var query = new Parse.Query(BoxedNumber); + it('first with error', function(done) { + const query = new Parse.Query(BoxedNumber); query.equalTo('$foo', 'bar'); - query.first(expectError(Parse.Error.INVALID_KEY_NAME, done)); + query + .first() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); }); - var Container = Parse.Object.extend({ - className: "Container" + const Container = Parse.Object.extend({ + className: 'Container', }); - it("notEqualTo object", function(done) { - var item1 = new TestObject(); - var item2 = new TestObject(); - var container1 = new Container({item: item1}); - var container2 = new Container({item: item2}); - Parse.Object.saveAll([item1, item2, container1, container2], function() { - var query = new Parse.Query(Container); - query.notEqualTo('item', item1); - query.find({ - success: function(results) { + it('notEqualTo object', function(done) { + const item1 = new TestObject(); + const item2 = new TestObject(); + const container1 = new Container({ item: item1 }); + const container2 = new Container({ item: item2 }); + Parse.Object.saveAll([item1, item2, container1, container2]).then( + function() { + const query = new Parse.Query(Container); + query.notEqualTo('item', item1); + query.find().then(function(results) { equal(results.length, 1); done(); - } - }); - }); + }); + } + ); }); - it("skip", function(done) { - Parse.Object.saveAll([new TestObject(), new TestObject()], function() { - var query = new Parse.Query(TestObject); + it('skip', function(done) { + Parse.Object.saveAll([new TestObject(), new TestObject()]).then(function() { + const query = new Parse.Query(TestObject); query.skip(1); - query.find({ - success: function(results) { - equal(results.length, 1); - query.skip(3); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - } + query.find().then(function(results) { + equal(results.length, 1); + query.skip(3); + query.find().then(function(results) { + equal(results.length, 0); + done(); + }); }); }); }); it("skip doesn't affect count", function(done) { - Parse.Object.saveAll([new TestObject(), new TestObject()], function() { - var query = new Parse.Query(TestObject); - query.count({ - success: function(count) { + Parse.Object.saveAll([new TestObject(), new TestObject()]).then(function() { + const query = new Parse.Query(TestObject); + query.count().then(function(count) { + equal(count, 2); + query.skip(1); + query.count().then(function(count) { equal(count, 2); - query.skip(1); - query.count({ - success: function(count) { - equal(count, 2); - query.skip(3); - query.count({ - success: function(count) { - equal(count, 2); - done(); - } - }); - } + query.skip(3); + query.count().then(function(count) { + equal(count, 2); + done(); }); - } + }); }); }); }); - it("count", function(done) { - var makeBoxedNumber = function(i) { + it('count', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThan("number", 1); - query.count({ - success: function(count) { - equal(count, 8); - done(); - } - }); + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber) + ).then(function() { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 1); + query.count().then(function(count) { + equal(count, 8); + done(); }); + }); }); - it("order by ascending number", function(done) { - var makeBoxedNumber = function(i) { + it('order by ascending number', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("number"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 1); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 3); - done(); - } - })); + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.ascending('number'); + query.find().then(function(results) { + equal(results.length, 3); + equal(results[0].get('number'), 1); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + done(); + }); }); }); - it("order by descending number", function(done) { - var makeBoxedNumber = function(i) { + it('order by descending number', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function() { - var query = new Parse.Query(BoxedNumber); - query.descending("number"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 1); - done(); - } - })); + const query = new Parse.Query(BoxedNumber); + query.descending('number'); + query.find().then(function(results) { + equal(results.length, 3); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 1); + done(); + }); }); }); - it("order by ascending number then descending string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { + it('can order on an object string field', function(done) { + const testSet = [ + { sortField: { value: 'Z' } }, + { sortField: { value: 'A' } }, + { sortField: { value: 'M' } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => + new Parse.Query('Test').addDescending('sortField.value').first() + ) + .then(result => { + expect(result.get('sortField').value).toBe('Z'); + return new Parse.Query('Test').addAscending('sortField.value').first(); + }) + .then(result => { + expect(result.get('sortField').value).toBe('A'); + done(); + }) + .catch(done.fail); + }); + + it('can order on an object string field (level 2)', function(done) { + const testSet = [ + { sortField: { value: { field: 'Z' } } }, + { sortField: { value: { field: 'A' } } }, + { sortField: { value: { field: 'M' } } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => + new Parse.Query('Test').addDescending('sortField.value.field').first() + ) + .then(result => { + expect(result.get('sortField').value.field).toBe('Z'); + return new Parse.Query('Test') + .addAscending('sortField.value.field') + .first(); + }) + .then(result => { + expect(result.get('sortField').value.field).toBe('A'); + done(); + }) + .catch(done.fail); + }); + + it('can order on an object number field', function(done) { + const testSet = [ + { sortField: { value: 10 } }, + { sortField: { value: 1 } }, + { sortField: { value: 5 } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => + new Parse.Query('Test').addDescending('sortField.value').first() + ) + .then(result => { + expect(result.get('sortField').value).toBe(10); + return new Parse.Query('Test').addAscending('sortField.value').first(); + }) + .then(result => { + expect(result.get('sortField').value).toBe(1); + done(); + }) + .catch(done.fail); + }); + + it('can order on an object number field (level 2)', function(done) { + const testSet = [ + { sortField: { value: { field: 10 } } }, + { sortField: { value: { field: 1 } } }, + { sortField: { value: { field: 5 } } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => + new Parse.Query('Test').addDescending('sortField.value.field').first() + ) + .then(result => { + expect(result.get('sortField').value.field).toBe(10); + return new Parse.Query('Test') + .addAscending('sortField.value.field') + .first(); + }) + .then(result => { + expect(result.get('sortField').value.field).toBe(1); + done(); + }) + .catch(done.fail); + }); + + it('order by ascending number then descending string', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { return new BoxedNumber({ number: num, string: strings[i] }); }; - Parse.Object.saveAll( - [3, 1, 3, 2].map(makeBoxedNumber)).then( - function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("number").addDescending("string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 1); - equal(results[0].get("string"), "b"); - equal(results[1].get("number"), 2); - equal(results[1].get("string"), "d"); - equal(results[2].get("number"), 3); - equal(results[2].get("string"), "c"); - equal(results[3].get("number"), 3); - equal(results[3].get("string"), "a"); - done(); - } - })); + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.ascending('number').addDescending('string'); + query.find().then(function(results) { + equal(results.length, 4); + equal(results[0].get('number'), 1); + equal(results[0].get('string'), 'b'); + equal(results[1].get('number'), 2); + equal(results[1].get('string'), 'd'); + equal(results[2].get('number'), 3); + equal(results[2].get('string'), 'c'); + equal(results[3].get('number'), 3); + equal(results[3].get('string'), 'a'); + done(); }); + }); }); - it("order by descending number then ascending string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { + it('order by descending number then ascending string', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { return new BoxedNumber({ number: num, string: strings[i] }); }; const objects = [3, 1, 3, 2].map(makeBoxedNumber); Parse.Object.saveAll(objects) .then(() => { - var query = new Parse.Query(BoxedNumber); - query.descending("number").addAscending("string"); + const query = new Parse.Query(BoxedNumber); + query.descending('number').addAscending('string'); return query.find(); - }).then((results) => { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "a"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "c"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - }, (err) => { - jfail(err); - done(); - }); + }) + .then( + results => { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'a'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'c'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it("order by descending number and string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { + it('order by descending number and string', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { return new BoxedNumber({ number: num, string: strings[i] }); }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( - function() { - var query = new Parse.Query(BoxedNumber); - query.descending("number,string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.descending('number,string'); + query.find().then(function(results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); }); + }); }); - 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]}); + it('order by descending number and string, with space', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); }; Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( - function () { - var query = new Parse.Query(BoxedNumber); - query.descending("number, string"); - query.find(expectSuccess({ - success: function (results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); + function() { + const query = new Parse.Query(BoxedNumber); + query.descending('number, string'); + query.find().then(function(results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }); }, - (err) => { + err => { jfail(err); done(); - }); + } + ); }); - it("order by descending number and string, with array arg", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { + it('order by descending number and string, with array arg', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { return new BoxedNumber({ number: num, string: strings[i] }); }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( - function() { - var query = new Parse.Query(BoxedNumber); - query.descending(["number", "string"]); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.descending(['number', 'string']); + query.find().then(function(results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); }); + }); }); - it("order by descending number and string, with multiple args", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { + it('order by descending number and string, with multiple args', function(done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function(num, i) { return new BoxedNumber({ number: num, string: strings[i] }); }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( - function() { - var query = new Parse.Query(BoxedNumber); - query.descending("number", "string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.descending('number', 'string'); + query.find().then(function(results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); }); + }); }); it("can't order by password", function(done) { - var makeBoxedNumber = function(i) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("_password"); - query.find(expectError(Parse.Error.INVALID_KEY_NAME, done)); + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function() { + const query = new Parse.Query(BoxedNumber); + query.ascending('_password'); + query + .find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); }); }); - it("order by _created_at", function(done) { - var makeBoxedNumber = function(i) { + it('order by _created_at', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("_created_at"); - query.find({ - success: function(results) { + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function() { + const query = new Parse.Query(BoxedNumber); + query.ascending('_created_at'); + query.find().then(function(results) { equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 1); - equal(results[2].get("number"), 2); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 1); + equal(results[2].get('number'), 2); done(); - }, - error: function(e) { - jfail(e); - done(); - }, + }, done.fail); }); - }); }); - it("order by createdAt", function(done) { - var makeBoxedNumber = function(i) { + it('order by createdAt', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - var query = new Parse.Query(BoxedNumber); - query.descending("createdAt"); - query.find({ - success: function(results) { + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function() { + const query = new Parse.Query(BoxedNumber); + query.descending('createdAt'); + query.find().then(function(results) { equal(results.length, 3); - equal(results[0].get("number"), 2); - equal(results[1].get("number"), 1); - equal(results[2].get("number"), 3); + equal(results[0].get('number'), 2); + equal(results[1].get('number'), 1); + equal(results[2].get('number'), 3); done(); - } + }); }); - }); }); - it("order by _updated_at", function(done) { - var makeBoxedNumber = function(i) { + it('order by _updated_at', function(done) { + const makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - numbers[1].set("number", 4); - numbers[1].save(null, { - success: function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("_updated_at"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 4); - done(); - } + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function() { + numbers[1].set('number', 4); + numbers[1].save().then(function() { + const query = new Parse.Query(BoxedNumber); + query.ascending('_updated_at'); + query.find().then(function(results) { + equal(results.length, 3); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 4); + done(); }); - } + }); }); - }); }); - it("order by updatedAt", function(done) { - var makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - numbers[1].set("number", 4); - numbers[1].save(null, { - success: function() { - var query = new Parse.Query(BoxedNumber); - query.descending("_updated_at"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 4); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 3); - done(); - } + it('order by updatedAt', function(done) { + const makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function() { + numbers[1].set('number', 4); + numbers[1].save().then(function() { + const query = new Parse.Query(BoxedNumber); + query.descending('_updated_at'); + query.find().then(function(results) { + equal(results.length, 3); + equal(results[0].get('number'), 4); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + done(); }); - } + }); }); - }); }); // Returns a promise function makeTimeObject(start, i) { - var time = new Date(); + const time = new Date(); time.setSeconds(start.getSeconds() + i); - var item = new TestObject({name: "item" + i, time: time}); + const item = new TestObject({ name: 'item' + i, time: time }); return item.save(); } // Returns a promise for all the time objects function makeThreeTimeObjects() { - var start = new Date(); - var one, two, three; - return makeTimeObject(start, 1).then((o1) => { - one = o1; - return makeTimeObject(start, 2); - }).then((o2) => { - two = o2; - return makeTimeObject(start, 3); - }).then((o3) => { - three = o3; - return [one, two, three]; - }); + const start = new Date(); + let one, two, three; + return makeTimeObject(start, 1) + .then(o1 => { + one = o1; + return makeTimeObject(start, 2); + }) + .then(o2 => { + two = o2; + return makeTimeObject(start, 3); + }) + .then(o3 => { + three = o3; + return [one, two, three]; + }); } - it("time equality", function(done) { + it('time equality', function(done) { makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.equalTo("time", list[1].get("time")); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get("name"), "item2"); - done(); - } + const query = new Parse.Query(TestObject); + query.equalTo('time', list[1].get('time')); + query.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), 'item2'); + done(); }); }); }); - it("time lessThan", function(done) { + it('time lessThan', function(done) { makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.lessThan("time", list[2].get("time")); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } + const query = new Parse.Query(TestObject); + query.lessThan('time', list[2].get('time')); + query.find().then(function(results) { + equal(results.length, 2); + done(); }); }); }); // This test requires Date objects to be consistently stored as a Date. - it("time createdAt", function(done) { + it('time createdAt', function(done) { makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.greaterThanOrEqualTo("createdAt", list[0].createdAt); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + const query = new Parse.Query(TestObject); + query.greaterThanOrEqualTo('createdAt', list[0].createdAt); + query.find().then(function(results) { + equal(results.length, 3); + done(); }); }); }); - it("matches string", function(done) { - var thing1 = new TestObject(); - thing1.set("myString", "football"); - var thing2 = new TestObject(); - thing2.set("myString", "soccer"); - Parse.Object.saveAll([thing1, thing2], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", "^fo*\\wb[^o]l+$"); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('matches string', function(done) { + const thing1 = new TestObject(); + thing1.set('myString', 'football'); + const thing2 = new TestObject(); + thing2.set('myString', 'soccer'); + Parse.Object.saveAll([thing1, thing2]).then(function() { + const query = new Parse.Query(TestObject); + query.matches('myString', '^fo*\\wb[^o]l+$'); + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - it("matches regex", function(done) { - var thing1 = new TestObject(); - thing1.set("myString", "football"); - var thing2 = new TestObject(); - thing2.set("myString", "soccer"); - Parse.Object.saveAll([thing1, thing2], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", /^fo*\wb[^o]l+$/); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('matches regex', function(done) { + const thing1 = new TestObject(); + thing1.set('myString', 'football'); + const thing2 = new TestObject(); + thing2.set('myString', 'soccer'); + Parse.Object.saveAll([thing1, thing2]).then(function() { + const query = new Parse.Query(TestObject); + query.matches('myString', /^fo*\wb[^o]l+$/); + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - it("case insensitive regex success", function(done) { - var thing = new TestObject(); - thing.set("myString", "football"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", "FootBall", "i"); - query.find({ - success: function() { - done(); - } - }); + it('case insensitive regex success', function(done) { + const thing = new TestObject(); + thing.set('myString', 'football'); + Parse.Object.saveAll([thing]).then(function() { + const query = new Parse.Query(TestObject); + query.matches('myString', 'FootBall', 'i'); + query.find().then(done); }); }); - it("regexes with invalid options fail", function(done) { - var query = new Parse.Query(TestObject); - query.matches("myString", "FootBall", "some invalid option"); - query.find(expectError(Parse.Error.INVALID_QUERY, done)); + it('regexes with invalid options fail', function(done) { + const query = new Parse.Query(TestObject); + query.matches('myString', 'FootBall', 'some invalid option'); + query + .find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_QUERY)) + .then(done); }); - it("Use a regex that requires all modifiers", function(done) { - var thing = new TestObject(); - thing.set("myString", "PArSe\nCom"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); + it('Use a regex that requires all modifiers', function(done) { + const thing = new TestObject(); + thing.set('myString', 'PArSe\nCom'); + Parse.Object.saveAll([thing]).then(function() { + const query = new Parse.Query(TestObject); query.matches( - "myString", + 'myString', "parse # First fragment. We'll write this in one case but match " + - "insensitively\n.com # Second fragment. This can be separated by any " + - "character, including newline", - "mixs"); - query.find({ - success: function(results) { + 'insensitively\n.com # Second fragment. This can be separated by any ' + + 'character, including newline', + 'mixs' + ); + query.find().then( + function(results) { equal(results.length, 1); done(); }, - error: function(err) { + function(err) { jfail(err); done(); } - }); + ); }); }); - it("Regular expression constructor includes modifiers inline", function(done) { - var thing = new TestObject(); - thing.set("myString", "\n\nbuffer\n\nparse.COM"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", /parse\.com/mi); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('Regular expression constructor includes modifiers inline', function(done) { + const thing = new TestObject(); + thing.set('myString', '\n\nbuffer\n\nparse.COM'); + Parse.Object.saveAll([thing]).then(function() { + const query = new Parse.Query(TestObject); + query.matches('myString', /parse\.com/im); + query.find().then(function(results) { + equal(results.length, 1); + done(); }); }); }); - var someAscii = "\\E' !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTU" + + const someAscii = + "\\E' !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTU" + "VWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'"; - it("contains", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.contains("myString", someAscii); - query.find({ - success: function(results) { - equal(results.length, 4); - done(); - } + it('contains', function(done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function() { + const query = new Parse.Query(TestObject); + query.contains('myString', someAscii); + query.find().then(function(results) { + equal(results.length, 4); + done(); }); }); }); - it('nested contains', (done) => { + it('nested contains', done => { const sender1 = { group: ['A', 'B'] }; const sender2 = { group: ['A', 'C'] }; const sender3 = { group: ['B', 'C'] }; const obj1 = new TestObject({ sender: sender1 }); const obj2 = new TestObject({ sender: sender2 }); const obj3 = new TestObject({ sender: sender3 }); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - const query = new Parse.Query(TestObject); - query.contains('sender.group', 'A'); - return query.find(); - }).then((results) => { - equal(results.length, 2); - done(); - }, done.fail); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.contains('sender.group', 'A'); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); }); - it("startsWith", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.startsWith("myString", someAscii); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } + it('startsWith', function(done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function() { + const query = new Parse.Query(TestObject); + query.startsWith('myString', someAscii); + query.find().then(function(results) { + equal(results.length, 2); + done(); }); }); }); - it("endsWith", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.endsWith("myString", someAscii); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } + it('endsWith', function(done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function() { + const query = new Parse.Query(TestObject); + query.endsWith('myString', someAscii); + query.find().then(function(results) { + equal(results.length, 2); + done(); }); }); }); - it("exists", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var item = new TestObject(); + it('exists', function(done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const item = new TestObject(); if (i % 2 === 0) { item.set('x', i + 1); } else { @@ -1505,25 +2244,23 @@ describe('Parse.Query testing', () => { } objects.push(item); } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - query.exists("x"); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var result of results) { - ok(result.get("x")); - } - done(); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(TestObject); + query.exists('x'); + query.find().then(function(results) { + equal(results.length, 5); + for (const result of results) { + ok(result.get('x')); } + done(); }); }); }); - it("doesNotExist", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var item = new TestObject(); + it('doesNotExist', function(done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const item = new TestObject(); if (i % 2 === 0) { item.set('x', i + 1); } else { @@ -1531,27 +2268,25 @@ describe('Parse.Query testing', () => { } objects.push(item); } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - query.doesNotExist("x"); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var result of results) { - ok(result.get("y")); - } - done(); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(TestObject); + query.doesNotExist('x'); + query.find().then(function(results) { + equal(results.length, 4); + for (const result of results) { + ok(result.get('y')); } + done(); }); }); }); - it("exists relation", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var container = new Container(); + it('exists relation', function(done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const container = new Container(); if (i % 2 === 0) { - var item = new TestObject(); + const item = new TestObject(); item.set('x', i); container.set('x', item); objects.push(item); @@ -1561,26 +2296,24 @@ describe('Parse.Query testing', () => { objects.push(container); } Parse.Object.saveAll(objects).then(function() { - var query = new Parse.Query(Container); - query.exists("x"); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var result of results) { - ok(result.get("x")); - } - done(); + const query = new Parse.Query(Container); + query.exists('x'); + query.find().then(function(results) { + equal(results.length, 5); + for (const result of results) { + ok(result.get('x')); } + done(); }); }); }); - it("doesNotExist relation", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7]) { - var container = new Container(); + it('doesNotExist relation', function(done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7]) { + const container = new Container(); if (i % 2 === 0) { - var item = new TestObject(); + const item = new TestObject(); item.set('x', i); container.set('x', item); objects.push(item); @@ -1589,472 +2322,577 @@ describe('Parse.Query testing', () => { } objects.push(container); } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(Container); - query.doesNotExist("x"); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var result of results) { - ok(result.get("y")); - } - done(); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(Container); + query.doesNotExist('x'); + query.find().then(function(results) { + equal(results.length, 4); + for (const result of results) { + ok(result.get('y')); } + done(); }); }); }); it("don't include by default", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function() { child._clearServerData(); - var query = new Parse.Query(Container); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); - ok(childAgain); - equal(childAgain.get("foo"), undefined); - Parse.serverURL = goodURL; - done(); - } + const query = new Parse.Query(Container); + query.find().then(function(results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), undefined); + Parse.serverURL = goodURL; + done(); }); }); }); - it("include relation", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Container); - query.include("child"); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); - ok(childAgain); - equal(childAgain.get("foo"), "bar"); - Parse.serverURL = goodURL; - done(); - } + it('include relation', function(done) { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function() { + const query = new Parse.Query(Container); + query.include('child'); + query.find().then(function(results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), 'bar'); + Parse.serverURL = goodURL; + done(); }); }); }); - it("include relation array", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Container); - query.include(["child"]); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); - ok(childAgain); - equal(childAgain.get("foo"), "bar"); - Parse.serverURL = goodURL; - done(); - } + it('include relation array', function(done) { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function() { + const query = new Parse.Query(Container); + query.include(['child']); + query.find().then(function(results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), 'bar'); + Parse.serverURL = goodURL; + done(); }); }); }); - it("nested include", function(done) { - var Child = Parse.Object.extend("Child"); - var Parent = Parse.Object.extend("Parent"); - var Grandparent = Parse.Object.extend("Grandparent"); - var objects = []; - for (var i = 0; i < 5; ++i) { - var grandparent = new Grandparent({ - z:i, + it('nested include', function(done) { + const Child = Parse.Object.extend('Child'); + const Parent = Parse.Object.extend('Parent'); + const Grandparent = Parse.Object.extend('Grandparent'); + const objects = []; + for (let i = 0; i < 5; ++i) { + const grandparent = new Grandparent({ + z: i, parent: new Parent({ - y:i, + y: i, child: new Child({ - x:i - }) - }) + x: i, + }), + }), }); objects.push(grandparent); } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(Grandparent); - query.include(["parent.child"]); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var object of results) { - equal(object.get("z"), object.get("parent").get("y")); - equal(object.get("z"), object.get("parent").get("child").get("x")); - } - done(); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(Grandparent); + query.include(['parent.child']); + query.find().then(function(results) { + equal(results.length, 5); + for (const object of results) { + equal(object.get('z'), object.get('parent').get('y')); + equal( + object.get('z'), + object + .get('parent') + .get('child') + .get('x') + ); } + done(); }); }); }); it("include doesn't make dirty wrong", function(done) { - var Parent = Parse.Object.extend("ParentObject"); - var Child = Parse.Object.extend("ChildObject"); - var parent = new Parent(); - var child = new Child(); - child.set("foo", "bar"); - parent.set("child", child); - - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Parent); - query.include("child"); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var childAgain = parentAgain.get("child"); - equal(childAgain.id, child.id); - equal(parentAgain.id, parent.id); - equal(childAgain.get("foo"), "bar"); - equal(false, parentAgain.dirty()); - equal(false, childAgain.dirty()); - done(); - } + const Parent = Parse.Object.extend('ParentObject'); + const Child = Parse.Object.extend('ChildObject'); + const parent = new Parent(); + const child = new Child(); + child.set('foo', 'bar'); + parent.set('child', child); + + Parse.Object.saveAll([child, parent]).then(function() { + const query = new Parse.Query(Parent); + query.include('child'); + query.find().then(function(results) { + equal(results.length, 1); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); + equal(childAgain.id, child.id); + equal(parentAgain.id, parent.id); + equal(childAgain.get('foo'), 'bar'); + equal(false, parentAgain.dirty()); + equal(false, childAgain.dirty()); + done(); }); }); }); - it('properly includes array', (done) => { + it('properly includes array', done => { const objects = []; let total = 0; - while(objects.length != 5) { + while (objects.length != 5) { const object = new Parse.Object('AnObject'); object.set('key', objects.length); total += objects.length; objects.push(object); } - Parse.Object.saveAll(objects).then(() => { - const object = new Parse.Object("AContainer"); - object.set('objects', objects); - return object.save(); - }).then(() => { - const query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - const res = results[0]; - const objects = res.get('objects'); - expect(objects.length).toBe(5); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, () => { - fail('should not fail'); - done(); - }) - }); - - it('properly includes array of mixed objects', (done) => { + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(5); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('properly includes array of mixed objects', done => { const objects = []; let total = 0; - while(objects.length != 5) { + while (objects.length != 5) { const object = new Parse.Object('AnObject'); object.set('key', objects.length); total += objects.length; objects.push(object); } - while(objects.length != 10) { + while (objects.length != 10) { const object = new Parse.Object('AnotherObject'); object.set('key', objects.length); total += objects.length; objects.push(object); } - Parse.Object.saveAll(objects).then(() => { - const object = new Parse.Object("AContainer"); - object.set('objects', objects); - return object.save(); - }).then(() => { - const query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - const res = results[0]; - const objects = res.get('objects'); - expect(objects.length).toBe(10); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, (e) => { - fail('should not fail'); - fail(JSON.stringify(e)); - done(); - }) - }); - - it('properly nested array of mixed objects with bad ids', (done) => { + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(10); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + e => { + fail('should not fail'); + fail(JSON.stringify(e)); + done(); + } + ); + }); + + it('properly nested array of mixed objects with bad ids', done => { const objects = []; let total = 0; - while(objects.length != 5) { + while (objects.length != 5) { const object = new Parse.Object('AnObject'); object.set('key', objects.length); objects.push(object); } - while(objects.length != 10) { + while (objects.length != 10) { const object = new Parse.Object('AnotherObject'); object.set('key', objects.length); objects.push(object); } - Parse.Object.saveAll(objects).then(() => { - const object = new Parse.Object("AContainer"); - for (var i = 0; i < objects.length; i++) { - if (i % 2 == 0) { - objects[i].id = 'randomThing' - } else { - total += objects[i].get('key'); + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + for (let i = 0; i < objects.length; i++) { + if (i % 2 == 0) { + objects[i].id = 'randomThing'; + } else { + total += objects[i].get('key'); + } } - } - object.set('objects', objects); - return object.save(); - }).then(() => { - const query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - const res = results[0]; - const objects = res.get('objects'); - expect(objects.length).toBe(5); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, (err) => { - jfail(err); - fail('should not fail'); - done(); - }) - }); - - it('properly fetches nested pointers', (done) => { + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(5); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + err => { + jfail(err); + fail('should not fail'); + done(); + } + ); + }); + + it('properly fetches nested pointers', done => { const color = new Parse.Object('Color'); - color.set('hex','#133733'); + color.set('hex', '#133733'); const circle = new Parse.Object('Circle'); circle.set('radius', 1337); - Parse.Object.saveAll([color, circle]).then(() => { - circle.set('color', color); - const badCircle = new Parse.Object('Circle'); - badCircle.id = 'badId'; - const complexFigure = new Parse.Object('ComplexFigure'); - complexFigure.set('consistsOf', [circle, badCircle]); - return complexFigure.save(); - }).then(() => { - const q = new Parse.Query('ComplexFigure'); - q.include('consistsOf.color'); - return q.find() - }).then((results) => { - expect(results.length).toBe(1); - const figure = results[0]; - expect(figure.get('consistsOf').length).toBe(1); - expect(figure.get('consistsOf')[0].get('color').get('hex')).toBe('#133733'); - done(); - }, () => { - fail('should not fail'); - done(); - }) - - }); - - it("result object creation uses current extension", function(done) { - var ParentObject = Parse.Object.extend({ className: "ParentObject" }); + Parse.Object.saveAll([color, circle]) + .then(() => { + circle.set('color', color); + const badCircle = new Parse.Object('Circle'); + badCircle.id = 'badId'; + const complexFigure = new Parse.Object('ComplexFigure'); + complexFigure.set('consistsOf', [circle, badCircle]); + return complexFigure.save(); + }) + .then(() => { + const q = new Parse.Query('ComplexFigure'); + q.include('consistsOf.color'); + return q.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const figure = results[0]; + expect(figure.get('consistsOf').length).toBe(1); + expect( + figure + .get('consistsOf')[0] + .get('color') + .get('hex') + ).toBe('#133733'); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('result object creation uses current extension', function(done) { + const ParentObject = Parse.Object.extend({ className: 'ParentObject' }); // Add a foo() method to ChildObject. - var ChildObject = Parse.Object.extend("ChildObject", { + let ChildObject = Parse.Object.extend('ChildObject', { foo: function() { - return "foo"; - } + return 'foo'; + }, }); - var parent = new ParentObject(); - var child = new ChildObject(); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { + const parent = new ParentObject(); + const child = new ChildObject(); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function() { // Add a bar() method to ChildObject. - ChildObject = Parse.Object.extend("ChildObject", { + ChildObject = Parse.Object.extend('ChildObject', { bar: function() { - return "bar"; - } + return 'bar'; + }, }); - var query = new Parse.Query(ParentObject); - query.include("child"); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var childAgain = parentAgain.get("child"); - equal(childAgain.foo(), "foo"); - equal(childAgain.bar(), "bar"); - done(); - } + const query = new Parse.Query(ParentObject); + query.include('child'); + query.find().then(function(results) { + equal(results.length, 1); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); + equal(childAgain.foo(), 'foo'); + equal(childAgain.bar(), 'bar'); + done(); }); }); }); - it("matches query", function(done) { - var ParentObject = Parse.Object.extend("ParentObject"); - var ChildObject = Parse.Object.extend("ChildObject"); - var objects = []; - for (var i = 0; i < 10; ++i) { + it('matches query', function(done) { + const ParentObject = Parse.Object.extend('ParentObject'); + const ChildObject = Parse.Object.extend('ChildObject'); + const objects = []; + for (let i = 0; i < 10; ++i) { objects.push( new ParentObject({ - child: new ChildObject({x: i}), - x: 10 + i - })); + child: new ChildObject({ x: i }), + x: 10 + i, + }) + ); } - Parse.Object.saveAll(objects, function() { - var subQuery = new Parse.Query(ChildObject); - subQuery.greaterThan("x", 5); - var query = new Parse.Query(ParentObject); - query.matchesQuery("child", subQuery); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var object of results) { - ok(object.get("x") > 15); - } - var query = new Parse.Query(ParentObject); - query.doesNotMatchQuery("child", subQuery); - query.find({ - success: function (results) { - equal(results.length, 6); - for (var object of results) { - ok(object.get("x") >= 10); - ok(object.get("x") <= 15); - done(); - } - } - }); + Parse.Object.saveAll(objects).then(function() { + const subQuery = new Parse.Query(ChildObject); + subQuery.greaterThan('x', 5); + const query = new Parse.Query(ParentObject); + query.matchesQuery('child', subQuery); + query.find().then(function(results) { + equal(results.length, 4); + for (const object of results) { + ok(object.get('x') > 15); } + const query = new Parse.Query(ParentObject); + query.doesNotMatchQuery('child', subQuery); + query.find().then(function(results) { + equal(results.length, 6); + for (const object of results) { + ok(object.get('x') >= 10); + ok(object.get('x') <= 15); + done(); + } + }); }); }); }); - it("select query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var objects = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }) + it('select query', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), ]; - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(RestaurantObject); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.matchesKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'Bob'); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.matchesKeyInQuery('hometown', 'location', query); + mainQuery.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Bob'); + done(); + }); + }); + }); + + it('$select inside $or', done => { + const Restaurant = Parse.Object.extend('Restaurant'); + const Person = Parse.Object.extend('Person'); + const objects = [ + new Restaurant({ ratings: 5, location: 'Djibouti' }), + new Restaurant({ ratings: 3, location: 'Ouagadougou' }), + new Person({ name: 'Bob', hometown: 'Djibouti' }), + new Person({ name: 'Tom', hometown: 'Ouagadougou' }), + new Person({ name: 'Billy', hometown: 'Detroit' }), + ]; + + Parse.Object.saveAll(objects) + .then(() => { + const subquery = new Parse.Query(Restaurant); + subquery.greaterThan('ratings', 4); + const query1 = new Parse.Query(Person); + query1.matchesKeyInQuery('hometown', 'location', subquery); + const query2 = new Parse.Query(Person); + query2.equalTo('name', 'Tom'); + const query = Parse.Query.or(query1, query2); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(2); + done(); + }, + error => { + jfail(error); done(); } - })); + ); + }); + + it('$nor valid query', done => { + const objects = Array.from(Array(10).keys()).map(rating => { + return new TestObject({ rating: rating }); + }); + + const highValue = 5; + const lowValue = 3; + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ + $nor: [ + { rating: { $gt: highValue } }, + { rating: { $lte: lowValue } }, + ], + }), + }, + }); + + Parse.Object.saveAll(objects) + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(response => { + const results = response.data; + expect(results.results.length).toBe(highValue - lowValue); + expect( + results.results.every( + res => res.rating > lowValue && res.rating <= highValue + ) + ).toBe(true); + done(); + }); + }); + + it('$nor invalid query - empty array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ $nor: [] }), + }, }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_QUERY); + done(); + }); }); - it('$select inside $or', (done) => { - var Restaurant = Parse.Object.extend('Restaurant'); - var Person = Parse.Object.extend('Person'); - var objects = [ - new Restaurant({ ratings: 5, location: "Djibouti" }), - new Restaurant({ ratings: 3, location: "Ouagadougou" }), - new Person({ name: "Bob", hometown: "Djibouti" }), - new Person({ name: "Tom", hometown: "Ouagadougou" }), - new Person({ name: "Billy", hometown: "Detroit" }) - ]; + it('$nor invalid query - wrong type', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ $nor: 1337 }), + }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request( + Object.assign( + { url: Parse.serverURL + '/classes/TestObject' }, + options + ) + ); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_QUERY); + done(); + }); + }); - Parse.Object.saveAll(objects).then(() => { - var subquery = new Parse.Query(Restaurant); - subquery.greaterThan('ratings', 4); - var query1 = new Parse.Query(Person); - query1.matchesKeyInQuery('hometown', 'location', subquery); - var query2 = new Parse.Query(Person); - query2.equalTo('name', 'Tom'); - var query = Parse.Query.or(query1, query2); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(2); - done(); - }, (error) => { - jfail(error); - done(); - }); - }); - - it("dontSelect query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var objects = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Djibouti" }) + it('dontSelect query', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Djibouti' }), ]; - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(RestaurantObject); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'Tom'); - done(); - } - })); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); + mainQuery.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Tom'); + done(); + }); }); }); - it("dontSelect query without conditions", function(done) { - const RestaurantObject = Parse.Object.extend("Restaurant"); - const PersonObject = Parse.Object.extend("Person"); + it('dontSelect query without conditions', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); const objects = [ - new RestaurantObject({ location: "Djibouti" }), - new RestaurantObject({ location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Yoloblahblahblah" }), - new PersonObject({ name: "Billy", hometown: "Ouagadougou" }) + new RestaurantObject({ location: 'Djibouti' }), + new RestaurantObject({ location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Yoloblahblahblah' }), + new PersonObject({ name: 'Billy', hometown: 'Ouagadougou' }), ]; - Parse.Object.saveAll(objects, function() { + Parse.Object.saveAll(objects).then(function() { const query = new Parse.Query(RestaurantObject); const mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); mainQuery.find().then(results => { equal(results.length, 1); equal(results[0].get('name'), 'Tom'); @@ -2063,482 +2901,629 @@ describe('Parse.Query testing', () => { }); }); - it("equalTo on same column as $dontSelect should not break $dontSelect functionality (#3678)", function(done) { - var AuthorObject = Parse.Object.extend("Author"); - var BlockedObject = Parse.Object.extend("Blocked"); - var PostObject = Parse.Object.extend("Post"); + it('equalTo on same column as $dontSelect should not break $dontSelect functionality (#3678)', function(done) { + const AuthorObject = Parse.Object.extend('Author'); + const BlockedObject = Parse.Object.extend('Blocked'); + const PostObject = Parse.Object.extend('Post'); - var postAuthor = null; - var requestUser = null; + let postAuthor = null; + let requestUser = null; - return new AuthorObject({ name: "Julius"}).save().then((user) => { - postAuthor = user; - return new AuthorObject({ name: "Bob"}).save(); - }).then((user) => { - requestUser = user; - var objects = [ - new PostObject({ author: postAuthor, title: "Lorem ipsum" }), - new PostObject({ author: requestUser, title: "Kafka" }), - new PostObject({ author: requestUser, title: "Brown fox" }), - new BlockedObject({ blockedBy: postAuthor, blockedUser: requestUser}) - ]; - return Parse.Object.saveAll(objects); - }).then(() => { - var banListQuery = new Parse.Query(BlockedObject); - banListQuery.equalTo("blockedUser", requestUser); + return new AuthorObject({ name: 'Julius' }) + .save() + .then(user => { + postAuthor = user; + return new AuthorObject({ name: 'Bob' }).save(); + }) + .then(user => { + requestUser = user; + const objects = [ + new PostObject({ author: postAuthor, title: 'Lorem ipsum' }), + new PostObject({ author: requestUser, title: 'Kafka' }), + new PostObject({ author: requestUser, title: 'Brown fox' }), + new BlockedObject({ + blockedBy: postAuthor, + blockedUser: requestUser, + }), + ]; + return Parse.Object.saveAll(objects); + }) + .then(() => { + const banListQuery = new Parse.Query(BlockedObject); + banListQuery.equalTo('blockedUser', requestUser); + + return new Parse.Query(PostObject) + .equalTo('author', postAuthor) + .doesNotMatchKeyInQuery('author', 'blockedBy', banListQuery) + .find() + .then(r => { + expect(r.length).toEqual(0); + done(); + }, done.fail); + }); + }); - return new Parse.Query(PostObject) - .equalTo("author", postAuthor) - .doesNotMatchKeyInQuery("author", "blockedBy", banListQuery) - .find() - .then((r) => { - expect(r.length).toEqual(0); - done(); - }, done.fail); - }) - }); - - it("multiple dontSelect query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var objects = [ - new RestaurantObject({ ratings: 7, location: "Djibouti2" }), - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), - new PersonObject({ name: "Bob2", hometown: "Djibouti2" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), + it('multiple dontSelect query', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 7, location: 'Djibouti2' }), + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob2', hometown: 'Djibouti2' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), ]; - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(RestaurantObject); - query.greaterThan("ratings", 6); - var query2 = new Parse.Query(RestaurantObject); - query2.lessThan("ratings", 4); - var subQuery = new Parse.Query(PersonObject); - subQuery.matchesKeyInQuery("hometown", "location", query); - var subQuery2 = new Parse.Query(PersonObject); - subQuery2.matchesKeyInQuery("hometown", "location", query2); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("objectId", "objectId", Parse.Query.or(subQuery, subQuery2)); - mainQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'Bob'); - done(); - } - })); + Parse.Object.saveAll(objects).then(function() { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 6); + const query2 = new Parse.Query(RestaurantObject); + query2.lessThan('ratings', 4); + const subQuery = new Parse.Query(PersonObject); + subQuery.matchesKeyInQuery('hometown', 'location', query); + const subQuery2 = new Parse.Query(PersonObject); + subQuery2.matchesKeyInQuery('hometown', 'location', query2); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery( + 'objectId', + 'objectId', + Parse.Query.or(subQuery, subQuery2) + ); + mainQuery.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Bob'); + done(); + }); }); }); - it("object with length", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("length", 5); - equal(obj.get("length"), 5); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { + it('object with length', function(done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('length', 5); + equal(obj.get('length'), 5); + obj.save().then( + function() { + const query = new Parse.Query(TestObject); + query.find().then( + function(results) { equal(results.length, 1); - equal(results[0].get("length"), 5); + equal(results[0].get('length'), 5); done(); }, - error: function(error) { + function(error) { ok(false, error.message); done(); } - }); + ); }, - error: function(error) { + function(error) { ok(false, error.message); done(); } - }); + ); }); - it("include user", function(done) { - Parse.User.signUp("bob", "password", { age: 21 }, { - success: function(user) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.save({ - owner: user - }, { - success: function(obj) { - var query = new Parse.Query(TestObject); - query.include("owner"); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.id, obj.id); - ok(objAgain.get("owner") instanceof Parse.User); - equal(objAgain.get("owner").get("age"), 21); - done(); - }, - error: function(objAgain, error) { - ok(false, error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.message); + it('include user', function(done) { + Parse.User.signUp('bob', 'password', { age: 21 }).then(function(user) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj + .save({ + owner: user, + }) + .then(function(obj) { + const query = new Parse.Query(TestObject); + query.include('owner'); + query.get(obj.id).then(function(objAgain) { + equal(objAgain.id, obj.id); + ok(objAgain.get('owner') instanceof Parse.User); + equal(objAgain.get('owner').get('age'), 21); done(); - } - }); - }, - error: function(user, error) { - ok(false, error.message); - done(); - } - }); + }, done.fail); + }, done.fail); + }, done.fail); }); - it("or queries", function(done) { - var objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var object = new Parse.Object('BoxedNumber'); + it('or queries', function(done) { + const objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { + const object = new Parse.Object('BoxedNumber'); object.set('x', x); return object; }); - Parse.Object.saveAll(objects, expectSuccess({ - success: function() { - var query1 = new Parse.Query('BoxedNumber'); - query1.lessThan('x', 2); - var query2 = new Parse.Query('BoxedNumber'); - query2.greaterThan('x', 5); - var orQuery = Parse.Query.or(query1, query2); - orQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 6); - for (var number of results) { - ok(number.get('x') < 2 || number.get('x') > 5); - } - done(); - } - })); - } - })); + Parse.Object.saveAll(objects).then(function() { + const query1 = new Parse.Query('BoxedNumber'); + query1.lessThan('x', 2); + const query2 = new Parse.Query('BoxedNumber'); + query2.greaterThan('x', 5); + const orQuery = Parse.Query.or(query1, query2); + orQuery.find().then(function(results) { + equal(results.length, 6); + for (const number of results) { + ok(number.get('x') < 2 || number.get('x') > 5); + } + done(); + }); + }); }); // This relies on matchesQuery aka the $inQuery operator - it("or complex queries", function(done) { - var objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var child = new Parse.Object('Child'); + it('or complex queries', function(done) { + const objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { + const child = new Parse.Object('Child'); child.set('x', x); - var parent = new Parse.Object('Parent'); + const parent = new Parse.Object('Parent'); parent.set('child', child); parent.set('y', x); return parent; }); - Parse.Object.saveAll(objects, expectSuccess({ - success: function() { - var subQuery = new Parse.Query('Child'); - subQuery.equalTo('x', 4); - var query1 = new Parse.Query('Parent'); - query1.matchesQuery('child', subQuery); - var query2 = new Parse.Query('Parent'); - query2.lessThan('y', 2); - var orQuery = Parse.Query.or(query1, query2); - orQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - done(); - } - })); - } - })); + Parse.Object.saveAll(objects).then(function() { + const subQuery = new Parse.Query('Child'); + subQuery.equalTo('x', 4); + const query1 = new Parse.Query('Parent'); + query1.matchesQuery('child', subQuery); + const query2 = new Parse.Query('Parent'); + query2.lessThan('y', 2); + const orQuery = Parse.Query.or(query1, query2); + orQuery.find().then(function(results) { + equal(results.length, 3); + done(); + }); + }); }); - it("async methods", function(done) { - var saves = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var obj = new Parse.Object("TestObject"); - obj.set("x", x + 1); + it('async methods', function(done) { + const saves = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { + const obj = new Parse.Object('TestObject'); + obj.set('x', x + 1); return obj.save(); }); - Parse.Promise.when(saves).then(function() { - var query = new Parse.Query("TestObject"); - query.ascending("x"); - return query.first(); + Promise.all(saves) + .then(function() { + const query = new Parse.Query('TestObject'); + query.ascending('x'); + return query.first(); + }) + .then(function(obj) { + equal(obj.get('x'), 1); + const query = new Parse.Query('TestObject'); + query.descending('x'); + return query.find(); + }) + .then(function(results) { + equal(results.length, 10); + const query = new Parse.Query('TestObject'); + return query.get(results[0].id); + }) + .then(function(obj1) { + equal(obj1.get('x'), 10); + const query = new Parse.Query('TestObject'); + return query.count(); + }) + .then(function(count) { + equal(count, 10); + }) + .then(function() { + done(); + }); + }); + + it('query.each', function(done) { + const TOTAL = 50; + const COUNT = 25; + + const items = range(TOTAL).map(function(x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + Parse.Object.saveAll(items).then(function() { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + + const seen = []; + query + .each( + function(obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }, + { + batchSize: 10, + } + ) + .then(function() { + equal(seen.length, COUNT); + for (let i = 0; i < COUNT; i++) { + equal(seen[i], 1, 'Should have seen object number ' + i); + } + done(); + }, done.fail); + }); + }); - }).then(function(obj) { - equal(obj.get("x"), 1); - var query = new Parse.Query("TestObject"); - query.descending("x"); - return query.find(); + it('query.each async', function(done) { + const TOTAL = 50; + const COUNT = 25; - }).then(function(results) { - equal(results.length, 10); - var query = new Parse.Query("TestObject"); - return query.get(results[0].id); + expect(COUNT + 1); - }).then(function(obj1) { - equal(obj1.get("x"), 10); - var query = new Parse.Query("TestObject"); - return query.count(); + const items = range(TOTAL).map(function(x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); - }).then(function(count) { - equal(count, 10); + const seen = []; + + Parse.Object.saveAll(items) + .then(function() { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + return query.each( + function(obj) { + return new Promise(resolve => { + process.nextTick(function() { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + resolve(); + }); + }); + }, + { + batchSize: 10, + } + ); + }) + .then(function() { + equal(seen.length, COUNT); + for (let i = 0; i < COUNT; i++) { + equal(seen[i], 1, 'Should have seen object number ' + i); + } + done(); + }); + }); - }).then(function() { - done(); + it('query.each fails with order', function(done) { + const TOTAL = 50; + const COUNT = 25; + const items = range(TOTAL).map(function(x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; }); + + const seen = []; + + Parse.Object.saveAll(items) + .then(function() { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.ascending('x'); + return query.each(function(obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }); + }) + .then( + function() { + ok(false, 'This should have failed.'); + done(); + }, + function() { + done(); + } + ); }); - it("query.each", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('query.each fails with skip', function(done) { + const TOTAL = 50; + const COUNT = 25; - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); + const items = range(TOTAL).map(function(x) { + const obj = new TestObject(); + obj.set('x', x); return obj; }); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - - var seen = []; - query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; + const seen = []; - }, { - batchSize: 10, - success: function() { - equal(seen.length, COUNT); - for (var i = 0; i < COUNT; i++) { - equal(seen[i], 1, "Should have seen object number " + i); - } + Parse.Object.saveAll(items) + .then(function() { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.skip(5); + return query.each(function(obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }); + }) + .then( + function() { + ok(false, 'This should have failed.'); done(); }, - error: function(error) { - ok(false, error); + function() { done(); } - }); - }); + ); }); - it("query.each async", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('query.each fails with limit', function(done) { + const TOTAL = 50; + const COUNT = 25; - expect(COUNT + 1); + expect(0); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); + const items = range(TOTAL).map(function(x) { + const obj = new TestObject(); + obj.set('x', x); return obj; }); - var seen = []; + const seen = []; - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - return query.each(function(obj) { - var promise = new Parse.Promise(); - process.nextTick(function() { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - promise.resolve(); + Parse.Object.saveAll(items) + .then(function() { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.limit(5); + return query.each(function(obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; }); - return promise; - }, { - batchSize: 10 - }); - - }).then(function() { - equal(seen.length, COUNT); - for (var i = 0; i < COUNT; i++) { - equal(seen[i], 1, "Should have seen object number " + i); - } - done(); - }); + }) + .then( + function() { + ok(false, 'This should have failed.'); + done(); + }, + function() { + done(); + } + ); }); - it("query.each fails with order", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('select keys query', function(done) { + const obj = new TestObject({ foo: 'baz', bar: 1 }); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; + obj + .save() + .then(function() { + obj._clearServerData(); + const query = new Parse.Query(TestObject); + query.select('foo'); + return query.first(); + }) + .then(function(result) { + ok(result.id, 'expected object id to be set'); + ok(result.createdAt, 'expected object createdAt to be set'); + ok(result.updatedAt, 'expected object updatedAt to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual(result.get('foo'), 'baz'); + strictEqual( + result.get('bar'), + undefined, + "expected 'bar' field to be unset" + ); + return result.fetch(); + }) + .then(function(result) { + strictEqual(result.get('foo'), 'baz'); + strictEqual(result.get('bar'), 1); + }) + .then(function() { + obj._clearServerData(); + const query = new Parse.Query(TestObject); + query.select([]); + return query.first(); + }) + .then(function(result) { + ok(result.id, 'expected object id to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual( + result.get('foo'), + undefined, + "expected 'foo' field to be unset" + ); + strictEqual( + result.get('bar'), + undefined, + "expected 'bar' field to be unset" + ); + }) + .then(function() { + obj._clearServerData(); + const query = new Parse.Query(TestObject); + query.select(['foo', 'bar']); + return query.first(); + }) + .then(function(result) { + ok(result.id, 'expected object id to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual(result.get('foo'), 'baz'); + strictEqual(result.get('bar'), 1); + }) + .then(function() { + obj._clearServerData(); + const query = new Parse.Query(TestObject); + query.select('foo', 'bar'); + return query.first(); + }) + .then(function(result) { + ok(result.id, 'expected object id to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual(result.get('foo'), 'baz'); + strictEqual(result.get('bar'), 1); + }) + .then( + function() { + done(); + }, + function(err) { + ok(false, 'other error: ' + JSON.stringify(err)); + done(); + } + ); + }); + it('exclude keys', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + }); - var seen = []; - - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.ascending("x"); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - }); + it('exclude keys with select same key', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function() { - done(); + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: 'foo', + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBeUndefined(); }); - it("query.each fails with skip", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('exclude keys with select different key', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: 'foo,hello', + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + }); - var seen = []; - - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.skip(5); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - }); - - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function() { - done(); + it('exclude keys with include same key', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ child: pointer, hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + include: 'child', + excludeKeys: 'child', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + expect(response.data.results[0].child).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); }); - it("query.each fails with limit", function(done) { - var TOTAL = 50; - var COUNT = 25; - - expect(0); - - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; + it('exclude keys with include different key', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ + child1: pointer, + child2: pointer, + hello: 'world', }); - - var seen = []; - - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.limit(5); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - }); - - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function() { - done(); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + include: 'child1,child2', + excludeKeys: 'child1', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + expect(response.data.results[0].child1).toBeUndefined(); + expect(response.data.results[0].child2.objectId).toEqual(pointer.id); + expect(response.data.results[0].hello).toBe('world'); }); - it("select keys query", function(done) { - var obj = new TestObject({ foo: 'baz', bar: 1 }); - - obj.save().then(function () { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select('foo'); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(result.createdAt, "expected object createdAt to be set"); - ok(result.updatedAt, "expected object updatedAt to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), undefined, - "expected 'bar' field to be unset"); - return result.fetch(); - }).then(function(result) { - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select([]); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), undefined, - "expected 'foo' field to be unset"); - strictEqual(result.get('bar'), undefined, - "expected 'bar' field to be unset"); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select(['foo','bar']); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select('foo', 'bar'); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - done(); - }, function (err) { - ok(false, "other error: " + JSON.stringify(err)); - done(); + it('exclude keys with includeAll', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ + child1: pointer, + child2: pointer, + hello: 'world', }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + includeAll: true, + excludeKeys: 'child1', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].child).toBeUndefined(); + expect(response.data.results[0].child2.objectId).toEqual(pointer.id); + expect(response.data.results[0].hello).toBe('world'); }); it('select keys with each query', function(done) { - var obj = new TestObject({ foo: 'baz', bar: 1 }); + const obj = new TestObject({ foo: 'baz', bar: 1 }); obj.save().then(function() { obj._clearServerData(); - var query = new Parse.Query(TestObject); + const query = new Parse.Query(TestObject); query.select('foo'); - query.each(function(result) { - ok(result.id, 'expected object id to be set'); - ok(result.createdAt, 'expected object createdAt to be set'); - ok(result.updatedAt, 'expected object updatedAt to be set'); - ok(!result.dirty(), 'expected result not to be dirty'); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), undefined, - 'expected "bar" field to be unset'); - }).then(function() { - done(); - }, function(err) { - jfail(err); - done(); - }); + query + .each(function(result) { + ok(result.id, 'expected object id to be set'); + ok(result.createdAt, 'expected object createdAt to be set'); + ok(result.updatedAt, 'expected object updatedAt to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual(result.get('foo'), 'baz'); + strictEqual( + result.get('bar'), + undefined, + 'expected "bar" field to be unset' + ); + }) + .then( + function() { + done(); + }, + function(err) { + jfail(err); + done(); + } + ); }); }); - it('notEqual with array of pointers', (done) => { - var children = []; - var parents = []; - var promises = []; - for (var i = 0; i < 2; i++) { - var proc = (iter) => { - var child = new Parse.Object('Child'); + it('notEqual with array of pointers', done => { + const children = []; + const parents = []; + const promises = []; + for (let i = 0; i < 2; i++) { + const proc = iter => { + const child = new Parse.Object('Child'); children.push(child); - var parent = new Parse.Object('Parent'); + const parent = new Parse.Object('Parent'); parents.push(parent); promises.push( child.save().then(() => { @@ -2549,393 +3534,500 @@ describe('Parse.Query testing', () => { }; proc(i); } - Promise.all(promises).then(() => { - var query = new Parse.Query('Parent'); - query.notEqualTo('child', children[0]); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0].id).toEqual(parents[1].id); - done(); - }).catch((error) => { console.log(error); }); + Promise.all(promises) + .then(() => { + const query = new Parse.Query('Parent'); + query.notEqualTo('child', children[0]); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].id).toEqual(parents[1].id); + done(); + }) + .catch(error => { + console.log(error); + }); }); // PG don't support creating a null column - it_exclude_dbs(['postgres'])('querying for null value', (done) => { - var obj = new Parse.Object('TestObject'); + it_exclude_dbs(['postgres'])('querying for null value', done => { + const obj = new Parse.Object('TestObject'); obj.set('aNull', null); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('aNull', null); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0].get('aNull')).toEqual(null); - done(); - }) - }); - - it('query within dictionary', (done) => { - var promises = []; - for (var i = 0; i < 2; i++) { - var proc = (iter) => { - var obj = new Parse.Object('TestObject'); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('aNull', null); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].get('aNull')).toEqual(null); + done(); + }); + }); + + it('query within dictionary', done => { + const promises = []; + for (let i = 0; i < 2; i++) { + const proc = iter => { + const obj = new Parse.Object('TestObject'); obj.set('aDict', { x: iter + 1, y: iter + 2 }); promises.push(obj.save()); }; proc(i); } - Promise.all(promises).then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('aDict.x', 1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); + Promise.all(promises) + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('aDict.x', 1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); }); it('supports include on the wrong key type (#2262)', function(done) { const childObject = new Parse.Object('TestChildObject'); childObject.set('hello', 'world'); - childObject.save().then(() => { - const obj = new Parse.Object('TestObject'); - obj.set('foo', 'bar'); - obj.set('child', childObject); - return obj.save(); - }).then(() => { - const q = new Parse.Query('TestObject'); - q.include('child'); - q.include('child.parent'); - q.include('createdAt'); - q.include('createdAt.createdAt'); - return q.find(); - }).then((objs) => { - expect(objs.length).toBe(1); - expect(objs[0].get('child').get('hello')).toEqual('world'); - expect(objs[0].createdAt instanceof Date).toBe(true); - done(); - }, () => { - fail('should not fail'); - done(); - }); - }); - - it('query match on array with single object', (done) => { - var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'}; - var obj = new Parse.Object('TestObject'); + childObject + .save() + .then(() => { + const obj = new Parse.Object('TestObject'); + obj.set('foo', 'bar'); + obj.set('child', childObject); + return obj.save(); + }) + .then(() => { + const q = new Parse.Query('TestObject'); + q.include('child'); + q.include('child.parent'); + q.include('createdAt'); + q.include('createdAt.createdAt'); + return q.find(); + }) + .then( + objs => { + expect(objs.length).toBe(1); + expect(objs[0].get('child').get('hello')).toEqual('world'); + expect(objs[0].createdAt instanceof Date).toBe(true); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('query match on array with single object', done => { + const target = { + __type: 'Pointer', + className: 'TestObject', + objectId: 'abc123', + }; + const obj = new Parse.Object('TestObject'); obj.set('someObjs', [target]); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('someObjs', target); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); - }); - - it('query match on array with multiple objects', (done) => { - var target1 = {__type: 'Pointer', className: 'TestObject', objectId: 'abc'}; - var target2 = {__type: 'Pointer', className: 'TestObject', objectId: '123'}; - var obj = new Parse.Object('TestObject'); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); + }); + + it('query match on array with multiple objects', done => { + const target1 = { + __type: 'Pointer', + className: 'TestObject', + objectId: 'abc', + }; + const target2 = { + __type: 'Pointer', + className: 'TestObject', + objectId: '123', + }; + const obj = new Parse.Object('TestObject'); obj.set('someObjs', [target1, target2]); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('someObjs', target1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); }); - it('query should not match on array when searching for null', (done) => { - var target = {__type: 'Pointer', className: 'TestObject', objectId: '123'}; - var obj = new Parse.Object('TestObject'); + it('query should not match on array when searching for null', done => { + const target = { + __type: 'Pointer', + className: 'TestObject', + objectId: '123', + }; + const obj = new Parse.Object('TestObject'); obj.set('someKey', 'someValue'); obj.set('someObjs', [target]); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('someKey', 'someValue'); - query.equalTo('someObjs', null); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(0); - done(); - }, (error) => { - console.log(error); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someKey', 'someValue'); + query.equalTo('someObjs', null); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(0); + done(); + }, + error => { + console.log(error); + } + ); }); // #371 - it('should properly interpret a query v1', (done) => { - var query = new Parse.Query("C1"); - var auxQuery = new Parse.Query("C1"); - query.matchesKeyInQuery("A1", "A2", auxQuery); - query.include("A3"); - query.include("A2"); - query.find().then(() => { - done(); - }, (err) => { - jfail(err); - fail("should not failt"); - done(); - }) - }); - - it('should properly interpret a query v2', (done) => { - var user = new Parse.User(); - user.set("username", "foo"); - user.set("password", "bar"); - return user.save().then((user) => { - var objIdQuery = new Parse.Query("_User").equalTo("objectId", user.id); - var blockedUserQuery = user.relation("blockedUsers").query(); - - var aResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); - aResponseQuery.equalTo("userA", user); - aResponseQuery.equalTo("userAResponse", 1); - - var bResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); - bResponseQuery.equalTo("userB", user); - bResponseQuery.equalTo("userBResponse", 1); - - var matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); - var matchRelationshipA = new Parse.Query("_User"); - matchRelationshipA.matchesKeyInQuery("objectId", "userAObjectId", matchOr); - var matchRelationshipB = new Parse.Query("_User"); - matchRelationshipB.matchesKeyInQuery("objectId", "userBObjectId", matchOr); - - - var orQuery = Parse.Query.or(objIdQuery, blockedUserQuery, matchRelationshipA, matchRelationshipB); - var query = new Parse.Query("_User"); - query.doesNotMatchQuery("objectId", orQuery); - return query.find(); - }).then(() => { - done(); - }, (err) => { - jfail(err); - fail("should not fail"); - done(); - }); - }); - - it("should match a key in an array (#3195)", function(done) { - var AuthorObject = Parse.Object.extend("Author"); - var GroupObject = Parse.Object.extend("Group"); - var PostObject = Parse.Object.extend("Post"); - - return new AuthorObject().save().then((user) => { - const post = new PostObject({ - author: user - }); - - const group = new GroupObject({ - members: [user], - }); - - return Parse.Promise.when(post.save(), group.save()); - }).then((p) => { - return new Parse.Query(PostObject) - .matchesKeyInQuery("author", "members", new Parse.Query(GroupObject)) - .find() - .then((r) => { - expect(r.length).toEqual(1); - if (r.length > 0) { - expect(r[0].id).toEqual(p.id); - } + it('should properly interpret a query v1', done => { + const query = new Parse.Query('C1'); + const auxQuery = new Parse.Query('C1'); + query.matchesKeyInQuery('A1', 'A2', auxQuery); + query.include('A3'); + query.include('A2'); + query.find().then( + () => { + done(); + }, + err => { + jfail(err); + fail('should not failt'); + done(); + } + ); + }); + + it('should properly interpret a query v2', done => { + const user = new Parse.User(); + user.set('username', 'foo'); + user.set('password', 'bar'); + return user + .save() + .then(user => { + const objIdQuery = new Parse.Query('_User').equalTo( + 'objectId', + user.id + ); + const blockedUserQuery = user.relation('blockedUsers').query(); + + const aResponseQuery = new Parse.Query( + 'MatchRelationshipActivityResponse' + ); + aResponseQuery.equalTo('userA', user); + aResponseQuery.equalTo('userAResponse', 1); + + const bResponseQuery = new Parse.Query( + 'MatchRelationshipActivityResponse' + ); + bResponseQuery.equalTo('userB', user); + bResponseQuery.equalTo('userBResponse', 1); + + const matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); + const matchRelationshipA = new Parse.Query('_User'); + matchRelationshipA.matchesKeyInQuery( + 'objectId', + 'userAObjectId', + matchOr + ); + const matchRelationshipB = new Parse.Query('_User'); + matchRelationshipB.matchesKeyInQuery( + 'objectId', + 'userBObjectId', + matchOr + ); + + const orQuery = Parse.Query.or( + objIdQuery, + blockedUserQuery, + matchRelationshipA, + matchRelationshipB + ); + const query = new Parse.Query('_User'); + query.doesNotMatchQuery('objectId', orQuery); + return query.find(); + }) + .then( + () => { done(); - }, done.fail); - }); + }, + err => { + jfail(err); + fail('should not fail'); + done(); + } + ); + }); + + it('should match a key in an array (#3195)', function(done) { + const AuthorObject = Parse.Object.extend('Author'); + const GroupObject = Parse.Object.extend('Group'); + const PostObject = Parse.Object.extend('Post'); + + return new AuthorObject() + .save() + .then(user => { + const post = new PostObject({ + author: user, + }); + + const group = new GroupObject({ + members: [user], + }); + + return Promise.all([post.save(), group.save()]); + }) + .then(results => { + const p = results[0]; + return new Parse.Query(PostObject) + .matchesKeyInQuery('author', 'members', new Parse.Query(GroupObject)) + .find() + .then(r => { + expect(r.length).toEqual(1); + if (r.length > 0) { + expect(r[0].id).toEqual(p.id); + } + done(); + }, done.fail); + }); }); - it('should find objects with array of pointers', (done) => { - var objects = []; - while(objects.length != 5) { - var object = new Parse.Object('ContainedObject'); + it('should find objects with array of pointers', done => { + const objects = []; + while (objects.length != 5) { + const object = new Parse.Object('ContainedObject'); object.set('index', objects.length); objects.push(object); } - Parse.Object.saveAll(objects).then((objects) => { - var container = new Parse.Object('Container'); - var pointers = objects.map((obj) => { - return { - __type: 'Pointer', - className: 'ContainedObject', - objectId: obj.id + Parse.Object.saveAll(objects) + .then(objects => { + const container = new Parse.Object('Container'); + const pointers = objects.map(obj => { + return { + __type: 'Pointer', + className: 'ContainedObject', + objectId: obj.id, + }; + }); + container.set('objects', pointers); + const container2 = new Parse.Object('Container'); + container2.set('objects', pointers.slice(2, 3)); + return Parse.Object.saveAll([container, container2]); + }) + .then(() => { + const inQuery = new Parse.Query('ContainedObject'); + inQuery.greaterThanOrEqualTo('index', 1); + const query = new Parse.Query('Container'); + query.matchesQuery('objects', inQuery); + return query.find(); + }) + .then(results => { + if (results) { + expect(results.length).toBe(2); } + done(); }) - container.set('objects', pointers); - const container2 = new Parse.Object('Container'); - container2.set('objects', pointers.slice(2, 3)); - return Parse.Object.saveAll([container, container2]); - }).then(() => { - const inQuery = new Parse.Query('ContainedObject'); - inQuery.greaterThanOrEqualTo('index', 1); - const query = new Parse.Query('Container'); - query.matchesQuery('objects', inQuery); - return query.find(); - }).then((results) => { - if (results) { - expect(results.length).toBe(2); - } - done(); - }).fail((err) => { - jfail(err); - fail('should not fail'); - done(); - }) - }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); it('query with two OR subqueries (regression test #1259)', done => { const relatedObject = new Parse.Object('Class2'); - relatedObject.save().then(relatedObject => { - const anObject = new Parse.Object('Class1'); - const relation = anObject.relation('relation'); - relation.add(relatedObject); - return anObject.save(); - }).then(anObject => { - const q1 = anObject.relation('relation').query(); - q1.doesNotExist('nonExistantKey1'); - const q2 = anObject.relation('relation').query(); - q2.doesNotExist('nonExistantKey2'); - Parse.Query.or(q1, q2).find().then(results => { - expect(results.length).toEqual(1); - if (results.length == 1) { - expect(results[0].objectId).toEqual(q1.objectId); - } - done(); + relatedObject + .save() + .then(relatedObject => { + const anObject = new Parse.Object('Class1'); + const relation = anObject.relation('relation'); + relation.add(relatedObject); + return anObject.save(); + }) + .then(anObject => { + const q1 = anObject.relation('relation').query(); + q1.doesNotExist('nonExistantKey1'); + const q2 = anObject.relation('relation').query(); + q2.doesNotExist('nonExistantKey2'); + Parse.Query.or(q1, q2) + .find() + .then(results => { + expect(results.length).toEqual(1); + if (results.length == 1) { + expect(results[0].objectId).toEqual(q1.objectId); + } + done(); + }); }); - }); }); it('objectId containedIn with multiple large array', done => { const obj = new Parse.Object('MyClass'); - obj.save().then(obj => { - const longListOfStrings = []; - for (let i = 0; i < 130; i++) { - longListOfStrings.push(i.toString()); - } - longListOfStrings.push(obj.id); - const q = new Parse.Query('MyClass'); - q.containedIn('objectId', longListOfStrings); - q.containedIn('objectId', longListOfStrings); - return q.find(); - }).then(results => { - expect(results.length).toEqual(1); - done(); - }); + obj + .save() + .then(obj => { + const longListOfStrings = []; + for (let i = 0; i < 130; i++) { + longListOfStrings.push(i.toString()); + } + longListOfStrings.push(obj.id); + const q = new Parse.Query('MyClass'); + q.containedIn('objectId', longListOfStrings); + q.containedIn('objectId', longListOfStrings); + return q.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + done(); + }); }); it('containedIn with pointers should work with string array', done => { const obj = new Parse.Object('MyClass'); const child = new Parse.Object('Child'); - child.save().then(() => { - obj.set('child', child); - return obj.save(); - }).then(() => { - const objs = []; - for(let i = 0; i < 10; i++) { - objs.push(new Parse.Object('MyClass')); - } - return Parse.Object.saveAll(objs); - }).then(() => { - const query = new Parse.Query('MyClass'); - query.containedIn('child', [child.id]); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - }).then(done).catch(done.fail); + child + .save() + .then(() => { + obj.set('child', child); + return obj.save(); + }) + .then(() => { + const objs = []; + for (let i = 0; i < 10; i++) { + objs.push(new Parse.Object('MyClass')); + } + return Parse.Object.saveAll(objs); + }) + .then(() => { + const query = new Parse.Query('MyClass'); + query.containedIn('child', [child.id]); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + }) + .then(done) + .catch(done.fail); }); it('containedIn with pointers should work with string array, with many objects', done => { const objs = []; const children = []; - for(let i = 0; i < 10; i++) { + for (let i = 0; i < 10; i++) { const obj = new Parse.Object('MyClass'); const child = new Parse.Object('Child'); objs.push(obj); children.push(child); } - Parse.Object.saveAll(children).then(() => { - return Parse.Object.saveAll(objs.map((obj, i) => { - obj.set('child', children[i]); - return obj; - })); - }).then(() => { - const query = new Parse.Query('MyClass'); - const subset = children.slice(0, 5).map((child) => { - return child.id; - }); - query.containedIn('child', subset); - return query.find(); - }).then((results) => { - expect(results.length).toBe(5); - }).then(done).catch(done.fail); - }); - - it('include for specific object', function(done){ - var child = new Parse.Object('Child'); - var parent = new Parse.Object('Parent'); + Parse.Object.saveAll(children) + .then(() => { + return Parse.Object.saveAll( + objs.map((obj, i) => { + obj.set('child', children[i]); + return obj; + }) + ); + }) + .then(() => { + const query = new Parse.Query('MyClass'); + const subset = children.slice(0, 5).map(child => { + return child.id; + }); + query.containedIn('child', subset); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(5); + }) + .then(done) + .catch(done.fail); + }); + + it('include for specific object', function(done) { + const child = new Parse.Object('Child'); + const parent = new Parse.Object('Parent'); child.set('foo', 'bar'); parent.set('child', child); - Parse.Object.saveAll([child, parent], function(response){ - var savedParent = response[1]; - var parentQuery = new Parse.Query('Parent'); + Parse.Object.saveAll([child, parent]).then(function(response) { + const savedParent = response[1]; + const parentQuery = new Parse.Query('Parent'); parentQuery.include('child'); - parentQuery.get(savedParent.id, { - success: function(parentObj) { - var childPointer = parentObj.get('child'); - ok(childPointer); - equal(childPointer.get('foo'), 'bar'); - done(); - } + parentQuery.get(savedParent.id).then(function(parentObj) { + const childPointer = parentObj.get('child'); + ok(childPointer); + equal(childPointer.get('foo'), 'bar'); + done(); }); }); }); - it('select keys for specific object', function(done){ - var Foobar = new Parse.Object('Foobar'); + it('select keys for specific object', function(done) { + const Foobar = new Parse.Object('Foobar'); Foobar.set('foo', 'bar'); Foobar.set('fizz', 'buzz'); - Foobar.save({ - success: function(savedFoobar){ - var foobarQuery = new Parse.Query('Foobar'); - foobarQuery.select('fizz'); - foobarQuery.get(savedFoobar.id,{ - success: function(foobarObj){ - equal(foobarObj.get('fizz'), 'buzz'); - equal(foobarObj.get('foo'), undefined); - done(); - } - }); - } - }) + Foobar.save().then(function(savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select('fizz'); + foobarQuery.get(savedFoobar.id).then(function(foobarObj) { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + done(); + }); + }); }); it('select nested keys (issue #1567)', function(done) { - var Foobar = new Parse.Object('Foobar'); - var BarBaz = new Parse.Object('Barbaz'); + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); BarBaz.set('key', 'value'); BarBaz.set('otherKey', 'value'); - BarBaz.save().then(() => { - Foobar.set('foo', 'bar'); - Foobar.set('fizz', 'buzz'); - Foobar.set('barBaz', BarBaz); - return Foobar.save(); - }).then(function(savedFoobar){ - var foobarQuery = new Parse.Query('Foobar'); - foobarQuery.include('barBaz'); - foobarQuery.select(['fizz', 'barBaz.key']); - foobarQuery.get(savedFoobar.id,{ - success: function(foobarObj){ + BarBaz.save() + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function(savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.include('barBaz'); + foobarQuery.select(['fizz', 'barBaz.key']); + foobarQuery.get(savedFoobar.id).then(function(foobarObj) { equal(foobarObj.get('fizz'), 'buzz'); equal(foobarObj.get('foo'), undefined); if (foobarObj.has('barBaz')) { @@ -2945,149 +4037,325 @@ describe('Parse.Query testing', () => { fail('barBaz should be set'); } done(); - } + }); }); - }); }); it('select nested keys 2 level (issue #1567)', function(done) { - var Foobar = new Parse.Object('Foobar'); - var BarBaz = new Parse.Object('Barbaz'); - var Bazoo = new Parse.Object('Bazoo'); + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); Bazoo.set('some', 'thing'); Bazoo.set('otherSome', 'value'); - Bazoo.save().then(() => { - BarBaz.set('key', 'value'); - BarBaz.set('otherKey', 'value'); - BarBaz.set('bazoo', Bazoo); - return BarBaz.save(); - }).then(() => { - Foobar.set('foo', 'bar'); - Foobar.set('fizz', 'buzz'); - Foobar.set('barBaz', BarBaz); - return Foobar.save(); - }).then(function(savedFoobar){ - var foobarQuery = new Parse.Query('Foobar'); - foobarQuery.include('barBaz'); - foobarQuery.include('barBaz.bazoo'); - foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); - foobarQuery.get(savedFoobar.id,{ - success: function(foobarObj){ + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function(savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.include('barBaz'); + foobarQuery.include('barBaz.bazoo'); + foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); + foobarQuery.get(savedFoobar.id).then(function(foobarObj) { equal(foobarObj.get('fizz'), 'buzz'); equal(foobarObj.get('foo'), undefined); if (foobarObj.has('barBaz')) { equal(foobarObj.get('barBaz').get('key'), 'value'); equal(foobarObj.get('barBaz').get('otherKey'), undefined); - equal(foobarObj.get('barBaz').get('bazoo').get('some'), 'thing'); - equal(foobarObj.get('barBaz').get('bazoo').get('otherSome'), undefined); + equal( + foobarObj + .get('barBaz') + .get('bazoo') + .get('some'), + 'thing' + ); + equal( + foobarObj + .get('barBaz') + .get('bazoo') + .get('otherSome'), + undefined + ); } else { fail('barBaz should be set'); } done(); - } + }); }); + }); + + it('include with *', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: '*', + }, + }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); + }); + + it('include with * overrides', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: 'child2,*', + }, }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); + }); + + it('includeAll', done => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + Parse.Object.saveAll([parent, child1, child2, child3]) + .then(() => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + includeAll: true, + }, + }); + return request( + Object.assign( + { url: Parse.serverURL + '/classes/Container' }, + options + ) + ); + }) + .then(resp => { + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); + done(); + }); + }); + + it('select nested keys 2 level includeAll', done => { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); + const Tang = new Parse.Object('Tang'); + + Bazoo.set('some', 'thing'); + Bazoo.set('otherSome', 'value'); + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Tang.set('clan', 'wu'); + return Tang.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + Foobar.set('group', Tang); + return Foobar.save(); + }) + .then(savedFoobar => { + const options = Object.assign( + { + url: Parse.serverURL + '/classes/Foobar', + }, + masterKeyOptions, + { + qs: { + where: JSON.stringify({ objectId: savedFoobar.id }), + includeAll: true, + keys: 'fizz,barBaz.key,barBaz.bazoo.some', + }, + } + ); + return request(options); + }) + .then(resp => { + const result = resp.data.results[0]; + equal(result.group.clan, 'wu'); + equal(result.foo, undefined); + equal(result.fizz, 'buzz'); + equal(result.barBaz.key, 'value'); + equal(result.barBaz.otherKey, undefined); + equal(result.barBaz.bazoo.some, 'thing'); + equal(result.barBaz.bazoo.otherSome, undefined); + done(); + }) + .catch(done.fail); }); it('select nested keys 2 level without include (issue #3185)', function(done) { - var Foobar = new Parse.Object('Foobar'); - var BarBaz = new Parse.Object('Barbaz'); - var Bazoo = new Parse.Object('Bazoo'); + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); Bazoo.set('some', 'thing'); Bazoo.set('otherSome', 'value'); - Bazoo.save().then(() => { - BarBaz.set('key', 'value'); - BarBaz.set('otherKey', 'value'); - BarBaz.set('bazoo', Bazoo); - return BarBaz.save(); - }).then(() => { - Foobar.set('foo', 'bar'); - Foobar.set('fizz', 'buzz'); - Foobar.set('barBaz', BarBaz); - return Foobar.save(); - }).then(function(savedFoobar){ - var foobarQuery = new Parse.Query('Foobar'); - foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); - return foobarQuery.get(savedFoobar.id); - }).then((foobarObj) => { - equal(foobarObj.get('fizz'), 'buzz'); - equal(foobarObj.get('foo'), undefined); - if (foobarObj.has('barBaz')) { - equal(foobarObj.get('barBaz').get('key'), 'value'); - equal(foobarObj.get('barBaz').get('otherKey'), undefined); - if (foobarObj.get('barBaz').has('bazoo')) { - equal(foobarObj.get('barBaz').get('bazoo').get('some'), 'thing'); - equal(foobarObj.get('barBaz').get('bazoo').get('otherSome'), undefined); + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function(savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); + return foobarQuery.get(savedFoobar.id); + }) + .then(foobarObj => { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + if (foobarObj.get('barBaz').has('bazoo')) { + equal( + foobarObj + .get('barBaz') + .get('bazoo') + .get('some'), + 'thing' + ); + equal( + foobarObj + .get('barBaz') + .get('bazoo') + .get('otherSome'), + undefined + ); + } else { + fail('bazoo should be set'); + } } else { - fail('bazoo should be set'); + fail('barBaz should be set'); } - } else { - fail('barBaz should be set'); - } - done(); - }) + done(); + }); }); it('properly handles nested ors', function(done) { - var objects = []; - while(objects.length != 4) { - var obj = new Parse.Object('Object'); + const objects = []; + while (objects.length != 4) { + const obj = new Parse.Object('Object'); obj.set('x', objects.length); - objects.push(obj) + objects.push(obj); } - Parse.Object.saveAll(objects).then(() => { - const q0 = new Parse.Query('Object'); - q0.equalTo('x', 0); - const q1 = new Parse.Query('Object'); - q1.equalTo('x', 1); - const q2 = new Parse.Query('Object'); - q2.equalTo('x', 2); - const or01 = Parse.Query.or(q0,q1); - return Parse.Query.or(or01, q2).find(); - }).then((results) => { - expect(results.length).toBe(3); - done(); - }).catch((error) => { - fail('should not fail'); - jfail(error); - done(); - }) + Parse.Object.saveAll(objects) + .then(() => { + const q0 = new Parse.Query('Object'); + q0.equalTo('x', 0); + const q1 = new Parse.Query('Object'); + q1.equalTo('x', 1); + const q2 = new Parse.Query('Object'); + q2.equalTo('x', 2); + const or01 = Parse.Query.or(q0, q1); + return Parse.Query.or(or01, q2).find(); + }) + .then(results => { + expect(results.length).toBe(3); + done(); + }) + .catch(error => { + fail('should not fail'); + jfail(error); + done(); + }); }); it('should not depend on parameter order #3169', function(done) { - const score1 = new Parse.Object('Score', {scoreId: '1'}); - const score2 = new Parse.Object('Score', {scoreId: '2'}); - const game1 = new Parse.Object('Game', {gameId: '1'}); - const game2 = new Parse.Object('Game', {gameId: '2'}); - Parse.Object.saveAll([score1, score2, game1, game2]).then(() => { - game1.set('score', [score1]); - game2.set('score', [score2]); - return Parse.Object.saveAll([game1, game2]); - }).then(() => { - const where = { - score: { - objectId: score1.id, - className: 'Score', - __type: 'Pointer', - } - } - return require('request-promise').post({ - url: Parse.serverURL + "/classes/Game", - json: { where, "_method": "GET" }, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey - } - }); - }).then((response) => { - expect(response.results.length).toBe(1); - done(); - }, done.fail); + const score1 = new Parse.Object('Score', { scoreId: '1' }); + const score2 = new Parse.Object('Score', { scoreId: '2' }); + const game1 = new Parse.Object('Game', { gameId: '1' }); + const game2 = new Parse.Object('Game', { gameId: '2' }); + Parse.Object.saveAll([score1, score2, game1, game2]) + .then(() => { + game1.set('score', [score1]); + game2.set('score', [score2]); + return Parse.Object.saveAll([game1, game2]); + }) + .then(() => { + const where = { + score: { + objectId: score1.id, + className: 'Score', + __type: 'Pointer', + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Game', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then( + response => { + const results = response.data; + expect(results.results.length).toBe(1); + done(); + }, + res => done.fail(res.data) + ); }); - it('should not interfere with has when using select on field with undefined value #3999', (done) => { + it('should not interfere with has when using select on field with undefined value #3999', done => { const obj1 = new Parse.Object('TestObject'); const obj2 = new Parse.Object('OtherObject'); obj2.set('otherField', 1); @@ -3095,17 +4363,24 @@ describe('Parse.Query testing', () => { obj1.set('shouldBe', true); const obj3 = new Parse.Object('TestObject'); obj3.set('shouldBe', false); - Parse.Object.saveAll([obj1, obj3]).then(() => { - const query = new Parse.Query('TestObject'); - query.include('testPointerField'); - query.select(['testPointerField', 'testPointerField.otherField', 'shouldBe']); - return query.find(); - }).then(results => { - results.forEach(result => { - equal(result.has('testPointerField'), result.get('shouldBe')); - }); - done(); - }).catch(done.fail); + Parse.Object.saveAll([obj1, obj3]) + .then(() => { + const query = new Parse.Query('TestObject'); + query.include('testPointerField'); + query.select([ + 'testPointerField', + 'testPointerField.otherField', + 'shouldBe', + ]); + return query.find(); + }) + .then(results => { + results.forEach(result => { + equal(result.has('testPointerField'), result.get('shouldBe')); + }); + done(); + }) + .catch(done.fail); }); it_only_db('mongo')('should handle relative times correctly', function(done) { @@ -3125,7 +4400,7 @@ describe('Parse.Query testing', () => { q.greaterThan('ttl', { $relativeTime: 'in 1 day' }); return q.find({ useMasterKey: true }); }) - .then((results) => { + .then(results => { expect(results.length).toBe(1); }) .then(() => { @@ -3133,7 +4408,7 @@ describe('Parse.Query testing', () => { q.greaterThan('ttl', { $relativeTime: '1 day ago' }); return q.find({ useMasterKey: true }); }) - .then((results) => { + .then(results => { expect(results.length).toBe(1); }) .then(() => { @@ -3141,7 +4416,7 @@ describe('Parse.Query testing', () => { q.lessThan('ttl', { $relativeTime: '5 days ago' }); return q.find({ useMasterKey: true }); }) - .then((results) => { + .then(results => { expect(results.length).toBe(0); }) .then(() => { @@ -3149,7 +4424,32 @@ describe('Parse.Query testing', () => { q.greaterThan('ttl', { $relativeTime: '3 days ago' }); return q.find({ useMasterKey: true }); }) - .then((results) => { + .then(results => { + expect(results.length).toBe(2); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: 'now' }); + return q.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: 'now' }); + q.lessThan('ttl', { $relativeTime: 'in 1 day' }); + return q.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(0); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: '1 year 3 weeks ago' }); + return q.find({ useMasterKey: true }); + }) + .then(results => { expect(results.length).toBe(2); }) .then(done, done.fail); @@ -3163,22 +4463,441 @@ describe('Parse.Query testing', () => { const q = new Parse.Query('MyCustomObject'); q.greaterThan('ttl', { $relativeTime: '-12 bananas ago' }); - obj1.save({ useMasterKey: true }) + obj1 + .save({ useMasterKey: true }) .then(() => q.find({ useMasterKey: true })) - .then(done.fail, done); + .then(done.fail, () => done()); }); - it_only_db('mongo')('should error when using $relativeTime on non-Date field', function(done) { - const obj1 = new Parse.Object('MyCustomObject', { - name: 'obj1', - nonDateField: 'abcd', - ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + it_only_db('mongo')( + 'should error when using $relativeTime on non-Date field', + function(done) { + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + nonDateField: 'abcd', + ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('nonDateField', { $relativeTime: '1 day ago' }); + obj1 + .save({ useMasterKey: true }) + .then(() => q.find({ useMasterKey: true })) + .then(done.fail, () => done()); + } + ); + + it('should match complex structure with dot notation when using matchesKeyInQuery', function(done) { + const group1 = new Parse.Object('Group', { + name: 'Group #1', }); - const q = new Parse.Query('MyCustomObject'); - q.greaterThan('nonDateField', { $relativeTime: '1 day ago' }); - obj1.save({ useMasterKey: true }) - .then(() => q.find({ useMasterKey: true })) - .then(done.fail, done); + const group2 = new Parse.Object('Group', { + name: 'Group #2', + }); + + Parse.Object.saveAll([group1, group2]) + .then(() => { + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: group1, + }); + + return Parse.Object.saveAll([role1, role2]); + }) + .then(() => { + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.matchesKeyInQuery( + 'objectId', + 'belongsTo.objectId', + rolesOfTypeX + ); + + groupsWithRoleX.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), group1.get('name')); + done(); + }); + }); + }); + + it('should match complex structure with dot notation when using doesNotMatchKeyInQuery', function(done) { + const group1 = new Parse.Object('Group', { + name: 'Group #1', + }); + + const group2 = new Parse.Object('Group', { + name: 'Group #2', + }); + + Parse.Object.saveAll([group1, group2]) + .then(() => { + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: group1, + }); + + return Parse.Object.saveAll([role1, role2]); + }) + .then(() => { + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.doesNotMatchKeyInQuery( + 'objectId', + 'belongsTo.objectId', + rolesOfTypeX + ); + + groupsWithRoleX.find().then(function(results) { + equal(results.length, 1); + equal(results[0].get('name'), group2.get('name')); + done(); + }); + }); + }); + + it('should not throw error with undefined dot notation when using matchesKeyInQuery', async () => { + const group = new Parse.Object('Group', { name: 'Group #1' }); + await group.save(); + + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: undefined, + }); + await Parse.Object.saveAll([role1, role2]); + + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.matchesKeyInQuery( + 'objectId', + 'belongsTo.objectId', + rolesOfTypeX + ); + + const results = await groupsWithRoleX.find(); + equal(results.length, 1); + equal(results[0].get('name'), group.get('name')); + }); + + it('should not throw error with undefined dot notation when using doesNotMatchKeyInQuery', async () => { + const group1 = new Parse.Object('Group', { name: 'Group #1' }); + const group2 = new Parse.Object('Group', { name: 'Group #2' }); + await Parse.Object.saveAll([group1, group2]); + + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: undefined, + }); + await Parse.Object.saveAll([role1, role2]); + + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.doesNotMatchKeyInQuery( + 'objectId', + 'belongsTo.objectId', + rolesOfTypeX + ); + + const results = await groupsWithRoleX.find(); + equal(results.length, 1); + equal(results[0].get('name'), group2.get('name')); + }); + + it('withJSON supports geoWithin.centerSphere', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('TestObject', { location: inbound }); + const obj2 = new Parse.Object('TestObject', { location: onbound }); + const obj3 = new Parse.Object('TestObject', { location: outbound }); + const center = new Parse.GeoPoint(0, 0); + const distanceInKilometers = 1569 + 1; // 1569km is the approximate distance between {0, 0} and {10, 10}. + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [center, distanceInKilometers / 6371.0], + }, + }; + q.withJSON(jsonQ); + return q.find(); + }) + .then(results => { + equal(results.length, 2); + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[0, 0], distanceInKilometers / 6371.0], + }, + }; + q.withJSON(jsonQ); + return q.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }) + .catch(error => { + fail(error); + done(); + }); + }); + + it('withJSON with geoWithin.centerSphere fails without parameters', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_JSON)) + .then(done); + }); + + it('withJSON with geoWithin.centerSphere fails with invalid distance', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[0, 0], 'invalid_distance'], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_JSON)) + .then(done); + }); + + it('withJSON with geoWithin.centerSphere fails with invalid coordinate', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[-190, -190], 1], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(() => done()); + }); + + it('withJSON with geoWithin.centerSphere fails with invalid geo point', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [{ longitude: 0, dummytude: 0 }, 1], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(() => done()); + }); + + it('can add new config to existing config', async () => { + await request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + files: [{ __type: 'File', name: 'name', url: 'http://url' }], + }, + }, + headers: masterKeyHeaders, + }); + + await request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { newConfig: 'good' }, + }, + headers: masterKeyHeaders, + }); + + const result = await Parse.Config.get(); + equal(result.get('files')[0].toJSON(), { + __type: 'File', + name: 'name', + url: 'http://url', + }); + equal(result.get('newConfig'), 'good'); + }); + + it('can set object type key', async () => { + const data = { bar: true, baz: 100 }; + const object = new TestObject(); + object.set('objectField', data); + await object.save(); + + const query = new Parse.Query(TestObject); + let result = await query.get(object.id); + equal(result.get('objectField'), data); + + object.set('objectField.baz', 50, { ignoreValidation: true }); + await object.save(); + + result = await query.get(object.id); + equal(result.get('objectField'), { bar: true, baz: 50 }); + }); + + it('can update numeric array', async () => { + const data1 = [0, 1.1, 1, -2, 3]; + const data2 = [0, 1.1, 1, -2, 3, 4]; + const obj1 = new TestObject(); + obj1.set('array', data1); + await obj1.save(); + equal(obj1.get('array'), data1); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', obj1.id); + + const result = await query.first(); + equal(result.get('array'), data1); + + result.set('array', data2); + equal(result.get('array'), data2); + await result.save(); + equal(result.get('array'), data2); + + const results = await query.find(); + equal(results[0].get('array'), data2); + }); + + it('can update mixed array', async () => { + const data1 = [0, 1.1, 'hello world', { foo: 'bar' }]; + const data2 = [0, 1, { foo: 'bar' }, [], [1, 2, 'bar']]; + const obj1 = new TestObject(); + obj1.set('array', data1); + await obj1.save(); + equal(obj1.get('array'), data1); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', obj1.id); + + const result = await query.first(); + equal(result.get('array'), data1); + + result.set('array', data2); + equal(result.get('array'), data2); + + await result.save(); + equal(result.get('array'), data2); + + const results = await query.find(); + equal(results[0].get('array'), data2); + }); + + it('can query regex with unicode', async () => { + const object = new TestObject(); + object.set('field', 'autoöo'); + await object.save(); + + const query = new Parse.Query(TestObject); + query.contains('field', 'autoöo'); + const results = await query.find(); + + expect(results.length).toBe(1); + expect(results[0].get('field')).toBe('autoöo'); + }); + + it('can update mixed array more than 100 elements', async () => { + const array = [0, 1.1, 'hello world', { foo: 'bar' }, null]; + const obj = new TestObject({ array }); + await obj.save(); + + const query = new Parse.Query(TestObject); + const result = await query.get(obj.id); + equal(result.get('array').length, 5); + + for (let i = 0; i < 100; i += 1) { + array.push(i); + } + obj.set('array', array); + await obj.save(); + + const results = await query.find(); + equal(results[0].get('array').length, 105); + }); + + it('exclude keys (sdk query)', async done => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const query = new Parse.Query('TestObject'); + query.exclude('foo'); + + const object = await query.get(obj.id); + expect(object.get('foo')).toBeUndefined(); + expect(object.get('hello')).toBe('world'); + done(); + }); + + xit('todo: exclude keys with select key (sdk query get)', async done => { + // there is some problem with js sdk caching + + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const query = new Parse.Query('TestObject'); + + query.withJSON({ + keys: 'hello', + excludeKeys: 'hello', + }); + + const object = await query.get(obj.id); + expect(object.get('foo')).toBeUndefined(); + expect(object.get('hello')).toBeUndefined(); + done(); }); }); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index e3eb562d34..bd6771458c 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -2,514 +2,536 @@ // This is a port of the test suite: // hungry/js/test/parse_relation_test.js -var ChildObject = Parse.Object.extend({className: "ChildObject"}); -var ParentObject = Parse.Object.extend({className: "ParentObject"}); +const ChildObject = Parse.Object.extend({ className: 'ChildObject' }); +const ParentObject = Parse.Object.extend({ className: 'ParentObject' }); describe('Parse.Relation testing', () => { - it("simple add and remove relation", (done) => { - var child = new ChildObject(); - child.set("x", 2); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - - child.save().then(() => { - relation.add(child); - return parent.save(); - }, (e) => { - fail(e); - }).then(() => { - return relation.query().find(); - }).then((list) => { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, child.id, - "Should have gotten the right value"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - - relation.remove(child); - return parent.save(); - }).then(() => { - return relation.query().find(); - }).then((list) => { - equal(list.length, 0, - "Delete should have worked"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - done(); - }); - }); - - it("query relation without schema", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x:i})); - } + it('simple add and remove relation', done => { + const child = new ChildObject(); + child.set('x', 2); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + + child + .save() + .then( + () => { + relation.add(child); + return parent.save(); + }, + e => { + fail(e); + } + ) + .then(() => { + return relation.query().find(); + }) + .then(list => { + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, child.id, 'Should have gotten the right value'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); - Parse.Object.saveAll(childObjects, expectSuccess({ - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - parent.save(null, expectSuccess({ - success: function() { - var parentAgain = new ParentObject(); - parentAgain.id = parent.id; - var relation = parentAgain.relation("child"); - relation.query().find(expectSuccess({ - success: function(list) { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, childObjects[0].id, - "Should have gotten the right value"); - done(); - } - })); - } - })); - } - })); + relation.remove(child); + return parent.save(); + }) + .then(() => { + return relation.query().find(); + }) + .then(list => { + equal(list.length, 0, 'Delete should have worked'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + done(); + }); }); - it("relations are constructed right from query", (done) => { - - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('query relation without schema', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - parent.save(null, { - success: function() { - var query = new Parse.Query(ParentObject); - query.get(parent.id, { - success: function(object) { - var relationAgain = object.relation("child"); - relationAgain.query().find({ - success: function(list) { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, childObjects[0].id, - "Should have gotten the right value"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - done(); - }, - error: function() { - ok(false, "This shouldn't have failed"); - done(); - } - }); - - } - }); - } - }); - } - }); - + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + let relation = parent.relation('child'); + relation.add(childObjects[0]); + await parent.save(); + const parentAgain = new ParentObject(); + parentAgain.id = parent.id; + relation = parentAgain.relation('child'); + const list = await relation.query().find(); + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, childObjects[0].id, 'Should have gotten the right value'); }); - it("compound add and remove relation", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('relations are constructed right from query', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - var parent; - var relation; - - Parse.Object.saveAll(childObjects).then(function() { - var ParentObject = Parse.Object.extend('ParentObject'); - parent = new ParentObject(); - parent.set('x', 4); - relation = parent.relation('child'); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.remove(childObjects[0]); - relation.add(childObjects[2]); - return parent.save(); - }).then(function() { - return relation.query().find(); - }).then(function(list) { - equal(list.length, 2, 'Should have gotten two elements back'); - ok(!parent.dirty('child'), 'The relation should not be dirty'); - relation.remove(childObjects[1]); - relation.remove(childObjects[2]); - relation.add(childObjects[1]); - relation.add(childObjects[0]); - return parent.save(); - }).then(function() { - return relation.query().find(); - }).then(function(list) { - equal(list.length, 2, 'Deletes and then adds should have worked'); - ok(!parent.dirty('child'), 'The relation should not be dirty'); - done(); - }, function(err) { - ok(false, err.message); - done(); - }); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + await parent.save(); + const query = new Parse.Query(ParentObject); + const object = await query.get(parent.id); + const relationAgain = object.relation('child'); + const list = await relationAgain.query().find(); + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, childObjects[0].id, 'Should have gotten the right value'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); }); - - it_exclude_dbs(['postgres'])("queries with relations", (done) => { - - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('compound add and remove relation', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); + let parent; + let relation; + + Parse.Object.saveAll(childObjects) + .then(function() { + const ParentObject = Parse.Object.extend('ParentObject'); + parent = new ParentObject(); + parent.set('x', 4); + relation = parent.relation('child'); relation.add(childObjects[0]); relation.add(childObjects[1]); + relation.remove(childObjects[0]); relation.add(childObjects[2]); - parent.save(null, { - success: function() { - var query = relation.query(); - query.equalTo("x", 2); - query.find({ - success: function(list) { - equal(list.length, 1, - "There should only be one element"); - ok(list[0] instanceof ChildObject, - "Should be of type ChildObject"); - equal(list[0].id, childObjects[2].id, - "We should have gotten back the right result"); - done(); - } - }); - } - }); - } - }); + return parent.save(); + }) + .then(function() { + return relation.query().find(); + }) + .then(function(list) { + equal(list.length, 2, 'Should have gotten two elements back'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + relation.remove(childObjects[1]); + relation.remove(childObjects[2]); + relation.add(childObjects[1]); + relation.add(childObjects[0]); + return parent.save(); + }) + .then(function() { + return relation.query().find(); + }) + .then( + function(list) { + equal(list.length, 2, 'Deletes and then adds should have worked'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + done(); + }, + function(err) { + ok(false, err.message); + done(); + } + ); }); - it("queries on relation fields", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('related at ordering optimizations', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - var relation2 = parent2.relation("child"); - relation2.add(childObjects[4]); - relation2.add(childObjects[5]); - relation2.add(childObjects[6]); - var parents = []; - parents.push(parent); - parents.push(parent2); - Parse.Object.saveAll(parents, { - success: function() { - var query = new Parse.Query(ParentObject); - var objects = []; - objects.push(childObjects[4]); - objects.push(childObjects[9]); - query.containedIn("child", objects); - query.find({ - success: function(list) { - equal(list.length, 1, "There should be only one result"); - equal(list[0].id, parent2.id, - "Should have gotten back the right result"); - done(); - } - }); - } - }); - } - }); + let parent; + let relation; + + Parse.Object.saveAll(childObjects) + .then(function() { + const ParentObject = Parse.Object.extend('ParentObject'); + parent = new ParentObject(); + parent.set('x', 4); + relation = parent.relation('child'); + relation.add(childObjects); + return parent.save(); + }) + .then(function() { + const query = relation.query(); + query.descending('createdAt'); + query.skip(1); + query.limit(3); + return query.find(); + }) + .then(function(list) { + expect(list.length).toBe(3); + }) + .then(done, done.fail); }); - it("queries on relation fields with multiple containedIn (regression test for #1271)", (done) => { - const ChildObject = Parse.Object.extend("ChildObject"); + it_exclude_dbs(['postgres'])('queries with relations', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); const childObjects = []; for (let i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects).then(() => { - const ParentObject = Parse.Object.extend("ParentObject"); - const parent = new ParentObject(); - parent.set("x", 4); - const parent1Children = parent.relation("child"); - parent1Children.add(childObjects[0]); - parent1Children.add(childObjects[1]); - parent1Children.add(childObjects[2]); - const parent2 = new ParentObject(); - parent2.set("x", 3); - const parent2Children = parent2.relation("child"); - parent2Children.add(childObjects[4]); - parent2Children.add(childObjects[5]); - parent2Children.add(childObjects[6]); - - const parent2OtherChildren = parent2.relation("otherChild"); - parent2OtherChildren.add(childObjects[0]); - parent2OtherChildren.add(childObjects[1]); - parent2OtherChildren.add(childObjects[2]); - - return Parse.Object.saveAll([parent, parent2]); - }).then(() => { - const objectsWithChild0InBothChildren = new Parse.Query(ParentObject); - objectsWithChild0InBothChildren.containedIn("child", [childObjects[0]]); - objectsWithChild0InBothChildren.containedIn("otherChild", [childObjects[0]]); - return objectsWithChild0InBothChildren.find(); - }).then(objectsWithChild0InBothChildren => { - //No parent has child 0 in both it's "child" and "otherChild" field; - expect(objectsWithChild0InBothChildren.length).toEqual(0); - }).then(() => { - const objectsWithChild4andOtherChild1 = new Parse.Query(ParentObject); - objectsWithChild4andOtherChild1.containedIn("child", [childObjects[4]]); - objectsWithChild4andOtherChild1.containedIn("otherChild", [childObjects[1]]); - return objectsWithChild4andOtherChild1.find(); - }).then(objects => { - // parent2 has child 4 and otherChild 1 - expect(objects.length).toEqual(1); - done(); - }); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + await parent.save(); + const query = relation.query(); + query.equalTo('x', 2); + const list = await query.find(); + equal(list.length, 1, 'There should only be one element'); + ok(list[0] instanceof ChildObject, 'Should be of type ChildObject'); + equal( + list[0].id, + childObjects[2].id, + 'We should have gotten back the right result' + ); }); - it_exclude_dbs(['postgres'])("query on pointer and relation fields with equal", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('queries on relation fields', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + const relation2 = parent2.relation('child'); + relation2.add(childObjects[4]); + relation2.add(childObjects[5]); + relation2.add(childObjects[6]); + const parents = []; + parents.push(parent); + parents.push(parent2); + await Parse.Object.saveAll(parents); + const query = new Parse.Query(ParentObject); + const objects = []; + objects.push(childObjects[4]); + objects.push(childObjects[9]); + const list = await query.containedIn('child', objects).find(); + equal(list.length, 1, 'There should be only one result'); + equal(list[0].id, parent2.id, 'Should have gotten back the right result'); + }); - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.set("toChild", childObjects[2]); + it('queries on relation fields with multiple containedIn (regression test for #1271)', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } - var parents = []; - parents.push(parent); - parents.push(parent2); - parents.push(new ParentObject()); + Parse.Object.saveAll(childObjects) + .then(() => { + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const parent1Children = parent.relation('child'); + parent1Children.add(childObjects[0]); + parent1Children.add(childObjects[1]); + parent1Children.add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + const parent2Children = parent2.relation('child'); + parent2Children.add(childObjects[4]); + parent2Children.add(childObjects[5]); + parent2Children.add(childObjects[6]); + + const parent2OtherChildren = parent2.relation('otherChild'); + parent2OtherChildren.add(childObjects[0]); + parent2OtherChildren.add(childObjects[1]); + parent2OtherChildren.add(childObjects[2]); + + return Parse.Object.saveAll([parent, parent2]); + }) + .then(() => { + const objectsWithChild0InBothChildren = new Parse.Query(ParentObject); + objectsWithChild0InBothChildren.containedIn('child', [childObjects[0]]); + objectsWithChild0InBothChildren.containedIn('otherChild', [ + childObjects[0], + ]); + return objectsWithChild0InBothChildren.find(); + }) + .then(objectsWithChild0InBothChildren => { + //No parent has child 0 in both it's "child" and "otherChild" field; + expect(objectsWithChild0InBothChildren.length).toEqual(0); + }) + .then(() => { + const objectsWithChild4andOtherChild1 = new Parse.Query(ParentObject); + objectsWithChild4andOtherChild1.containedIn('child', [childObjects[4]]); + objectsWithChild4andOtherChild1.containedIn('otherChild', [ + childObjects[1], + ]); + return objectsWithChild4andOtherChild1.find(); + }) + .then(objects => { + // parent2 has child 4 and otherChild 1 + expect(objects.length).toEqual(1); + done(); + }); + }); - return Parse.Object.saveAll(parents).then(() => { - var query = new Parse.Query(ParentObject); - query.equalTo("objectId", parent.id); - query.equalTo("toChilds", childObjects[2]); + it_exclude_dbs(['postgres'])( + 'query on pointer and relation fields with equal', + done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } - return query.find().then((list) => { - equal(list.length, 1, "There should be 1 result"); + Parse.Object.saveAll(childObjects) + .then(() => { + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.set('toChild', childObjects[2]); + + const parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + const query = new Parse.Query(ParentObject); + query.equalTo('objectId', parent.id); + query.equalTo('toChilds', childObjects[2]); + + return query.find().then(list => { + equal(list.length, 1, 'There should be 1 result'); + done(); + }); + }); + }) + .catch(err => { + jfail(err); done(); }); - }); - }).catch(err => { - jfail(err); - done(); - }); - }); + } + ); - it("query on pointer and relation fields with equal bis", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('query on pointer and relation fields with equal bis', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); relation.add(childObjects[0]); relation.add(childObjects[1]); relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.relation("toChilds").add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.relation('toChilds').add(childObjects[2]); - var parents = []; + const parents = []; parents.push(parent); parents.push(parent2); parents.push(new ParentObject()); return Parse.Object.saveAll(parents).then(() => { - var query = new Parse.Query(ParentObject); - query.equalTo("objectId", parent2.id); + const query = new Parse.Query(ParentObject); + query.equalTo('objectId', parent2.id); // childObjects[2] is in 2 relations // before the fix, that woul yield 2 results - query.equalTo("toChilds", childObjects[2]); + query.equalTo('toChilds', childObjects[2]); - return query.find().then((list) => { - equal(list.length, 1, "There should be 1 result"); + return query.find().then(list => { + equal(list.length, 1, 'There should be 1 result'); done(); }); }); }); }); - it_exclude_dbs(['postgres'])("or queries on pointer and relation fields", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); - } - - Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); + it_exclude_dbs(['postgres'])( + 'or queries on pointer and relation fields', + done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.set("toChild", childObjects[2]); + Parse.Object.saveAll(childObjects).then(() => { + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); - var parents = []; - parents.push(parent); - parents.push(parent2); - parents.push(new ParentObject()); + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.set('toChild', childObjects[2]); - return Parse.Object.saveAll(parents).then(() => { - var query1 = new Parse.Query(ParentObject); - query1.containedIn("toChilds", [childObjects[2]]); - var query2 = new Parse.Query(ParentObject); - query2.equalTo("toChild", childObjects[2]); - var query = Parse.Query.or(query1, query2); - return query.find().then((list) => { - var objectIds = list.map(function(item){ - return item.id; + const parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + const query1 = new Parse.Query(ParentObject); + query1.containedIn('toChilds', [childObjects[2]]); + const query2 = new Parse.Query(ParentObject); + query2.equalTo('toChild', childObjects[2]); + const query = Parse.Query.or(query1, query2); + return query.find().then(list => { + const objectIds = list.map(function(item) { + return item.id; + }); + expect(objectIds.indexOf(parent.id)).not.toBe(-1); + expect(objectIds.indexOf(parent2.id)).not.toBe(-1); + equal(list.length, 2, 'There should be 2 results'); + done(); }); - expect(objectIds.indexOf(parent.id)).not.toBe(-1); - expect(objectIds.indexOf(parent2.id)).not.toBe(-1); - equal(list.length, 2, "There should be 2 results"); - done(); }); }); - }); - }); - + } + ); - it("Get query on relation using un-fetched parent object", (done) => { + it('Get query on relation using un-fetched parent object', done => { // Setup data model - var Wheel = Parse.Object.extend('Wheel'); - var Car = Parse.Object.extend('Car'); - var origWheel = new Wheel(); - origWheel.save().then(function() { - var car = new Car(); - var relation = car.relation('wheels'); - relation.add(origWheel); - return car.save(); - }).then(function(car) { - // Test starts here. - // Create an un-fetched shell car object - var unfetchedCar = new Car(); - unfetchedCar.id = car.id; - var relation = unfetchedCar.relation('wheels'); - var query = relation.query(); - - // Parent object is un-fetched, so this will call /1/classes/Car instead - // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. - return query.get(origWheel.id); - }).then(function(wheel) { - // Make sure this is Wheel and not Car. - strictEqual(wheel.className, 'Wheel'); - strictEqual(wheel.id, origWheel.id); - }).then(function() { - done(); - },function(err) { - ok(false, 'unexpected error: ' + JSON.stringify(err)); - done(); - }); + const Wheel = Parse.Object.extend('Wheel'); + const Car = Parse.Object.extend('Car'); + const origWheel = new Wheel(); + origWheel + .save() + .then(function() { + const car = new Car(); + const relation = car.relation('wheels'); + relation.add(origWheel); + return car.save(); + }) + .then(function(car) { + // Test starts here. + // Create an un-fetched shell car object + const unfetchedCar = new Car(); + unfetchedCar.id = car.id; + const relation = unfetchedCar.relation('wheels'); + const query = relation.query(); + + // Parent object is un-fetched, so this will call /1/classes/Car instead + // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. + return query.get(origWheel.id); + }) + .then(function(wheel) { + // Make sure this is Wheel and not Car. + strictEqual(wheel.className, 'Wheel'); + strictEqual(wheel.id, origWheel.id); + }) + .then( + function() { + done(); + }, + function(err) { + ok(false, 'unexpected error: ' + JSON.stringify(err)); + done(); + } + ); }); - it("Find query on relation using un-fetched parent object", (done) => { + it('Find query on relation using un-fetched parent object', done => { // Setup data model - var Wheel = Parse.Object.extend('Wheel'); - var Car = Parse.Object.extend('Car'); - var origWheel = new Wheel(); - origWheel.save().then(function() { - var car = new Car(); - var relation = car.relation('wheels'); - relation.add(origWheel); - return car.save(); - }).then(function(car) { - // Test starts here. - // Create an un-fetched shell car object - var unfetchedCar = new Car(); - unfetchedCar.id = car.id; - var relation = unfetchedCar.relation('wheels'); - var query = relation.query(); - - // Parent object is un-fetched, so this will call /1/classes/Car instead - // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. - return query.find(origWheel.id); - }).then(function(results) { - // Make sure this is Wheel and not Car. - var wheel = results[0]; - strictEqual(wheel.className, 'Wheel'); - strictEqual(wheel.id, origWheel.id); - }).then(function() { - done(); - },function(err) { - ok(false, 'unexpected error: ' + JSON.stringify(err)); - done(); - }); + const Wheel = Parse.Object.extend('Wheel'); + const Car = Parse.Object.extend('Car'); + const origWheel = new Wheel(); + origWheel + .save() + .then(function() { + const car = new Car(); + const relation = car.relation('wheels'); + relation.add(origWheel); + return car.save(); + }) + .then(function(car) { + // Test starts here. + // Create an un-fetched shell car object + const unfetchedCar = new Car(); + unfetchedCar.id = car.id; + const relation = unfetchedCar.relation('wheels'); + const query = relation.query(); + + // Parent object is un-fetched, so this will call /1/classes/Car instead + // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. + return query.find(origWheel.id); + }) + .then(function(results) { + // Make sure this is Wheel and not Car. + const wheel = results[0]; + strictEqual(wheel.className, 'Wheel'); + strictEqual(wheel.id, origWheel.id); + }) + .then( + function() { + done(); + }, + function(err) { + ok(false, 'unexpected error: ' + JSON.stringify(err)); + done(); + } + ); }); - it('Find objects with a related object using equalTo', (done) => { + it('Find objects with a related object using equalTo', done => { // Setup the objects - var Card = Parse.Object.extend('Card'); - var House = Parse.Object.extend('House'); - var card = new Card(); - card.save().then(() => { - var house = new House(); - var relation = house.relation('cards'); - relation.add(card); - return house.save(); - }).then(() => { - var query = new Parse.Query('House'); - query.equalTo('cards', card); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }); + const Card = Parse.Object.extend('Card'); + const House = Parse.Object.extend('House'); + const card = new Card(); + card + .save() + .then(() => { + const house = new House(); + const relation = house.relation('cards'); + relation.add(card); + return house.save(); + }) + .then(() => { + const query = new Parse.Query('House'); + query.equalTo('cards', card); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + done(); + }); }); - it('should properly get related objects with unfetched queries', (done) => { + it('should properly get related objects with unfetched queries', done => { const objects = []; const owners = []; const allObjects = []; @@ -518,7 +540,7 @@ describe('Parse.Relation testing', () => { const object = new Parse.Object('AnObject'); object.set({ index: objects.length, - even: objects.length % 2 == 0 + even: objects.length % 2 == 0, }); objects.push(object); const owner = new Parse.Object('AnOwner'); @@ -529,144 +551,156 @@ describe('Parse.Relation testing', () => { const anotherOwner = new Parse.Object('AnotherOwner'); - return Parse.Object.saveAll(allObjects.concat([anotherOwner])).then(() => { - // put all the AnObject into the anotherOwner relationKey - anotherOwner.relation('relationKey').add(objects); - // Set each object[i] into owner[i]; - owners.forEach((owner,i) => { - owner.set('key', objects[i]); - }); - return Parse.Object.saveAll(owners.concat([anotherOwner])); - }).then(() => { - // Query on the relation of another owner - const object = new Parse.Object('AnotherOwner'); - object.id = anotherOwner.id; - const relationQuery = object.relation('relationKey').query(); - // Just get the even ones - relationQuery.equalTo('even', true); - // Make the query on anOwner - const query = new Parse.Query('AnOwner'); - // where key match the relation query. - query.matchesQuery('key', relationQuery); - query.include('key'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(5); - results.forEach((result) => { - expect(result.get('key').get('even')).toBe(true); - }); - return Promise.resolve(); - }).then(() => { - // Query on the relation of another owner - const object = new Parse.Object('AnotherOwner'); - object.id = anotherOwner.id; - const relationQuery = object.relation('relationKey').query(); - // Just get the even ones - relationQuery.equalTo('even', true); - // Make the query on anOwner - const query = new Parse.Query('AnOwner'); - // where key match the relation query. - query.doesNotMatchQuery('key', relationQuery); - query.include('key'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(5); - results.forEach((result) => { - expect(result.get('key').get('even')).toBe(false); - }); - done(); - }, (e) => { - fail(JSON.stringify(e)); - done(); - }) + return Parse.Object.saveAll(allObjects.concat([anotherOwner])) + .then(() => { + // put all the AnObject into the anotherOwner relationKey + anotherOwner.relation('relationKey').add(objects); + // Set each object[i] into owner[i]; + owners.forEach((owner, i) => { + owner.set('key', objects[i]); + }); + return Parse.Object.saveAll(owners.concat([anotherOwner])); + }) + .then(() => { + // Query on the relation of another owner + const object = new Parse.Object('AnotherOwner'); + object.id = anotherOwner.id; + const relationQuery = object.relation('relationKey').query(); + // Just get the even ones + relationQuery.equalTo('even', true); + // Make the query on anOwner + const query = new Parse.Query('AnOwner'); + // where key match the relation query. + query.matchesQuery('key', relationQuery); + query.include('key'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(5); + results.forEach(result => { + expect(result.get('key').get('even')).toBe(true); + }); + return Promise.resolve(); + }) + .then(() => { + // Query on the relation of another owner + const object = new Parse.Object('AnotherOwner'); + object.id = anotherOwner.id; + const relationQuery = object.relation('relationKey').query(); + // Just get the even ones + relationQuery.equalTo('even', true); + // Make the query on anOwner + const query = new Parse.Query('AnOwner'); + // where key match the relation query. + query.doesNotMatchQuery('key', relationQuery); + query.include('key'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(5); + results.forEach(result => { + expect(result.get('key').get('even')).toBe(false); + }); + done(); + }, + e => { + fail(JSON.stringify(e)); + done(); + } + ); }); - it("select query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var OwnerObject = Parse.Object.extend('Owner'); - var restaurants = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), + it('select query', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const OwnerObject = Parse.Object.extend('Owner'); + const restaurants = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), ]; const persons = [ - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), ]; - const owner = new OwnerObject({name: 'Joe'}); + const owner = new OwnerObject({ name: 'Joe' }); const allObjects = [owner].concat(restaurants).concat(persons); expect(allObjects.length).toEqual(6); - Parse.Object.saveAll([owner].concat(restaurants).concat(persons)).then(function() { - owner.relation('restaurants').add(restaurants); - return owner.save() - }).then(() => { - const unfetchedOwner = new OwnerObject(); - unfetchedOwner.id = owner.id; - var query = unfetchedOwner.relation('restaurants').query(); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.matchesKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { + Parse.Object.saveAll([owner].concat(restaurants).concat(persons)) + .then(function() { + owner.relation('restaurants').add(restaurants); + return owner.save(); + }) + .then( + async () => { + const unfetchedOwner = new OwnerObject(); + unfetchedOwner.id = owner.id; + const query = unfetchedOwner.relation('restaurants').query(); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.matchesKeyInQuery('hometown', 'location', query); + const results = await mainQuery.find(); equal(results.length, 1); if (results.length > 0) { equal(results[0].get('name'), 'Bob'); } done(); + }, + e => { + fail(JSON.stringify(e)); + done(); } - })); - }, (e) => { - fail(JSON.stringify(e)); - done(); - }); + ); }); - it("dontSelect query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var OwnerObject = Parse.Object.extend('Owner'); - var restaurants = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), + it('dontSelect query', function(done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const OwnerObject = Parse.Object.extend('Owner'); + const restaurants = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), ]; const persons = [ - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), ]; - const owner = new OwnerObject({name: 'Joe'}); + const owner = new OwnerObject({ name: 'Joe' }); const allObjects = [owner].concat(restaurants).concat(persons); expect(allObjects.length).toEqual(6); - Parse.Object.saveAll([owner].concat(restaurants).concat(persons)).then(function() { - owner.relation('restaurants').add(restaurants); - return owner.save() - }).then(() => { - const unfetchedOwner = new OwnerObject(); - unfetchedOwner.id = owner.id; - var query = unfetchedOwner.relation('restaurants').query(); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); - mainQuery.ascending('name'); - mainQuery.find(expectSuccess({ - success: function(results) { + Parse.Object.saveAll([owner].concat(restaurants).concat(persons)) + .then(function() { + owner.relation('restaurants').add(restaurants); + return owner.save(); + }) + .then( + async () => { + const unfetchedOwner = new OwnerObject(); + unfetchedOwner.id = owner.id; + const query = unfetchedOwner.relation('restaurants').query(); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); + mainQuery.ascending('name'); + const results = await mainQuery.find(); equal(results.length, 2); if (results.length > 0) { equal(results[0].get('name'), 'Billy'); equal(results[1].get('name'), 'Tom'); } done(); + }, + e => { + fail(JSON.stringify(e)); + done(); } - })); - }, (e) => { - fail(JSON.stringify(e)); - done(); - }); + ); }); it('relations are not bidirectional (regression test for #871)', done => { - const PersonObject = Parse.Object.extend("Person"); + const PersonObject = Parse.Object.extend('Person'); const p1 = new PersonObject(); const p2 = new PersonObject(); Parse.Object.saveAll([p1, p2]).then(results => { @@ -688,90 +722,153 @@ describe('Parse.Relation testing', () => { done(); }); }); - }) + }); }); }); it('can query roles in Cloud Code (regession test #1489)', done => { - Parse.Cloud.define('isAdmin', (request, response) => { + Parse.Cloud.define('isAdmin', request => { const query = new Parse.Query(Parse.Role); query.equalTo('name', 'admin'); - query.first({ useMasterKey: true }) - .then(role => { + return query.first({ useMasterKey: true }).then( + role => { const relation = new Parse.Relation(role, 'users'); const admins = relation.query(); admins.equalTo('username', request.user.get('username')); - admins.first({ useMasterKey: true }) - .then(user => { + admins.first({ useMasterKey: true }).then( + user => { if (user) { - response.success(user); done(); } else { fail('Should have found admin user, found nothing instead'); done(); } - }, () => { + }, + () => { fail('User not admin'); done(); - }) - }, error => { + } + ); + }, + error => { fail('Should have found admin user, errored instead'); fail(error); done(); - }); + } + ); }); const adminUser = new Parse.User(); adminUser.set('username', 'name'); adminUser.set('password', 'pass'); - adminUser.signUp() - .then(adminUser => { + adminUser.signUp().then( + adminUser => { const adminACL = new Parse.ACL(); adminACL.setPublicReadAccess(true); // Create admin role const adminRole = new Parse.Role('admin', adminACL); adminRole.getUsers().add(adminUser); - adminRole.save() - .then(() => { + adminRole.save().then( + () => { Parse.Cloud.run('isAdmin'); - }, error => { + }, + error => { fail('failed to save role'); fail(error); - done() - }); - }, error => { + done(); + } + ); + }, + error => { fail('failed to sign up'); fail(error); done(); - }); + } + ); }); it('can be saved without error', done => { const obj1 = new Parse.Object('PPAP'); - obj1.save() - .then(() => { + obj1.save().then( + () => { const newRelation = obj1.relation('aRelation'); newRelation.add(obj1); - obj1.save().then(() => { - const relation = obj1.get('aRelation'); - obj1.set('aRelation', relation); - obj1.save().then(() => { - done(); - }, error => { - fail('failed to save ParseRelation object'); + obj1.save().then( + () => { + const relation = obj1.get('aRelation'); + obj1.set('aRelation', relation); + obj1.save().then( + () => { + done(); + }, + error => { + fail('failed to save ParseRelation object'); + fail(error); + done(); + } + ); + }, + error => { + fail('failed to create relation field'); fail(error); done(); - }); - }, error => { - fail('failed to create relation field'); - fail(error); - done(); - }); - }, error => { + } + ); + }, + error => { fail('failed to save obj'); fail(error); done(); - }); + } + ); + }); + + it('ensures beforeFind on relation doesnt side effect', done => { + const parent = new Parse.Object('Parent'); + const child = new Parse.Object('Child'); + child + .save() + .then(() => { + parent.relation('children').add(child); + return parent.save(); + }) + .then(() => { + // We need to use a new reference otherwise the JS SDK remembers the className for a relation + // After saves or finds + const otherParent = new Parse.Object('Parent'); + otherParent.id = parent.id; + return otherParent + .relation('children') + .query() + .find(); + }) + .then(children => { + // Without an after find all is good, all results have been redirected with proper className + children.forEach(child => expect(child.className).toBe('Child')); + // Setup the afterFind + Parse.Cloud.afterFind('Child', req => { + return Promise.resolve( + req.objects.map(child => { + child.set('afterFound', true); + return child; + }) + ); + }); + const otherParent = new Parse.Object('Parent'); + otherParent.id = parent.id; + return otherParent + .relation('children') + .query() + .find(); + }) + .then(children => { + children.forEach(child => { + expect(child.className).toBe('Child'); + expect(child.get('afterFound')).toBe(true); + }); + }) + .then(done) + .catch(done.fail); }); }); diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 17c3094238..47af03457e 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -1,73 +1,83 @@ -"use strict"; +'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. -var RestQuery = require("../src/RestQuery"); -var Auth = require("../src/Auth").Auth; -var Config = require("../src/Config"); +const RestQuery = require('../lib/RestQuery'); +const Auth = require('../lib/Auth').Auth; +const Config = require('../lib/Config'); describe('Parse Role testing', () => { it('Do a bunch of basic role testing', done => { - var user; - var role; - - createTestUser().then((x) => { - user = x; - const acl = new Parse.ACL(); - acl.setPublicReadAccess(true); - acl.setPublicWriteAccess(false); - role = new Parse.Object('_Role'); - role.set('name', 'Foos'); - role.setACL(acl); - var users = role.relation('users'); - users.add(user); - return role.save({}, { useMasterKey: true }); - }).then(() => { - var query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(1); - var relation = x[0].relation('users').query(); - return relation.first({ useMasterKey: true }); - }).then((x) => { - expect(x.id).toEqual(user.id); - // Here we've got a valid role and a user assigned. - // Lets create an object only the role can read/write and test - // the different scenarios. - var obj = new Parse.Object('TestObject'); - var acl = new Parse.ACL(); - acl.setPublicReadAccess(false); - acl.setPublicWriteAccess(false); - acl.setRoleReadAccess('Foos', true); - acl.setRoleWriteAccess('Foos', true); - obj.setACL(acl); - return obj.save(); - }).then(() => { - var query = new Parse.Query('TestObject'); - return query.find({ sessionToken: user.getSessionToken() }); - }).then((x) => { - expect(x.length).toEqual(1); - var objAgain = x[0]; - objAgain.set('foo', 'bar'); - // This should succeed: - return objAgain.save({}, {sessionToken: user.getSessionToken()}); - }).then((x) => { - x.set('foo', 'baz'); - // This should fail: - return x.save({},{sessionToken: ""}); - }).then(() => { - fail('Should not have been able to save.'); - }, (e) => { - expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - + let user; + let role; + + createTestUser() + .then(x => { + user = x; + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + role = new Parse.Object('_Role'); + role.set('name', 'Foos'); + role.setACL(acl); + const users = role.relation('users'); + users.add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(1); + const relation = x[0].relation('users').query(); + return relation.first({ useMasterKey: true }); + }) + .then(x => { + expect(x.id).toEqual(user.id); + // Here we've got a valid role and a user assigned. + // Lets create an object only the role can read/write and test + // the different scenarios. + const obj = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setRoleReadAccess('Foos', true); + acl.setRoleWriteAccess('Foos', true); + obj.setACL(acl); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then(x => { + expect(x.length).toEqual(1); + const objAgain = x[0]; + objAgain.set('foo', 'bar'); + // This should succeed: + return objAgain.save({}, { sessionToken: user.getSessionToken() }); + }) + .then(x => { + x.set('foo', 'baz'); + // This should fail: + return x.save({}, { sessionToken: '' }); + }) + .then( + () => { + fail('Should not have been able to save.'); + }, + e => { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - var createRole = function(name, sibling, user) { - var role = new Parse.Role(name, new Parse.ACL()); + const createRole = function(name, sibling, user) { + const role = new Parse.Role(name, new Parse.ACL()); if (user) { - var users = role.relation('users'); + const users = role.relation('users'); users.add(user); } if (sibling) { @@ -76,455 +86,535 @@ describe('Parse Role testing', () => { return role.save({}, { useMasterKey: true }); }; - it("should not recursively load the same role multiple times", (done) => { - var rootRole = "RootRole"; - var roleNames = ["FooRole", "BarRole", "BazRole"]; - var allRoles = [rootRole].concat(roleNames); - - var roleObjs = {}; - var createAllRoles = function(user) { - var promises = allRoles.map(function(roleName) { - return createRole(roleName, null, user) - .then(function(roleObj) { - roleObjs[roleName] = roleObj; - return roleObj; - }); + it('should not recursively load the same role multiple times', done => { + const rootRole = 'RootRole'; + const roleNames = ['FooRole', 'BarRole', 'BazRole']; + const allRoles = [rootRole].concat(roleNames); + + const roleObjs = {}; + const createAllRoles = function(user) { + const promises = allRoles.map(function(roleName) { + return createRole(roleName, null, user).then(function(roleObj) { + roleObjs[roleName] = roleObj; + return roleObj; + }); }); return Promise.all(promises); }; - var restExecute = spyOn(RestQuery.prototype, "execute").and.callThrough(); - - var user, - auth, - getAllRolesSpy; - createTestUser().then((newUser) => { - user = newUser; - return createAllRoles(user); - }).then ((roles) => { - var rootRoleObj = roleObjs[rootRole]; - roles.forEach(function(role, i) { - // Add all roles to the RootRole - if (role.id !== rootRoleObj.id) { - role.relation("roles").add(rootRoleObj); - } - // Add all "roleNames" roles to the previous role - if (i > 0) { - role.relation("roles").add(roles[i - 1]); - } - }); + const restExecute = spyOn(RestQuery.prototype, 'execute').and.callThrough(); - return Parse.Object.saveAll(roles, { useMasterKey: true }); - }).then(() => { - auth = new Auth({config: Config.get("test"), isMaster: true, user: user}); - getAllRolesSpy = spyOn(auth, "_getAllRolesNamesForRoleIds").and.callThrough(); + let user, auth, getAllRolesSpy; + createTestUser() + .then(newUser => { + user = newUser; + return createAllRoles(user); + }) + .then(roles => { + const rootRoleObj = roleObjs[rootRole]; + roles.forEach(function(role, i) { + // Add all roles to the RootRole + if (role.id !== rootRoleObj.id) { + role.relation('roles').add(rootRoleObj); + } + // Add all "roleNames" roles to the previous role + if (i > 0) { + role.relation('roles').add(roles[i - 1]); + } + }); + + return Parse.Object.saveAll(roles, { useMasterKey: true }); + }) + .then(() => { + auth = new Auth({ + config: Config.get('test'), + isMaster: true, + user: user, + }); + getAllRolesSpy = spyOn( + auth, + '_getAllRolesNamesForRoleIds' + ).and.callThrough(); - return auth._loadRoles(); - }).then ((roles) => { - expect(roles.length).toEqual(4); + return auth._loadRoles(); + }) + .then(roles => { + expect(roles.length).toEqual(4); - allRoles.forEach(function(name) { - expect(roles.indexOf("role:" + name)).not.toBe(-1); - }); + allRoles.forEach(function(name) { + expect(roles.indexOf('role:' + name)).not.toBe(-1); + }); - // 1 Query for the initial setup - // 1 query for the parent roles - expect(restExecute.calls.count()).toEqual(2); - - // 1 call for the 1st layer of roles - // 1 call for the 2nd layer - expect(getAllRolesSpy.calls.count()).toEqual(2); - done() - }).catch(() => { - fail("should succeed"); - done(); - }); + // 1 Query for the initial setup + // 1 query for the parent roles + expect(restExecute.calls.count()).toEqual(2); + // 1 call for the 1st layer of roles + // 1 call for the 2nd layer + expect(getAllRolesSpy.calls.count()).toEqual(2); + done(); + }) + .catch(() => { + fail('should succeed'); + done(); + }); }); - it("should recursively load roles", (done) => { - var rolesNames = ["FooRole", "BarRole", "BazRole"]; - var roleIds = {}; - createTestUser().then((user) => { - // 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; - // 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: Config.get("test"), isMaster: true, user: user }); - return auth._loadRoles(); + function testLoadRoles(config, done) { + const rolesNames = ['FooRole', 'BarRole', 'BazRole']; + const roleIds = {}; + createTestUser() + .then(user => { + // 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; + // 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; + const auth = new Auth({ config, isMaster: true, user: user }); + return auth._loadRoles(); + }); }) - }).then((roles) => { - expect(roles.length).toEqual(3); - rolesNames.forEach((name) => { - expect(roles.indexOf('role:' + name)).not.toBe(-1); - }); - done(); - }, function(){ - fail("should succeed") - done(); - }); + .then( + roles => { + expect(roles.length).toEqual(3); + rolesNames.forEach(name => { + expect(roles.indexOf('role:' + name)).not.toBe(-1); + }); + done(); + }, + function() { + fail('should succeed'); + done(); + } + ); + } + + it('should recursively load roles', done => { + testLoadRoles(Config.get('test'), done); }); - it("_Role object should not save without name.", (done) => { - var role = new Parse.Role(); - role.save(null,{useMasterKey:true}) - .then(() => { - fail("_Role object should not save without name."); - }, (error) => { + it('should recursively load roles without config', done => { + testLoadRoles(undefined, done); + }); + + it('_Role object should not save without name.', done => { + const role = new Parse.Role(); + role.save(null, { useMasterKey: true }).then( + () => { + fail('_Role object should not save without name.'); + }, + error => { expect(error.code).toEqual(111); - role.set('name','testRole'); - role.save(null,{useMasterKey:true}) - .then(()=>{ - fail("_Role object should not save without ACL."); - }, (error2) =>{ + role.set('name', 'testRole'); + role.save(null, { useMasterKey: true }).then( + () => { + fail('_Role object should not save without ACL.'); + }, + error2 => { expect(error2.code).toEqual(111); done(); - }); - }); + } + ); + } + ); }); - it("Different _Role objects cannot have the same name.", (done) => { - const roleName = "MyRole"; + it('Different _Role objects cannot have the same name.', done => { + const roleName = 'MyRole'; let aUser; - createTestUser().then((user) => { - aUser = user; - return createRole(roleName, null, aUser); - }).then((firstRole) => { - expect(firstRole.getName()).toEqual(roleName); - return createRole(roleName, null, aUser); - }).then(() => { - fail("_Role cannot have the same name as another role"); - done(); - }, (error) => { - expect(error.code).toEqual(137); - done(); - }); + createTestUser() + .then(user => { + aUser = user; + return createRole(roleName, null, aUser); + }) + .then(firstRole => { + expect(firstRole.getName()).toEqual(roleName); + return createRole(roleName, null, aUser); + }) + .then( + () => { + fail('_Role cannot have the same name as another role'); + done(); + }, + error => { + expect(error.code).toEqual(137); + done(); + } + ); }); - it("Should properly resolve roles", (done) => { - const admin = new Parse.Role("Admin", new Parse.ACL()); - const moderator = new Parse.Role("Moderator", new Parse.ACL()); - const superModerator = new Parse.Role("SuperModerator", new Parse.ACL()); + it('Should properly resolve roles', done => { + const admin = new Parse.Role('Admin', new Parse.ACL()); + const moderator = new Parse.Role('Moderator', new Parse.ACL()); + const superModerator = new Parse.Role('SuperModerator', new Parse.ACL()); const contentManager = new Parse.Role('ContentManager', new Parse.ACL()); - const 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: Config.get("test"), isMaster: true }); - // For each role, fetch their sibling, what they inherit - // return with result and roleId for later comparison - const promises = [admin, moderator, contentManager, superModerator].map((role) => { - return auth._getAllRolesNamesForRoleIds([role.id]).then((result) => { - return Parse.Promise.as({ - id: role.id, - name: role.get('name'), - roleNames: result - }); - }) - }); + const 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(() => { + const auth = new Auth({ config: Config.get('test'), isMaster: true }); + // For each role, fetch their sibling, what they inherit + // return with result and roleId for later comparison + const promises = [admin, moderator, contentManager, superModerator].map( + role => { + return auth._getAllRolesNamesForRoleIds([role.id]).then(result => { + return Promise.resolve({ + id: role.id, + name: role.get('name'), + roleNames: result, + }); + }); + } + ); - return Parse.Promise.when(promises); - }).then((results) => { - results.forEach((result) => { - const id = result.id; - const roleNames = result.roleNames; - if (id == admin.id) { - expect(roleNames.length).toBe(2); - expect(roleNames.indexOf("Moderator")).not.toBe(-1); - expect(roleNames.indexOf("ContentManager")).not.toBe(-1); - } else if (id == moderator.id) { - expect(roleNames.length).toBe(1); - expect(roleNames.indexOf("ContentManager")).toBe(0); - } else if (id == contentManager.id) { - expect(roleNames.length).toBe(0); - } else if (id == superModerator.id) { - expect(roleNames.length).toBe(3); - expect(roleNames.indexOf("Moderator")).not.toBe(-1); - expect(roleNames.indexOf("ContentManager")).not.toBe(-1); - expect(roleNames.indexOf("SuperContentManager")).not.toBe(-1); - } + return Promise.all(promises); + }) + .then(results => { + results.forEach(result => { + const id = result.id; + const roleNames = result.roleNames; + if (id == admin.id) { + expect(roleNames.length).toBe(2); + expect(roleNames.indexOf('Moderator')).not.toBe(-1); + expect(roleNames.indexOf('ContentManager')).not.toBe(-1); + } else if (id == moderator.id) { + expect(roleNames.length).toBe(1); + expect(roleNames.indexOf('ContentManager')).toBe(0); + } else if (id == contentManager.id) { + expect(roleNames.length).toBe(0); + } else if (id == superModerator.id) { + expect(roleNames.length).toBe(3); + expect(roleNames.indexOf('Moderator')).not.toBe(-1); + expect(roleNames.indexOf('ContentManager')).not.toBe(-1); + expect(roleNames.indexOf('SuperContentManager')).not.toBe(-1); + } + }); + done(); + }) + .catch(() => { + done(); }); - done(); - }).fail(() => { - done(); - }) - }); - it('can create role and query empty users', (done)=> { - var roleACL = new Parse.ACL(); + it('can create role and query empty users', done => { + const roleACL = new Parse.ACL(); roleACL.setPublicReadAccess(true); - var role = new Parse.Role('subscribers', roleACL); - role.save({}, {useMasterKey : true}) - .then(()=>{ - var query = role.relation('users').query(); - query.find({useMasterKey : true}) - .then(()=>{ + const role = new Parse.Role('subscribers', roleACL); + role.save({}, { useMasterKey: true }).then( + () => { + const query = role.relation('users').query(); + query.find({ useMasterKey: true }).then( + () => { done(); - }, ()=>{ + }, + () => { fail('should not have errors'); done(); - }); - }, () => { + } + ); + }, + () => { fail('should not have errored'); - }); + } + ); }); // Based on various scenarios described in issues #827 and #683, - it('should properly handle role permissions on objects', (done) => { - var user, user2, user3; - var role, role2, role3; - var obj, obj2; + it('should properly handle role permissions on objects', done => { + let user, user2, user3; + let role, role2, role3; + let obj, obj2; - var prACL = new Parse.ACL(); + const prACL = new Parse.ACL(); prACL.setPublicReadAccess(true); - var adminACL, superACL, customerACL; - - createTestUser().then((x) => { - user = x; - user2 = new Parse.User(); - return user2.save({ username: 'user2', password: 'omgbbq' }); - }).then(() => { - user3 = new Parse.User(); - return user3.save({ username: 'user3', password: 'omgbbq' }); - }).then(() => { - role = new Parse.Role('Admin', prACL); - role.getUsers().add(user); - return role.save({}, { useMasterKey: true }); - }).then(() => { - adminACL = new Parse.ACL(); - adminACL.setRoleReadAccess("Admin", true); - adminACL.setRoleWriteAccess("Admin", true); - - role2 = new Parse.Role('Super', prACL); - role2.getUsers().add(user2); - return role2.save({}, { useMasterKey: true }); - }).then(() => { - superACL = new Parse.ACL(); - superACL.setRoleReadAccess("Super", true); - superACL.setRoleWriteAccess("Super", true); - - role.getRoles().add(role2); - return role.save({}, { useMasterKey: true }); - }).then(() => { - role3 = new Parse.Role('Customer', prACL); - role3.getUsers().add(user3); - role3.getRoles().add(role); - return role3.save({}, { useMasterKey: true }); - }).then(() => { - customerACL = new Parse.ACL(); - customerACL.setRoleReadAccess("Customer", true); - customerACL.setRoleWriteAccess("Customer", true); - - var query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(3); - - obj = new Parse.Object('TestObjectRoles'); - obj.set('ACL', customerACL); - return obj.save(null, { useMasterKey: true }); - }).then(() => { - // Above, the Admin role was added to the Customer role. - // An object secured by the Customer ACL should be able to be edited by the Admin user. - obj.set('changedByAdmin', true); - return obj.save(null, { sessionToken: user.getSessionToken() }); - }).then(() => { - obj2 = new Parse.Object('TestObjectRoles'); - obj2.set('ACL', adminACL); - return obj2.save(null, { useMasterKey: true }); - }, () => { - fail('Admin user should have been able to save.'); - done(); - }).then(() => { - // An object secured by the Admin ACL should not be able to be edited by a Customer role user. - obj2.set('changedByCustomer', true); - return obj2.save(null, { sessionToken: user3.getSessionToken() }); - }).then(() => { - fail('Customer user should not have been able to save.'); - done(); - }, (e) => { - if (e) { - expect(e.code).toEqual(101); - } else { - fail('should return an error'); - } - done(); - }) + let adminACL, superACL, customerACL; + + createTestUser() + .then(x => { + user = x; + user2 = new Parse.User(); + return user2.save({ username: 'user2', password: 'omgbbq' }); + }) + .then(() => { + user3 = new Parse.User(); + return user3.save({ username: 'user3', password: 'omgbbq' }); + }) + .then(() => { + role = new Parse.Role('Admin', prACL); + role.getUsers().add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + adminACL = new Parse.ACL(); + adminACL.setRoleReadAccess('Admin', true); + adminACL.setRoleWriteAccess('Admin', true); + + role2 = new Parse.Role('Super', prACL); + role2.getUsers().add(user2); + return role2.save({}, { useMasterKey: true }); + }) + .then(() => { + superACL = new Parse.ACL(); + superACL.setRoleReadAccess('Super', true); + superACL.setRoleWriteAccess('Super', true); + + role.getRoles().add(role2); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + role3 = new Parse.Role('Customer', prACL); + role3.getUsers().add(user3); + role3.getRoles().add(role); + return role3.save({}, { useMasterKey: true }); + }) + .then(() => { + customerACL = new Parse.ACL(); + customerACL.setRoleReadAccess('Customer', true); + customerACL.setRoleWriteAccess('Customer', true); + + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(3); + + obj = new Parse.Object('TestObjectRoles'); + obj.set('ACL', customerACL); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + // Above, the Admin role was added to the Customer role. + // An object secured by the Customer ACL should be able to be edited by the Admin user. + obj.set('changedByAdmin', true); + return obj.save(null, { sessionToken: user.getSessionToken() }); + }) + .then( + () => { + obj2 = new Parse.Object('TestObjectRoles'); + obj2.set('ACL', adminACL); + return obj2.save(null, { useMasterKey: true }); + }, + () => { + fail('Admin user should have been able to save.'); + done(); + } + ) + .then(() => { + // An object secured by the Admin ACL should not be able to be edited by a Customer role user. + obj2.set('changedByCustomer', true); + return obj2.save(null, { sessionToken: user3.getSessionToken() }); + }) + .then( + () => { + fail('Customer user should not have been able to save.'); + done(); + }, + e => { + if (e) { + expect(e.code).toEqual(101); + } else { + fail('should return an error'); + } + done(); + } + ); }); - it('should add multiple users to a role and remove users', (done) => { - var user, user2, user3; - var role; - var obj; + it('should add multiple users to a role and remove users', done => { + let user, user2, user3; + let role; + let obj; - var prACL = new Parse.ACL(); + const prACL = new Parse.ACL(); prACL.setPublicReadAccess(true); prACL.setPublicWriteAccess(true); - createTestUser().then((x) => { - user = x; - user2 = new Parse.User(); - return user2.save({ username: 'user2', password: 'omgbbq' }); - }).then(() => { - user3 = new Parse.User(); - return user3.save({ username: 'user3', password: 'omgbbq' }); - }).then(() => { - role = new Parse.Role('sharedRole', prACL); - var users = role.relation('users'); - users.add(user); - users.add(user2); - users.add(user3); - return role.save({}, { useMasterKey: true }); - }).then(() => { - // query for saved role and get 3 users - var query = new Parse.Query('_Role'); - query.equalTo('name', 'sharedRole'); - return query.find({ useMasterKey: true }); - }).then((role) => { - expect(role.length).toEqual(1); - var users = role[0].relation('users').query(); - return users.find({ useMasterKey: true }); - }).then((users) => { - expect(users.length).toEqual(3); - obj = new Parse.Object('TestObjectRoles'); - obj.set('ACL', prACL); - return obj.save(null, { useMasterKey: true }); - }).then(() => { - // Above, the Admin role was added to the Customer role. - // An object secured by the Customer ACL should be able to be edited by the Admin user. - obj.set('changedByUsers', true); - return obj.save(null, { sessionToken: user.getSessionToken() }); - }).then(() => { - // query for saved role and get 3 users - var query = new Parse.Query('_Role'); - query.equalTo('name', 'sharedRole'); - return query.find({ useMasterKey: true }); - }).then((role) => { - expect(role.length).toEqual(1); - var users = role[0].relation('users'); - users.remove(user); - users.remove(user3); - return role[0].save({}, { useMasterKey: true }); - }).then((role) =>{ - var users = role.relation('users').query(); - return users.find({ useMasterKey: true }); - }).then((users) => { - expect(users.length).toEqual(1); - expect(users[0].get('username')).toEqual('user2'); - done(); - }); + createTestUser() + .then(x => { + user = x; + user2 = new Parse.User(); + return user2.save({ username: 'user2', password: 'omgbbq' }); + }) + .then(() => { + user3 = new Parse.User(); + return user3.save({ username: 'user3', password: 'omgbbq' }); + }) + .then(() => { + role = new Parse.Role('sharedRole', prACL); + const users = role.relation('users'); + users.add(user); + users.add(user2); + users.add(user3); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + // query for saved role and get 3 users + const query = new Parse.Query('_Role'); + query.equalTo('name', 'sharedRole'); + return query.find({ useMasterKey: true }); + }) + .then(role => { + expect(role.length).toEqual(1); + const users = role[0].relation('users').query(); + return users.find({ useMasterKey: true }); + }) + .then(users => { + expect(users.length).toEqual(3); + obj = new Parse.Object('TestObjectRoles'); + obj.set('ACL', prACL); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + // Above, the Admin role was added to the Customer role. + // An object secured by the Customer ACL should be able to be edited by the Admin user. + obj.set('changedByUsers', true); + return obj.save(null, { sessionToken: user.getSessionToken() }); + }) + .then(() => { + // query for saved role and get 3 users + const query = new Parse.Query('_Role'); + query.equalTo('name', 'sharedRole'); + return query.find({ useMasterKey: true }); + }) + .then(role => { + expect(role.length).toEqual(1); + const users = role[0].relation('users'); + users.remove(user); + users.remove(user3); + return role[0].save({}, { useMasterKey: true }); + }) + .then(role => { + const users = role.relation('users').query(); + return users.find({ useMasterKey: true }); + }) + .then(users => { + expect(users.length).toEqual(1); + expect(users[0].get('username')).toEqual('user2'); + done(); + }); }); - it('should be secure (#3835)', (done) => { + it('should be secure (#3835)', done => { const acl = new Parse.ACL(); acl.getPublicReadAccess(true); const role = new Parse.Role('admin', acl); - role.save().then(() => { - const user = new Parse.User(); - return user.signUp({username: 'hello', password: 'world'}); - }).then((user) => { - role.getUsers().add(user) - return role.save(); - }).then(done.fail, () => { - const query = role.getUsers().query(); - return query.find({useMasterKey: true}); - }).then((results) => { - expect(results.length).toBe(0); - done(); - }) + role + .save() + .then(() => { + const user = new Parse.User(); + return user.signUp({ username: 'hello', password: 'world' }); + }) + .then(user => { + role.getUsers().add(user); + return role.save(); + }) + .then(done.fail, () => { + const query = role.getUsers().query(); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(0); + done(); + }) .catch(done.fail); }); - it('should match when matching in users relation', (done) => { - var user = new Parse.User(); - user - .save({ username: 'admin', password: 'admin' }) - .then((user) => { - var aCL = new Parse.ACL(); - aCL.setPublicReadAccess(true); - aCL.setPublicWriteAccess(true); - var role = new Parse.Role('admin', aCL); - var users = role.relation('users'); - users.add(user); - role - .save({}, { useMasterKey: true }) - .then(() => { - var query = new Parse.Query(Parse.Role); - query.equalTo('name', 'admin'); - query.equalTo('users', user); - query.find().then(function (roles) { - expect(roles.length).toEqual(1); - done(); - }); - }); - }); - }); - - it('should not match any entry when not matching in users relation', (done) => { - var user = new Parse.User(); - user - .save({ username: 'admin', password: 'admin' }) - .then((user) => { - var aCL = new Parse.ACL(); - aCL.setPublicReadAccess(true); - aCL.setPublicWriteAccess(true); - var role = new Parse.Role('admin', aCL); - var users = role.relation('users'); - users.add(user); - role - .save({}, { useMasterKey: true }) - .then(() => { - var otherUser = new Parse.User(); - otherUser - .save({ username: 'otherUser', password: 'otherUser' }) - .then((otherUser) => { - var query = new Parse.Query(Parse.Role); - query.equalTo('name', 'admin'); - query.equalTo('users', otherUser); - query.find().then(function(roles) { - expect(roles.length).toEqual(0); - done(); - }); - }); - }); + it('should match when matching in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const query = new Parse.Query(Parse.Role); + query.equalTo('name', 'admin'); + query.equalTo('users', user); + query.find().then(function(roles) { + expect(roles.length).toEqual(1); + done(); + }); }); + }); }); - it('should not match any entry when searching for null in users relation', (done) => { - var user = new Parse.User(); - user - .save({ username: 'admin', password: 'admin' }) - .then((user) => { - var aCL = new Parse.ACL(); - aCL.setPublicReadAccess(true); - aCL.setPublicWriteAccess(true); - var role = new Parse.Role('admin', aCL); - var users = role.relation('users'); - users.add(user); - role - .save({}, { useMasterKey: true }) - .then(() => { - var query = new Parse.Query(Parse.Role); + it('should not match any entry when not matching in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const otherUser = new Parse.User(); + otherUser + .save({ username: 'otherUser', password: 'otherUser' }) + .then(otherUser => { + const query = new Parse.Query(Parse.Role); query.equalTo('name', 'admin'); - query.equalTo('users', null); - query.find().then(function (roles) { + query.equalTo('users', otherUser); + query.find().then(function(roles) { expect(roles.length).toEqual(0); done(); }); }); }); + }); + }); + + it('should not match any entry when searching for null in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const query = new Parse.Query(Parse.Role); + query.equalTo('name', 'admin'); + query.equalTo('users', null); + query.find().then(function(roles) { + expect(roles.length).toEqual(0); + done(); + }); + }); + }); }); }); diff --git a/spec/ParseServer.spec.js b/spec/ParseServer.spec.js index 77f97c4604..9f026f3fd0 100644 --- a/spec/ParseServer.spec.js +++ b/spec/ParseServer.spec.js @@ -1,34 +1,113 @@ 'use strict'; /* Tests for ParseServer.js */ const express = require('express'); - -import ParseServer from '../src/ParseServer'; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const ParseServer = require('../lib/ParseServer').default; +const path = require('path'); +const { spawn } = require('child_process'); describe('Server Url Checks', () => { + let server; + beforeAll(done => { + const app = express(); + app.get('/health', function(req, res) { + res.json({ + status: 'ok', + }); + }); + server = app.listen(13376, undefined, done); + }); - const app = express(); - app.get('/health', function(req, res){ - res.send('OK'); + afterAll(done => { + server.close(done); }); - app.listen(13376); - it('validate good server url', (done) => { + it('validate good server url', done => { Parse.serverURL = 'http://localhost:13376'; ParseServer.verifyServerUrl(function(result) { - if(!result) { + if (!result) { done.fail('Did not pass valid url'); } done(); }); }); - it('mark bad server url', (done) => { + it('mark bad server url', done => { + spyOn(console, 'warn').and.callFake(() => {}); Parse.serverURL = 'notavalidurl'; ParseServer.verifyServerUrl(function(result) { - if(result) { + if (result) { done.fail('Did not mark invalid url'); } done(); }); }); + + xit('handleShutdown, close connection', done => { + const mongoURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; + const postgresURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; + let databaseAdapter; + if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + databaseAdapter = new PostgresStorageAdapter({ + uri: process.env.PARSE_SERVER_TEST_DATABASE_URI || postgresURI, + collectionPrefix: 'test_', + }); + } else { + databaseAdapter = new MongoStorageAdapter({ + uri: mongoURI, + collectionPrefix: 'test_', + }); + } + let close = false; + const newConfiguration = Object.assign({}, defaultConfiguration, { + databaseAdapter, + serverStartComplete: () => { + let promise = Promise.resolve(); + if (process.env.PARSE_SERVER_TEST_DB !== 'postgres') { + promise = parseServer.config.filesController.adapter._connect(); + } + promise.then(() => { + parseServer.handleShutdown(); + parseServer.server.close(err => { + if (err) { + done.fail('Close Server Error'); + } + reconfigureServer({}).then(() => { + expect(close).toBe(true); + done(); + }); + }); + }); + }, + serverCloseComplete: () => { + close = true; + }, + }); + const parseServer = ParseServer.start(newConfiguration); + }); + + it('does not have unhandled promise rejection in the case of load error', done => { + const parseServerProcess = spawn( + path.resolve(__dirname, './support/FailingServer.js') + ); + let stdout; + let stderr; + parseServerProcess.stdout.on('data', data => { + stdout = data.toString(); + }); + parseServerProcess.stderr.on('data', data => { + stderr = data.toString(); + }); + parseServerProcess.on('close', code => { + expect(code).toEqual(1); + expect(stdout).toBeUndefined(); + expect(stderr).toContain('MongoServerSelectionError'); + done(); + }); + }); }); diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index a33244c0ba..b3b8ea36f3 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -1,155 +1,648 @@ -const ParseServerRESTController = require('../src/ParseServerRESTController').ParseServerRESTController; -const ParseServer = require('../src/ParseServer').default; +const ParseServerRESTController = require('../lib/ParseServerRESTController') + .ParseServerRESTController; +const ParseServer = require('../lib/ParseServer').default; const Parse = require('parse/node').Parse; +const TestUtils = require('../lib/TestUtils'); let RESTController; describe('ParseServerRESTController', () => { - beforeEach(() => { - RESTController = ParseServerRESTController(Parse.applicationId, ParseServer.promiseRouter({appId: Parse.applicationId})); - }) - - it('should handle a get request', (done) => { - RESTController.request("GET", "/classes/MyObject").then((res) => { - expect(res.results.length).toBe(0); - done(); - }, (err) => { - console.log(err); - jfail(err); - done(); - }); + RESTController = ParseServerRESTController( + Parse.applicationId, + ParseServer.promiseRouter({ appId: Parse.applicationId }) + ); }); - it('should handle a get request with full serverURL mount path', (done) => { - RESTController.request("GET", "/1/classes/MyObject").then((res) => { - expect(res.results.length).toBe(0); - done(); - }, (err) => { - jfail(err); - done(); - }); + it('should handle a get request', done => { + RESTController.request('GET', '/classes/MyObject').then( + res => { + expect(res.results.length).toBe(0); + done(); + }, + err => { + console.log(err); + jfail(err); + done(); + } + ); + }); + + it('should handle a get request with full serverURL mount path', done => { + RESTController.request('GET', '/1/classes/MyObject').then( + res => { + expect(res.results.length).toBe(0); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('should handle a POST batch', (done) => { - RESTController.request("POST", "batch", { + it('should handle a POST batch without transaction', done => { + RESTController.request('POST', 'batch', { requests: [ { method: 'GET', - path: '/classes/MyObject' + path: '/classes/MyObject', }, { method: 'POST', path: '/classes/MyObject', - body: {"key": "value"} + body: { key: 'value' }, }, { method: 'GET', - path: '/classes/MyObject' - } - ] - }).then((res) => { - expect(res.length).toBe(3); - done(); - }, (err) => { - jfail(err); - done(); - }); + path: '/classes/MyObject', + }, + ], + }).then( + res => { + expect(res.length).toBe(3); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('should handle a POST request', (done) => { - RESTController.request("POST", "/classes/MyObject", {"key": "value"}).then(() => { - return RESTController.request("GET", "/classes/MyObject"); - }).then((res) => { - expect(res.results.length).toBe(1); - expect(res.results[0].key).toEqual("value"); - done(); - }).fail((err) => { - console.log(err); - jfail(err); - done(); - }); + it('should handle a POST batch with transaction=false', done => { + RESTController.request('POST', 'batch', { + requests: [ + { + method: 'GET', + path: '/classes/MyObject', + }, + { + method: 'POST', + path: '/classes/MyObject', + body: { key: 'value' }, + }, + { + method: 'GET', + path: '/classes/MyObject', + }, + ], + transaction: false, + }).then( + res => { + expect(res.length).toBe(3); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('ensures sessionTokens are properly handled', (done) => { - let userId; - Parse.User.signUp('user', 'pass').then((user) => { - userId = user.id; - const sessionToken = user.getSessionToken(); - return RESTController.request("GET", "/users/me", undefined, {sessionToken}); - }).then((res) => { - // Result is in JSON format - expect(res.objectId).toEqual(userId); - done(); - }).fail((err) => { - console.log(err); - jfail(err); - done(); + if ( + (process.env.MONGODB_VERSION === '4.0.4' && + process.env.MONGODB_TOPOLOGY === 'replicaset' && + process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger') || + process.env.PARSE_SERVER_TEST_DB === 'postgres' + ) { + describe('transactions', () => { + beforeAll(async () => { + if ( + process.env.MONGODB_VERSION === '4.0.4' && + process.env.MONGODB_TOPOLOGY === 'replicaset' && + process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' + ) { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase?replicaSet=replicaset', + }); + } + }); + + beforeEach(async () => { + await TestUtils.destroyAllDataPermanently(true); + }); + + it('should handle a batch request with transaction = true', done => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + myObject + .save() + .then(() => { + return myObject.destroy(); + }) + .then(() => { + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }).then(response => { + expect(response.length).toEqual(2); + expect(response[0].success.objectId).toBeDefined(); + expect(response[0].success.createdAt).toBeDefined(); + expect(response[1].success.objectId).toBeDefined(); + expect(response[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(databaseAdapter.createObject.calls.count()).toBe(2); + expect(databaseAdapter.createObject.calls.argsFor(0)[3]).toBe( + databaseAdapter.createObject.calls.argsFor(1)[3] + ); + expect(results.map(result => result.get('key')).sort()).toEqual( + ['value1', 'value2'] + ); + done(); + }); + }); + }); + }); + + it('should not save anything when one operation fails in a transaction', done => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + myObject + .save() + .then(() => { + return myObject.destroy(); + }) + .then(() => { + RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + ], + transaction: true, + }).catch(error => { + expect(error).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(results.length).toBe(0); + done(); + }); + }); + }); + }); + + it('should generate separate session for each call', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save(); + await myObject.destroy(); + + const myObject2 = new Parse.Object('MyObject2'); // This is important because transaction only works on pre-existing collections + await myObject2.save(); + await myObject2.destroy(); + + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + let myObjectCalls = 0; + Parse.Cloud.beforeSave('MyObject', async () => { + myObjectCalls++; + if (myObjectCalls === 2) { + try { + await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + ], + transaction: true, + }); + fail('should fail'); + } catch (e) { + expect(e).toBeDefined(); + } + } + }); + + const response = await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }); + + expect(response.length).toEqual(2); + expect(response[0].success.objectId).toBeDefined(); + expect(response[0].success.createdAt).toBeDefined(); + expect(response[1].success.objectId).toBeDefined(); + expect(response[1].success.createdAt).toBeDefined(); + + await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value2' }, + }, + ], + }); + + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + + const query2 = new Parse.Query('MyObject2'); + const results2 = await query2.find(); + expect(results2.length).toEqual(0); + + const query3 = new Parse.Query('MyObject3'); + const results3 = await query3.find(); + expect(results3.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + + expect(databaseAdapter.createObject.calls.count()).toBe(13); + let transactionalSession; + let transactionalSession2; + let myObjectDBCalls = 0; + let myObject2DBCalls = 0; + let myObject3DBCalls = 0; + for (let i = 0; i < 13; i++) { + const args = databaseAdapter.createObject.calls.argsFor(i); + switch (args[0]) { + case 'MyObject': + myObjectDBCalls++; + if (!transactionalSession) { + transactionalSession = args[3]; + } else { + expect(transactionalSession).toBe(args[3]); + } + if (transactionalSession2) { + expect(transactionalSession2).not.toBe(args[3]); + } + break; + case 'MyObject2': + myObject2DBCalls++; + if (!transactionalSession2) { + transactionalSession2 = args[3]; + } else { + expect(transactionalSession2).toBe(args[3]); + } + if (transactionalSession) { + expect(transactionalSession).not.toBe(args[3]); + } + break; + case 'MyObject3': + myObject3DBCalls++; + expect(args[3]).toEqual(null); + break; + } + } + expect(myObjectDBCalls).toEqual(2); + expect(myObject2DBCalls).toEqual(9); + expect(myObject3DBCalls).toEqual(2); + }); }); + } + + it('should handle a POST request', done => { + RESTController.request('POST', '/classes/MyObject', { key: 'value' }) + .then(() => { + return RESTController.request('GET', '/classes/MyObject'); + }) + .then(res => { + expect(res.results.length).toBe(1); + expect(res.results[0].key).toEqual('value'); + done(); + }) + .catch(err => { + console.log(err); + jfail(err); + done(); + }); }); - it('ensures masterKey is properly handled', (done) => { + it('ensures sessionTokens are properly handled', done => { let userId; - Parse.User.signUp('user', 'pass').then((user) => { - userId = user.id; - return Parse.User.logOut().then(() => { - return RESTController.request("GET", "/classes/_User", undefined, {useMasterKey: true}); + Parse.User.signUp('user', 'pass') + .then(user => { + userId = user.id; + const sessionToken = user.getSessionToken(); + return RESTController.request('GET', '/users/me', undefined, { + sessionToken, + }); + }) + .then(res => { + // Result is in JSON format + expect(res.objectId).toEqual(userId); + done(); + }) + .catch(err => { + console.log(err); + jfail(err); + done(); }); - }).then((res) => { - expect(res.results.length).toBe(1); - expect(res.results[0].objectId).toEqual(userId); - done(); - }, (err) => { - jfail(err); - done(); - }); }); - it('ensures no user is created when passing an empty username', (done) => { - RESTController.request("POST", "/classes/_User", {username: "", password: "world"}).then(() => { - jfail(new Error('Success callback should not be called when passing an empty username.')); - done(); - }, (err) => { - expect(err.code).toBe(Parse.Error.USERNAME_MISSING); - expect(err.message).toBe('bad or missing username'); - done(); - }); + it('ensures masterKey is properly handled', done => { + let userId; + Parse.User.signUp('user', 'pass') + .then(user => { + userId = user.id; + return Parse.User.logOut().then(() => { + return RESTController.request('GET', '/classes/_User', undefined, { + useMasterKey: true, + }); + }); + }) + .then( + res => { + expect(res.results.length).toBe(1); + expect(res.results[0].objectId).toEqual(userId); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); - it('ensures no user is created when passing an empty password', (done) => { - RESTController.request("POST", "/classes/_User", {username: "hello", password: ""}).then(() => { - jfail(new Error('Success callback should not be called when passing an empty password.')); - done(); - }, (err) => { - expect(err.code).toBe(Parse.Error.PASSWORD_MISSING); - expect(err.message).toBe('password is required'); - done(); - }); + it('ensures no user is created when passing an empty username', done => { + RESTController.request('POST', '/classes/_User', { + username: '', + password: 'world', + }).then( + () => { + jfail( + new Error( + 'Success callback should not be called when passing an empty username.' + ) + ); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.USERNAME_MISSING); + expect(err.message).toBe('bad or missing username'); + done(); + } + ); }); - it('ensures no session token is created on creating users', (done) => { - RESTController.request("POST", "/classes/_User", {username: "hello", password: "world"}).then((user) => { - expect(user.sessionToken).toBeUndefined(); - const query = new Parse.Query('_Session'); - return query.find({useMasterKey: true}); - }).then(sessions => { - expect(sessions.length).toBe(0); - done(); - }, done.fail); + it('ensures no user is created when passing an empty password', done => { + RESTController.request('POST', '/classes/_User', { + username: 'hello', + password: '', + }).then( + () => { + jfail( + new Error( + 'Success callback should not be called when passing an empty password.' + ) + ); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.PASSWORD_MISSING); + expect(err.message).toBe('password is required'); + done(); + } + ); }); - it('ensures a session token is created when passing installationId != cloud', (done) => { - RESTController.request("POST", "/classes/_User", {username: "hello", password: "world"}, {installationId: 'my-installation'}).then((user) => { - expect(user.sessionToken).not.toBeUndefined(); - const query = new Parse.Query('_Session'); - return query.find({useMasterKey: true}); - }).then(sessions => { - expect(sessions.length).toBe(1); - expect(sessions[0].get('installationId')).toBe('my-installation'); - done(); - }, (err) => { - jfail(err); - done(); - }); + it('ensures no session token is created on creating users', done => { + RESTController.request('POST', '/classes/_User', { + username: 'hello', + password: 'world', + }) + .then(user => { + expect(user.sessionToken).toBeUndefined(); + const query = new Parse.Query('_Session'); + return query.find({ useMasterKey: true }); + }) + .then(sessions => { + expect(sessions.length).toBe(0); + done(); + }, done.fail); + }); + + it('ensures a session token is created when passing installationId != cloud', done => { + RESTController.request( + 'POST', + '/classes/_User', + { username: 'hello', password: 'world' }, + { installationId: 'my-installation' } + ) + .then(user => { + expect(user.sessionToken).not.toBeUndefined(); + const query = new Parse.Query('_Session'); + return query.find({ useMasterKey: true }); + }) + .then( + sessions => { + expect(sessions.length).toBe(1); + expect(sessions[0].get('installationId')).toBe('my-installation'); + done(); + }, + err => { + jfail(err); + done(); + } + ); }); }); diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js new file mode 100644 index 0000000000..084f141e08 --- /dev/null +++ b/spec/ParseSession.spec.js @@ -0,0 +1,138 @@ +// +// Tests behavior of Parse Sessions +// + +'use strict'; + +function setupTestUsers() { + const user1 = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + + user1.set('username', 'testuser_1'); + user2.set('username', 'testuser_2'); + user3.set('username', 'testuser_3'); + + user1.set('password', 'password'); + user2.set('password', 'password'); + user3.set('password', 'password'); + + return user1 + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + return user3.signUp(); + }); +} + +describe('Parse.Session', () => { + // multiple sessions with masterKey + sessionToken + it('should retain original sessionTokens with masterKey & sessionToken set', done => { + setupTestUsers() + .then(user => { + const query = new Parse.Query(Parse.Session); + return query.find({ + useMasterKey: true, + sessionToken: user.get('sessionToken'), + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken]) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // single session returned, with just one sessionToken + it('should retain original sessionTokens with just sessionToken set', done => { + let knownSessionToken; + setupTestUsers() + .then(user => { + knownSessionToken = user.get('sessionToken'); + const query = new Parse.Query(Parse.Session); + return query.find({ + sessionToken: knownSessionToken, + }); + }) + .then(results => { + expect(results.length).toBe(1); + const sessionToken = results[0].get('sessionToken'); + expect(sessionToken).toBe(knownSessionToken); + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // multiple users with masterKey + sessionToken + it('token on users should retain original sessionTokens with masterKey & sessionToken set', done => { + setupTestUsers() + .then(user => { + const query = new Parse.Query(Parse.User); + return query.find({ + useMasterKey: true, + sessionToken: user.get('sessionToken'), + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken] && sessionToken !== undefined) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // multiple users with just sessionToken + it('token on users should retain original sessionTokens with just sessionToken set', done => { + let knownSessionToken; + setupTestUsers() + .then(user => { + knownSessionToken = user.get('sessionToken'); + const query = new Parse.Query(Parse.User); + return query.find({ + sessionToken: knownSessionToken, + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken] && sessionToken !== undefined) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + + done(); + }) + .catch(err => { + fail(err); + }); + }); +}); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 8581b9554f..8484cb4b17 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -5,12 +5,14 @@ // Tests that involve revocable sessions. // Tests that involve sending password reset emails. -"use strict"; +'use strict'; -var request = require('request'); -var passwordCrypto = require('../src/password'); -var Config = require('../src/Config'); -const rp = require('request-promise'); +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const request = require('../lib/request'); +const passwordCrypto = require('../lib/password'); +const Config = require('../lib/Config'); +const cryptoUtils = require('../lib/cryptoUtils'); function verifyACL(user) { const ACL = user.getACL(); @@ -27,144 +29,133 @@ function verifyACL(user) { } describe('Parse.User testing', () => { - it("user sign up class method", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - ok(user.getSessionToken()); - done(); - } - }); + it('user sign up class method', async done => { + const user = await Parse.User.signUp('asdf', 'zxcv'); + ok(user.getSessionToken()); + done(); }); - it("user sign up instance method", (done) => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - ok(user.getSessionToken()); - done(); - }, - error: function(userAgain, error) { - ok(undefined, error); - } - }); + it('user sign up instance method', async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + ok(user.getSessionToken()); }); - it("user login wrong username", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function() { - Parse.User.logIn("non_existent_user", "asdf3", - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - }, - error: function(err) { - jfail(err); - fail("Shit should not fail"); - done(); - } - }); + it('user login wrong username', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + try { + await Parse.User.logIn('non_existent_user', 'asdf3'); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("user login wrong password", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function() { - Parse.User.logIn("asdf", "asdfWrong", - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - } - }); + it('user login wrong password', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + try { + await Parse.User.logIn('asdf', 'asdfWrong'); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it('user login with non-string username with REST API', (done) => { - Parse.User.signUp('asdf', 'zxcv', null, { - success: () => { - return rp.post({ - url: 'http://localhost:8378/1/login', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { - _method: 'GET', - username: {'$regex':'^asd'}, - password: 'zxcv', - } - }).then((res) => { - fail(`no request should succeed: ${JSON.stringify(res)}`); - done(); - }).catch((err) => { - expect(err.statusCode).toBe(404); - expect(err.message).toMatch('{"code":101,"error":"Invalid username/password."}'); - done(); - }); + it('user login with non-string username with REST API', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - }); + body: { + _method: 'GET', + username: { $regex: '^asd' }, + password: 'zxcv', + }, + }) + .then(res => { + fail(`no request should succeed: ${JSON.stringify(res)}`); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }); }); - it('user login with non-string username with REST API', (done) => { - Parse.User.signUp('asdf', 'zxcv', null, { - success: () => { - return rp.post({ - url: 'http://localhost:8378/1/login', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { - _method: 'GET', - username: 'asdf', - password: {'$regex':'^zx'}, - } - }).then((res) => { - fail(`no request should succeed: ${JSON.stringify(res)}`); - done(); - }).catch((err) => { - expect(err.statusCode).toBe(404); - expect(err.message).toMatch('{"code":101,"error":"Invalid username/password."}'); - done(); - }); + it('user login with non-string username with REST API (again)', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - }); + body: { + _method: 'GET', + username: 'asdf', + password: { $regex: '^zx' }, + }, + }) + .then(res => { + fail(`no request should succeed: ${JSON.stringify(res)}`); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }); }); - it('user login using POST with REST API', (done) => { - Parse.User.signUp('some_user', 'some_password', null, { - success: () => { - return rp.post({ - url: 'http://localhost:8378/1/login', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { - username: 'some_user', - password: 'some_password', - } - }).then((res) => { - expect(res.username).toBe('some_user'); - done(); - }).catch((err) => { - fail(`no request should fail: ${JSON.stringify(err)}`); - done(); - }); + it('user login using POST with REST API', async done => { + await Parse.User.signUp('some_user', 'some_password'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', }, - }); + body: { + username: 'some_user', + password: 'some_password', + }, + }) + .then(res => { + expect(res.data.username).toBe('some_user'); + done(); + }) + .catch(err => { + fail(`no request should fail: ${JSON.stringify(err)}`); + done(); + }); }); - it("user login", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function() { - Parse.User.logIn("asdf", "zxcv", { - success: function(user) { - equal(user.get("username"), "asdf"); - verifyACL(user); - done(); - } - }); - } - }); + it('user login', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + const user = await Parse.User.logIn('asdf', 'zxcv'); + equal(user.get('username'), 'asdf'); + verifyACL(user); + done(); }); - it('should respect ACL without locking user out', (done) => { + it('should respect ACL without locking user out', done => { const user = new Parse.User(); const ACL = new Parse.ACL(); ACL.setPublicReadAccess(false); @@ -172,824 +163,974 @@ describe('Parse.User testing', () => { user.setUsername('asdf'); user.setPassword('zxcv'); user.setACL(ACL); - user.signUp().then(() => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - equal(user.get("username"), "asdf"); - const ACL = user.getACL(); - expect(ACL.getReadAccess(user)).toBe(true); - expect(ACL.getWriteAccess(user)).toBe(true); - expect(ACL.getPublicReadAccess()).toBe(false); - expect(ACL.getPublicWriteAccess()).toBe(false); - const perms = ACL.permissionsById; - expect(Object.keys(perms).length).toBe(1); - expect(perms[user.id].read).toBe(true); - expect(perms[user.id].write).toBe(true); - expect(perms['*']).toBeUndefined(); - // Try to lock out user - const newACL = new Parse.ACL(); - newACL.setReadAccess(user.id, false); - newACL.setWriteAccess(user.id, false); - user.setACL(newACL); - return user.save(); - }).then(() => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - equal(user.get("username"), "asdf"); - const ACL = user.getACL(); - expect(ACL.getReadAccess(user)).toBe(true); - expect(ACL.getWriteAccess(user)).toBe(true); - expect(ACL.getPublicReadAccess()).toBe(false); - expect(ACL.getPublicWriteAccess()).toBe(false); - const perms = ACL.permissionsById; - expect(Object.keys(perms).length).toBe(1); - expect(perms[user.id].read).toBe(true); - expect(perms[user.id].write).toBe(true); - expect(perms['*']).toBeUndefined(); - done(); - }).catch(() => { - fail("Should not fail"); - done(); - }) + user + .signUp() + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + // Try to lock out user + const newACL = new Parse.ACL(); + newACL.setReadAccess(user.id, false); + newACL.setWriteAccess(user.id, false); + user.setACL(newACL); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + done(); + }) + .catch(() => { + fail('Should not fail'); + done(); + }); }); - it("user login with files", (done) => { - const file = new Parse.File("yolo.txt", [1,2,3], "text/plain"); - file.save().then((file) => { - return Parse.User.signUp("asdf", "zxcv", { "file" : file }); - }).then(() => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - const fileAgain = user.get('file'); - ok(fileAgain.name()); - ok(fileAgain.url()); - done(); - }).catch(err => { - jfail(err); - done(); + it('should let masterKey lockout user', done => { + const user = new Parse.User(); + const ACL = new Parse.ACL(); + ACL.setPublicReadAccess(false); + ACL.setPublicWriteAccess(false); + user.setUsername('asdf'); + user.setPassword('zxcv'); + user.setACL(ACL); + user + .signUp() + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + // Lock the user down + const ACL = new Parse.ACL(); + user.setACL(ACL); + return user.save(null, { useMasterKey: true }); + }) + .then(() => { + expect(user.getACL().getPublicReadAccess()).toBe(false); + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(done.fail) + .catch(err => { + expect(err.message).toBe('Invalid username/password.'); + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + }); + + it_only_db('mongo')('should let legacy users without ACL login', async () => { + const databaseURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; + const adapter = new MongoStorageAdapter({ + collectionPrefix: 'test_', + uri: databaseURI, + }); + await adapter.connect(); + await adapter.database.dropDatabase(); + delete adapter.connectionPromise; + + const user = new Parse.User(); + await user.signUp({ + username: 'newUser', + password: 'password', + }); + + const collection = await adapter._adaptiveCollection('_User'); + await collection.insertOne({ + // the hashed password is 'password' hashed + _hashed_password: + '$2b$10$mJ2ca2UbCM9hlojYHZxkQe8pyEXe5YMg0nMdvP4AJBeqlTEZJ6/Uu', + _session_token: 'xxx', + email: 'xxx@a.b', + username: 'oldUser', + emailVerified: true, + _email_verify_token: 'yyy', }); + + // get the 2 users + const users = await collection.find(); + expect(users.length).toBe(2); + + const aUser = await Parse.User.logIn('oldUser', 'password'); + expect(aUser).not.toBeUndefined(); + + const newUser = await Parse.User.logIn('newUser', 'password'); + expect(newUser).not.toBeUndefined(); + }); + + it('should be let masterKey lock user out with authData', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, + }); + const body = response.data; + const objectId = body.objectId; + const sessionToken = body.sessionToken; + expect(sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + const user = new Parse.User(); + user.id = objectId; + const ACL = new Parse.ACL(); + user.setACL(ACL); + await user.save(null, { useMasterKey: true }); + // update the user + const options = { + method: 'POST', + url: `http://localhost:8378/1/classes/_User/`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + const res = await request(options); + expect(res.data.objectId).not.toEqual(objectId); + }); + + it('user login with files', done => { + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(file => { + return Parse.User.signUp('asdf', 'zxcv', { file: file }); + }) + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + const fileAgain = user.get('file'); + ok(fileAgain.name()); + ok(fileAgain.url()); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); it('become sends token back', done => { let user = null; - var sessionToken = null; - - Parse.User.signUp('Jason', 'Parse', { 'code': 'red' }).then(newUser => { - user = newUser; - expect(user.get('code'), 'red'); - - sessionToken = newUser.getSessionToken(); - expect(sessionToken).toBeDefined(); - - return Parse.User.become(sessionToken); - }).then(newUser => { - expect(newUser.id).toEqual(user.id); - expect(newUser.get('username'), 'Jason'); - expect(newUser.get('code'), 'red'); - expect(newUser.getSessionToken()).toEqual(sessionToken); - }).then(() => { - done(); - }, error => { - jfail(error); - done(); - }); + let sessionToken = null; + + Parse.User.signUp('Jason', 'Parse', { code: 'red' }) + .then(newUser => { + user = newUser; + expect(user.get('code'), 'red'); + + sessionToken = newUser.getSessionToken(); + expect(sessionToken).toBeDefined(); + + return Parse.User.become(sessionToken); + }) + .then(newUser => { + expect(newUser.id).toEqual(user.id); + expect(newUser.get('username'), 'Jason'); + expect(newUser.get('code'), 'red'); + expect(newUser.getSessionToken()).toEqual(sessionToken); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it("become", (done) => { - var user = null; - var sessionToken = null; + it('become', done => { + let user = null; + let sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp("Jason", "Parse", { "code": "red" }); + Promise.resolve() + .then(function() { + return Parse.User.signUp('Jason', 'Parse', { code: 'red' }); + }) + .then(function(newUser) { + equal(Parse.User.current(), newUser); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); + user = newUser; + sessionToken = newUser.getSessionToken(); + ok(sessionToken); - user = newUser; - sessionToken = newUser.getSessionToken(); - ok(sessionToken); + return Parse.User.logOut(); + }) + .then(() => { + ok(!Parse.User.current()); - return Parse.User.logOut(); - }).then(() => { - ok(!Parse.User.current()); + return Parse.User.become(sessionToken); + }) + .then(function(newUser) { + equal(Parse.User.current(), newUser); - return Parse.User.become(sessionToken); + ok(newUser); + equal(newUser.id, user.id); + equal(newUser.get('username'), 'Jason'); + equal(newUser.get('code'), 'red'); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); + return Parse.User.logOut(); + }) + .then(() => { + ok(!Parse.User.current()); - ok(newUser); - equal(newUser.id, user.id); - equal(newUser.get("username"), "Jason"); - equal(newUser.get("code"), "red"); + return Parse.User.become('somegarbage'); + }) + .then( + function() { + // This should have failed actually. + ok( + false, + "Shouldn't have been able to log in with garbage session token." + ); + }, + function(error) { + ok(error); + // Handle the error. + return Promise.resolve(); + } + ) + .then( + function() { + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); + }); - return Parse.User.logOut(); - }).then(() => { - ok(!Parse.User.current()); + it('should not call beforeLogin with become', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); - return Parse.User.become("somegarbage"); + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; + }); - }).then(function() { - // This should have failed actually. - ok(false, "Shouldn't have been able to log in with garbage session token."); - }, function(error) { - ok(error); - // Handle the error. - return Parse.Promise.as(); + await Parse.User._logInWith('facebook'); + const sessionToken = Parse.User.current().getSessionToken(); + await Parse.User.become(sessionToken); + expect(hit).toBe(0); + done(); + }); - }).then(function() { - done(); - }, function(error) { - ok(false, error); + it('cannot save non-authed user', async done => { + let user = new Parse.User(); + user.set({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', + }); + let userAgain = await user.signUp(); + equal(userAgain, user); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + user.set({ + username: 'hacker', + password: 'password', + }); + userAgain = await user.signUp(); + equal(userAgain, user); + userNotAuthed.set('username', 'changed'); + userNotAuthed.save().then(fail, err => { + expect(err.code).toEqual(Parse.Error.SESSION_MISSING); done(); }); }); - it("cannot save non-authed user", (done) => { - var user = new Parse.User(); - user.set({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain, user); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.set({ - "username": "hacker", - "password": "password" - }); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain, user); - userNotAuthed.set("username", "changed"); - userNotAuthed.save().then(fail, (err) => { - expect(err.code).toEqual(Parse.Error.SESSION_MISSING); - done(); - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - } + it('cannot delete non-authed user', async done => { + let user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', }); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + const userAgain = await user.signUp({ + username: 'hacker', + password: 'password', + }); + equal(userAgain, user); + userNotAuthed.set('username', 'changed'); + try { + await userNotAuthed.destroy(); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + done(); + } }); - it("cannot delete non-authed user", (done) => { - var user = new Parse.User(); - user.signUp({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.signUp({ - "username": "hacker", - "password": "password" - }, { - success: function(userAgain) { - equal(userAgain, user); - userNotAuthed.set("username", "changed"); - userNotAuthed.destroy(expectError( - Parse.Error.SESSION_MISSING, done)); - } - }); - } - }); - } + it('cannot saveAll with non-authed user', async done => { + let user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', + }); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + await user.signUp({ + username: 'hacker', + password: 'password', + }); + const userNotAuthedNotChanged = await query.get(user.id); + userNotAuthed.set('username', 'changed'); + const object = new TestObject(); + await object.save({ + user: userNotAuthedNotChanged, }); + const item1 = new TestObject(); + await item1.save({ + number: 0, + }); + item1.set('number', 1); + const item2 = new TestObject(); + item2.set('number', 2); + try { + await Parse.Object.saveAll([item1, item2, userNotAuthed]); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + done(); + } }); - it("cannot saveAll with non-authed user", (done) => { - var user = new Parse.User(); - user.signUp({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.signUp({ - username: "hacker", - password: "password" - }, { - success: function() { - query.get(user.id, { - success: function(userNotAuthedNotChanged) { - userNotAuthed.set("username", "changed"); - var object = new TestObject(); - object.save({ - user: userNotAuthedNotChanged - }, { - success: function() { - var item1 = new TestObject(); - item1.save({ - number: 0 - }, { - success: function(item1) { - item1.set("number", 1); - var item2 = new TestObject(); - item2.set("number", 2); - Parse.Object.saveAll( - [item1, item2, userNotAuthed], - expectError(Parse.Error.SESSION_MISSING, done)); - } - }); - } - }); - } - }); - } - }); - } - }); - } + it('never locks himself up', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'username', + password: 'password', + }); + user.setACL(new Parse.ACL()); + await user.save(); + await user.fetch(); + expect(user.getACL().getReadAccess(user)).toBe(true); + expect(user.getACL().getWriteAccess(user)).toBe(true); + const publicReadACL = new Parse.ACL(); + publicReadACL.setPublicReadAccess(true); + + // Create an administrator role with a single admin user + const role = new Parse.Role('admin', publicReadACL); + const admin = new Parse.User(); + await admin.signUp({ + username: 'admin', + password: 'admin', }); + role.getUsers().add(admin); + await role.save(null, { useMasterKey: true }); + + // Grant the admins write rights on the user + const acl = user.getACL(); + acl.setRoleWriteAccess(role, true); + acl.setRoleReadAccess(role, true); + + // Update with the masterKey just to be sure + await user.save({ ACL: acl }, { useMasterKey: true }); + + // Try to update from admin... should all work fine + await user.save( + { key: 'fromAdmin' }, + { sessionToken: admin.getSessionToken() } + ); + await user.fetch(); + expect(user.toJSON().key).toEqual('fromAdmin'); + + // Try to save when logged out (public) + let failed = false; + try { + // Ensure no session token is sent + await Parse.User.logOut(); + await user.save({ key: 'fromPublic' }); + } catch (e) { + failed = true; + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + expect({ failed }).toEqual({ failed: true }); + + // Try to save with a random user, should fail + failed = false; + const anyUser = new Parse.User(); + await anyUser.signUp({ + username: 'randomUser', + password: 'password', + }); + try { + await user.save({ key: 'fromAnyUser' }); + } catch (e) { + failed = true; + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + expect({ failed }).toEqual({ failed: true }); }); - it("current user", (done) => { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp().then(() => { - var currentUser = Parse.User.current(); - equal(user.id, currentUser.id); - ok(user.getSessionToken()); + it('current user', done => { + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + user + .signUp() + .then(() => { + const currentUser = Parse.User.current(); + equal(user.id, currentUser.id); + ok(user.getSessionToken()); - var currentUserAgain = Parse.User.current(); - // should be the same object - equal(currentUser, currentUserAgain); + const currentUserAgain = Parse.User.current(); + // should be the same object + equal(currentUser, currentUserAgain); - // test logging out the current user - return Parse.User.logOut(); - }).then(() => { - equal(Parse.User.current(), null); - done(); - }); + // test logging out the current user + return Parse.User.logOut(); + }) + .then(() => { + equal(Parse.User.current(), null); + done(); + }); }); - it("user.isCurrent", (done) => { - var user1 = new Parse.User(); - var user2 = new Parse.User(); - var user3 = new Parse.User(); - - user1.set("username", "a"); - user2.set("username", "b"); - user3.set("username", "c"); - - user1.set("password", "password"); - user2.set("password", "password"); - user3.set("password", "password"); - - user1.signUp().then(() => { - equal(user1.isCurrent(), true); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), false); - return user2.signUp(); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return user3.signUp(); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), true); - return Parse.User.logIn("a", "password"); - }).then(() => { - equal(user1.isCurrent(), true); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), false); - return Parse.User.logIn("b", "password"); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return Parse.User.logIn("b", "password"); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return Parse.User.logOut(); - }).then(() => { - equal(user2.isCurrent(), false); - done(); - }); + it('user.isCurrent', done => { + const user1 = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + + user1.set('username', 'a'); + user2.set('username', 'b'); + user3.set('username', 'c'); + + user1.set('password', 'password'); + user2.set('password', 'password'); + user3.set('password', 'password'); + + user1 + .signUp() + .then(() => { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + return user2.signUp(); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return user3.signUp(); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), true); + return Parse.User.logIn('a', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + return Parse.User.logIn('b', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return Parse.User.logIn('b', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return Parse.User.logOut(); + }) + .then(() => { + equal(user2.isCurrent(), false); + done(); + }); }); - it("user associations", (done) => { - var child = new TestObject(); - child.save(null, { - success: function() { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.set("child", child); - user.signUp(null, { - success: function() { - var object = new TestObject(); - object.set("user", user); - object.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(objectAgain) { - var userAgain = objectAgain.get("user"); - userAgain.fetch({ - success: function() { - equal(user.id, userAgain.id); - equal(userAgain.get("child").id, child.id); - done(); - } - }); - } - }); - } - }); - } - }); - } - }); + it('user associations', async done => { + const child = new TestObject(); + await child.save(); + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + user.set('child', child); + await user.signUp(); + const object = new TestObject(); + object.set('user', user); + await object.save(); + const query = new Parse.Query(TestObject); + const objectAgain = await query.get(object.id); + const userAgain = objectAgain.get('user'); + await userAgain.fetch(); + equal(user.id, userAgain.id); + equal(userAgain.get('child').id, child.id); + done(); }); - it("user queries", (done) => { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp(null, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userAgain) { - equal(userAgain.id, user.id); - query.find({ - success: function(users) { - equal(users.length, 1); - equal(users[0].id, user.id); - ok(userAgain.get("email"), "asdf@example.com"); - done(); - } - }); - } - }); - } - }); + it('user queries', async done => { + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + await user.signUp(); + const query = new Parse.Query(Parse.User); + const userAgain = await query.get(user.id); + equal(userAgain.id, user.id); + const users = await query.find(); + equal(users.length, 1); + equal(users[0].id, user.id); + ok(userAgain.get('email'), 'asdf@example.com'); + done(); }); function signUpAll(list, optionsOrCallback) { - var promise = Parse.Promise.as(); - list.forEach((user) => { + let promise = Promise.resolve(); + list.forEach(user => { promise = promise.then(function() { return user.signUp(); }); }); - promise = promise.then(function() { return list; }); - return promise._thenRunCallbacks(optionsOrCallback); + promise = promise.then(function() { + return list; + }); + return promise.then(optionsOrCallback); } - it("contained in user array queries", (done) => { - var USERS = 4; - var MESSAGES = 5; + it('contained in user array queries', async done => { + const USERS = 4; + const MESSAGES = 5; // Make a list of users. - var userList = range(USERS).map(function(i) { - var user = new Parse.User(); - user.set("password", "user_num_" + i); - user.set("email", "user_num_" + i + "@example.com"); - user.set("username", "xinglblog_num_" + i); + const userList = range(USERS).map(function(i) { + const user = new Parse.User(); + user.set('password', 'user_num_' + i); + user.set('email', 'user_num_' + i + '@example.com'); + user.set('username', 'xinglblog_num_' + i); return user; }); - signUpAll(userList, function(users) { + signUpAll(userList, async function(users) { // Make a list of messages. if (!users || users.length != USERS) { fail('signupAll failed'); done(); return; } - var messageList = range(MESSAGES).map(function(i) { - var message = new TestObject(); - message.set("to", users[(i + 1) % USERS]); - message.set("from", users[i % USERS]); + const messageList = range(MESSAGES).map(function(i) { + const message = new TestObject(); + message.set('to', users[(i + 1) % USERS]); + message.set('from', users[i % USERS]); return message; }); // Save all the messages. - Parse.Object.saveAll(messageList, function() { - - // Assemble an "in" list. - var inList = [users[0], users[3], users[3]]; // Intentional dupe - var query = new Parse.Query(TestObject); - query.containedIn("from", inList); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } - }); - - }); + await Parse.Object.saveAll(messageList); + + // Assemble an "in" list. + const inList = [users[0], users[3], users[3]]; // Intentional dupe + const query = new Parse.Query(TestObject); + query.containedIn('from', inList); + const results = await query.find(); + equal(results.length, 3); + done(); }); }); - it("saving a user signs them up but doesn't log them in", (done) => { - var user = new Parse.User(); - user.save({ - password: "asdf", - email: "asdf@example.com", - username: "zxcv" - }, { - success: function() { - equal(Parse.User.current(), null); - done(); - } + it("saving a user signs them up but doesn't log them in", async done => { + const user = new Parse.User(); + await user.save({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', }); + equal(Parse.User.current(), null); + done(); }); - it("user updates", (done) => { - var user = new Parse.User(); - user.signUp({ - password: "asdf", - email: "asdf@example.com", - username: "zxcv" - }, { - success: function(user) { - user.set("username", "test"); - user.save(null, { - success: function() { - equal(Object.keys(user.attributes).length, 6); - ok(user.attributes["username"]); - ok(user.attributes["email"]); - user.destroy({ - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - error: function(model, error) { - // The user should no longer exist. - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } + it('user updates', async done => { + const user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', }); - }); - it("count users", (done) => { - var james = new Parse.User(); - james.set("username", "james"); - james.set("password", "mypass"); - james.signUp(null, { - success: function() { - var kevin = new Parse.User(); - kevin.set("username", "kevin"); - kevin.set("password", "mypass"); - kevin.signUp(null, { - success: function() { - var query = new Parse.Query(Parse.User); - query.count({ - success: function(count) { - equal(count, 2); - done(); - } - }); - } - }); - } - }); + user.set('username', 'test'); + await user.save(); + equal(Object.keys(user.attributes).length, 6); + ok(user.attributes['username']); + ok(user.attributes['email']); + await user.destroy(); + const query = new Parse.Query(Parse.User); + try { + await query.get(user.id); + done.fail(); + } catch (error) { + // The user should no longer exist. + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("user sign up with container class", (done) => { - Parse.User.signUp("ilya", "mypass", { "array": ["hello"] }, { - success: function() { - done(); - } - }); + it('count users', async done => { + const james = new Parse.User(); + james.set('username', 'james'); + james.set('password', 'mypass'); + await james.signUp(); + const kevin = new Parse.User(); + kevin.set('username', 'kevin'); + kevin.set('password', 'mypass'); + await kevin.signUp(); + const query = new Parse.Query(Parse.User); + const count = await query.count(); + equal(count, 2); + done(); }); - it("user modified while saving", (done) => { - Parse.Object.disableSingleInstance(); - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "password"); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain.get("username"), "bob"); - ok(userAgain.dirty("username")); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(freshUser) { - equal(freshUser.id, user.id); - equal(freshUser.get("username"), "alice"); - Parse.Object.enableSingleInstance(); - done(); - } - }); - } - }); - ok(user.set("username", "bob")); + it('user sign up with container class', async done => { + await Parse.User.signUp('ilya', 'mypass', { array: ['hello'] }); + done(); }); - it("user modified while saving with unsaved child", (done) => { + it('user modified while saving', done => { Parse.Object.disableSingleInstance(); - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "password"); - user.set("child", new TestObject()); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain.get("username"), "bob"); - // Should be dirty, but it depends on batch support. - // ok(userAgain.dirty("username")); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(freshUser) { - equal(freshUser.id, user.id); - // Should be alice, but it depends on batch support. - equal(freshUser.get("username"), "bob"); - Parse.Object.enableSingleInstance(); - done(); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'password'); + user.signUp().then(function(userAgain) { + equal(userAgain.get('username'), 'bob'); + ok(userAgain.dirty('username')); + const query = new Parse.Query(Parse.User); + query.get(user.id).then(freshUser => { + equal(freshUser.id, user.id); + equal(freshUser.get('username'), 'alice'); + done(); + }); + }); + // Jump a frame so the signup call is properly sent + // This is due to the fact that now, we use real promises + process.nextTick(() => { + ok(user.set('username', 'bob')); }); - ok(user.set("username", "bob")); }); - it("user loaded from localStorage from signup", (done) => { - Parse.User.signUp("alice", "password", null, { - success: function(alice) { - ok(alice.id, "Alice should have an objectId"); - ok(alice.getSessionToken(), "Alice should have a session token"); - equal(alice.get("password"), undefined, - "Alice should not have a password"); - - // Simulate the environment getting reset. - Parse.User._currentUser = null; - Parse.User._currentUserMatchesDisk = false; - - var aliceAgain = Parse.User.current(); - equal(aliceAgain.get("username"), "alice"); - equal(aliceAgain.id, alice.id, "currentUser should have objectId"); - ok(aliceAgain.getSessionToken(), - "currentUser should have a sessionToken"); - equal(alice.get("password"), undefined, - "currentUser should not have password"); + it('user modified while saving with unsaved child', done => { + Parse.Object.disableSingleInstance(); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'password'); + user.set('child', new TestObject()); + user.signUp().then(userAgain => { + equal(userAgain.get('username'), 'bob'); + // Should be dirty, but it depends on batch support. + // ok(userAgain.dirty("username")); + const query = new Parse.Query(Parse.User); + query.get(user.id).then(freshUser => { + equal(freshUser.id, user.id); + // Should be alice, but it depends on batch support. + equal(freshUser.get('username'), 'bob'); done(); - } + }); }); + ok(user.set('username', 'bob')); }); - - it("user loaded from localStorage from login", (done) => { - var id; - Parse.User.signUp("alice", "password").then((alice) => { - id = alice.id; - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn("alice", "password"); - }).then(() => { - // Force the current user to read from disk - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; - - var userFromDisk = Parse.User.current(); - equal(userFromDisk.get("password"), undefined, - "password should not be in attributes"); - equal(userFromDisk.id, id, "id should be set"); - ok(userFromDisk.getSessionToken(), - "currentUser should have a sessionToken"); - done(); - }); + it('user loaded from localStorage from signup', async done => { + const alice = await Parse.User.signUp('alice', 'password'); + ok(alice.id, 'Alice should have an objectId'); + ok(alice.getSessionToken(), 'Alice should have a session token'); + equal(alice.get('password'), undefined, 'Alice should not have a password'); + + // Simulate the environment getting reset. + Parse.User._currentUser = null; + Parse.User._currentUserMatchesDisk = false; + + const aliceAgain = Parse.User.current(); + equal(aliceAgain.get('username'), 'alice'); + equal(aliceAgain.id, alice.id, 'currentUser should have objectId'); + ok(aliceAgain.getSessionToken(), 'currentUser should have a sessionToken'); + equal( + alice.get('password'), + undefined, + 'currentUser should not have password' + ); + done(); }); - it("saving user after browser refresh", (done) => { - var id; - - Parse.User.signUp("alice", "password", null).then(function(alice) { - id = alice.id; - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn("alice", "password"); - }).then(function() { - // Simulate browser refresh by force-reloading user from localStorage - Parse.User._clearCache(); - - // Test that this save works correctly - return Parse.User.current().save({some_field: 1}); - }).then(function() { - // Check the user in memory just after save operation - var userInMemory = Parse.User.current(); - - equal(userInMemory.getUsername(), "alice", - "saving user should not remove existing fields"); - - equal(userInMemory.get('some_field'), 1, - "saving user should save specified field"); - - equal(userInMemory.get("password"), undefined, - "password should not be in attributes after saving user"); - - equal(userInMemory.get("objectId"), undefined, - "objectId should not be in attributes after saving user"); - - equal(userInMemory.get("_id"), undefined, - "_id should not be in attributes after saving user"); - - equal(userInMemory.id, id, "id should be set"); - - expect(userInMemory.updatedAt instanceof Date).toBe(true); - - ok(userInMemory.createdAt instanceof Date); - - ok(userInMemory.getSessionToken(), - "user should have a sessionToken after saving"); - - // Force the current user to read from localStorage, and check again - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; - var userFromDisk = Parse.User.current(); - - equal(userFromDisk.getUsername(), "alice", - "userFromDisk should have previously existing fields"); - - equal(userFromDisk.get('some_field'), 1, - "userFromDisk should have saved field"); - - equal(userFromDisk.get("password"), undefined, - "password should not be in attributes of userFromDisk"); - - equal(userFromDisk.get("objectId"), undefined, - "objectId should not be in attributes of userFromDisk"); - - equal(userFromDisk.get("_id"), undefined, - "_id should not be in attributes of userFromDisk"); - - equal(userFromDisk.id, id, "id should be set on userFromDisk"); - - ok(userFromDisk.updatedAt instanceof Date); - - ok(userFromDisk.createdAt instanceof Date); + it('user loaded from localStorage from login', done => { + let id; + Parse.User.signUp('alice', 'password') + .then(alice => { + id = alice.id; + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('alice', 'password'); + }) + .then(() => { + // Force the current user to read from disk + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; + + const userFromDisk = Parse.User.current(); + equal( + userFromDisk.get('password'), + undefined, + 'password should not be in attributes' + ); + equal(userFromDisk.id, id, 'id should be set'); + ok( + userFromDisk.getSessionToken(), + 'currentUser should have a sessionToken' + ); + done(); + }); + }); - ok(userFromDisk.getSessionToken(), - "userFromDisk should have a sessionToken"); + it('saving user after browser refresh', done => { + let id; - done(); - }, function(error) { - ok(false, error); - done(); - }); - }); + Parse.User.signUp('alice', 'password', null) + .then(function(alice) { + id = alice.id; + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('alice', 'password'); + }) + .then(function() { + // Simulate browser refresh by force-reloading user from localStorage + Parse.User._clearCache(); - it("user with missing username", (done) => { - var user = new Parse.User(); - user.set("password", "foo"); - user.signUp(null, { - success: function() { - ok(null, "This should have failed"); - done(); - }, - error: function(userAgain, error) { - equal(error.code, Parse.Error.OTHER_CAUSE); - done(); - } - }); - }); + // Test that this save works correctly + return Parse.User.current().save({ some_field: 1 }); + }) + .then( + function() { + // Check the user in memory just after save operation + const userInMemory = Parse.User.current(); + + equal( + userInMemory.getUsername(), + 'alice', + 'saving user should not remove existing fields' + ); + + equal( + userInMemory.get('some_field'), + 1, + 'saving user should save specified field' + ); + + equal( + userInMemory.get('password'), + undefined, + 'password should not be in attributes after saving user' + ); + + equal( + userInMemory.get('objectId'), + undefined, + 'objectId should not be in attributes after saving user' + ); + + equal( + userInMemory.get('_id'), + undefined, + '_id should not be in attributes after saving user' + ); + + equal(userInMemory.id, id, 'id should be set'); + + expect(userInMemory.updatedAt instanceof Date).toBe(true); + + ok(userInMemory.createdAt instanceof Date); + + ok( + userInMemory.getSessionToken(), + 'user should have a sessionToken after saving' + ); + + // Force the current user to read from localStorage, and check again + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; + const userFromDisk = Parse.User.current(); + + equal( + userFromDisk.getUsername(), + 'alice', + 'userFromDisk should have previously existing fields' + ); + + equal( + userFromDisk.get('some_field'), + 1, + 'userFromDisk should have saved field' + ); + + equal( + userFromDisk.get('password'), + undefined, + 'password should not be in attributes of userFromDisk' + ); + + equal( + userFromDisk.get('objectId'), + undefined, + 'objectId should not be in attributes of userFromDisk' + ); + + equal( + userFromDisk.get('_id'), + undefined, + '_id should not be in attributes of userFromDisk' + ); + + equal(userFromDisk.id, id, 'id should be set on userFromDisk'); + + ok(userFromDisk.updatedAt instanceof Date); + + ok(userFromDisk.createdAt instanceof Date); + + ok( + userFromDisk.getSessionToken(), + 'userFromDisk should have a sessionToken' + ); - it("user with missing password", (done) => { - var user = new Parse.User(); - user.set("username", "foo"); - user.signUp(null, { - success: function() { - ok(null, "This should have failed"); - done(); - }, - error: function(userAgain, error) { - equal(error.code, Parse.Error.OTHER_CAUSE); - done(); - } - }); + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); }); - it("user stupid subclassing", (done) => { + it('user with missing username', async done => { + const user = new Parse.User(); + user.set('password', 'foo'); + try { + await user.signUp(); + done.fail(); + } catch (error) { + equal(error.code, Parse.Error.OTHER_CAUSE); + done(); + } + }); - var SuperUser = Parse.Object.extend("User"); - var user = new SuperUser(); - user.set("username", "bob"); - user.set("password", "welcome"); - ok(user instanceof Parse.User, "Subclassing User should have worked"); - user.signUp(null, { - success: function() { - done(); - }, - error: function() { - ok(false, "Signing up should have worked"); - done(); - } - }); + it('user with missing password', async done => { + const user = new Parse.User(); + user.set('username', 'foo'); + try { + await user.signUp(); + done.fail(); + } catch (error) { + equal(error.code, Parse.Error.OTHER_CAUSE); + done(); + } }); - it("user signup class method uses subclassing", (done) => { + it('user stupid subclassing', async done => { + const SuperUser = Parse.Object.extend('User'); + const user = new SuperUser(); + user.set('username', 'bob'); + user.set('password', 'welcome'); + ok(user instanceof Parse.User, 'Subclassing User should have worked'); + await user.signUp(); + done(); + }); - var SuperUser = Parse.User.extend({ + it('user signup class method uses subclassing', async done => { + const SuperUser = Parse.User.extend({ secret: function() { return 1337; - } - }); - - Parse.User.signUp("bob", "welcome", null, { - success: function(user) { - ok(user instanceof SuperUser, "Subclassing User should have worked"); - equal(user.secret(), 1337); - done(); }, - error: function() { - ok(false, "Signing up should have worked"); - done(); - } }); + + const user = await Parse.User.signUp('bob', 'welcome'); + ok(user instanceof SuperUser, 'Subclassing User should have worked'); + equal(user.secret(), 1337); + done(); }); - it("user on disk gets updated after save", (done) => { + it('user on disk gets updated after save', async done => { Parse.User.extend({ isSuper: function() { return true; - } - }); - Parse.User.signUp("bob", "welcome", null, { - success: function(user) { - // Modify the user and save. - user.save("secret", 1337, { - success: function() { - // Force the current user to read from disk - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; - - var userFromDisk = Parse.User.current(); - equal(userFromDisk.get("secret"), 1337); - ok(userFromDisk.isSuper(), "The subclass should have been used"); - done(); - }, - error: function() { - ok(false, "Saving should have worked"); - done(); - } - }); }, - error: function() { - ok(false, "Sign up should have worked"); - done(); - } }); - }); - it("current user isn't dirty", (done) => { + const user = await Parse.User.signUp('bob', 'welcome'); + await user.save('secret', 1337); + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; - Parse.User.signUp("andrew", "oppa", { style: "gangnam" }, expectSuccess({ - success: function(user) { - ok(!user.dirty("style"), "The user just signed up."); - Parse.User._currentUser = null; - Parse.User._currentUserMatchesDisk = false; - var userAgain = Parse.User.current(); - ok(!userAgain.dirty("style"), "The user was just read from disk."); - done(); - } - })); + const userFromDisk = Parse.User.current(); + equal(userFromDisk.get('secret'), 1337); + ok(userFromDisk.isSuper(), 'The subclass should have been used'); + done(); }); - var getMockFacebookProviderWithIdToken = function(id, token) { + it("current user isn't dirty", async done => { + const user = await Parse.User.signUp('andrew', 'oppa', { + style: 'gangnam', + }); + ok(!user.dirty('style'), 'The user just signed up.'); + Parse.User._currentUser = null; + Parse.User._currentUserMatchesDisk = false; + const userAgain = Parse.User.current(); + ok(!userAgain.dirty('style'), 'The user was just read from disk.'); + done(); + }); + + const getMockFacebookProviderWithIdToken = function(id, token) { return { authData: { id: id, @@ -1004,7 +1145,7 @@ describe('Parse.User testing', () => { authenticate: function(options) { if (this.shouldError) { - options.error(this, "An error occurred"); + options.error(this, 'An error occurred'); } else if (this.shouldCancel) { options.error(this, null); } else { @@ -1024,26 +1165,26 @@ describe('Parse.User testing', () => { return true; }, getAuthType: function() { - return "facebook"; + return 'facebook'; }, deauthenticate: function() { this.loggedOut = true; this.restoreAuthentication(null); - } + }, }; - } + }; // Note that this mocks out client-side Facebook action rather than // server-side. - var getMockFacebookProvider = function() { + const getMockFacebookProvider = function() { return getMockFacebookProviderWithIdToken('8675309', 'jenny'); }; - var getMockMyOauthProvider = function() { + const getMockMyOauthProvider = function() { return { authData: { - id: "12345", - access_token: "12345", + id: '12345', + access_token: '12345', expiration_date: new Date().toJSON(), }, shouldError: false, @@ -1054,7 +1195,7 @@ describe('Parse.User testing', () => { authenticate: function(options) { if (this.shouldError) { - options.error(this, "An error occurred"); + options.error(this, 'An error occurred'); } else if (this.shouldCancel) { options.error(this, null); } else { @@ -1074,685 +1215,631 @@ describe('Parse.User testing', () => { return true; }, getAuthType: function() { - return "myoauth"; + return 'myoauth'; }, deauthenticate: function() { this.loggedOut = true; this.restoreAuthentication(null); - } + }, }; }; Parse.User.extend({ extended: function() { return true; - } + }, }); - it("log in with provider", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - done(); - }, - error: function(model, error) { - jfail(error); - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + done(); + }); + + it('can not set authdata to null', async () => { + try { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = await Parse.User._logInWith('facebook'); + user.set('authData', null); + await user.save(); + fail(); + } catch (e) { + expect(e.message).toBe('This authentication method is unsupported.'); + } }); - it("user authData should be available in cloudcode (#2342)", (done) => { + it('ignore setting authdata to undefined', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = await Parse.User._logInWith('facebook'); + user.set('authData', undefined); + await user.save(); + let authData = user.get('authData'); + expect(authData).toBe(undefined); + await user.fetch(); + authData = user.get('authData'); + expect(authData.facebook.id).toBeDefined(); + }); - Parse.Cloud.define('checkLogin', (req, res) => { + it('user authData should be available in cloudcode (#2342)', async done => { + Parse.Cloud.define('checkLogin', req => { expect(req.user).not.toBeUndefined(); expect(Parse.FacebookUtils.isLinked(req.user)).toBe(true); - res.success(); + return 'ok'; }); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - Parse.Cloud.run('checkLogin').then(done, done); - }, - error: function(model, error) { - jfail(error); - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + Parse.Cloud.run('checkLogin').then(done, done); }); - it("log in with provider and update token", (done) => { - var provider = getMockFacebookProvider(); - var secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); - var errorHandler = function() { - fail('should not fail'); - done(); - } + it('log in with provider and update token', async done => { + const provider = getMockFacebookProvider(); + const secondProvider = getMockFacebookProviderWithIdToken( + '8675309', + 'jenny_valid_token' + ); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: () => { - Parse.User._registerAuthenticationProvider(secondProvider); - return Parse.User.logOut().then(() => { - Parse.User._logInWith("facebook", { - success: () => { - expect(secondProvider.synchronizedAuthToken).toEqual('jenny_valid_token'); - // Make sure we can login with the new token again - Parse.User.logOut().then(() => { - Parse.User._logInWith("facebook", { - success: done, - error: errorHandler - }); - }); - }, - error: errorHandler - }); - }) - }, - error: errorHandler - }).catch((err) => { - errorHandler(err); - done(); - }); + await Parse.User._logInWith('facebook'); + Parse.User._registerAuthenticationProvider(secondProvider); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + expect(secondProvider.synchronizedAuthToken).toEqual('jenny_valid_token'); + // Make sure we can login with the new token again + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + done(); }); - it('returns authData when authed and logged in with provider (regression test for #1498)', done => { - Parse.Object.enableSingleInstance(); + it('returns authData when authed and logged in with provider (regression test for #1498)', async done => { const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith('facebook', { - success: user => { - const userQuery = new Parse.Query(Parse.User); - userQuery.get(user.id) - .then(user => { - expect(user.get('authData')).not.toBeUndefined(); - Parse.Object.disableSingleInstance(); - done(); - }); - } + const user = await Parse.User._logInWith('facebook'); + const userQuery = new Parse.Query(Parse.User); + userQuery.get(user.id).then(user => { + expect(user.get('authData')).not.toBeUndefined(); + done(); }); }); - it('only creates a single session for an installation / user pair (#2885)', done => { + it('only creates a single session for an installation / user pair (#2885)', async done => { Parse.Object.disableSingleInstance(); const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User.logInWith('facebook', { - success: () => { - return Parse.User.logInWith('facebook', { - success: () => { - return Parse.User.logInWith('facebook', { - success: (user) => { - const sessionToken = user.getSessionToken(); - const query = new Parse.Query('_Session'); - return query.find({ useMasterKey: true }) - .then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('sessionToken')).toBe(sessionToken); - expect(results[0].get('createdWith')).toEqual({ - action: 'login', - authProvider: 'facebook' - }); - done(); - }).catch(done.fail); - } - }); - } + await Parse.User.logInWith('facebook'); + await Parse.User.logInWith('facebook'); + const user = await Parse.User.logInWith('facebook'); + const sessionToken = user.getSessionToken(); + const query = new Parse.Query('_Session'); + return query + .find({ useMasterKey: true }) + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('sessionToken')).toBe(sessionToken); + expect(results[0].get('createdWith')).toEqual({ + action: 'login', + authProvider: 'facebook', }); - } - }); + done(); + }) + .catch(done.fail); }); it('log in with provider with files', done => { const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - const file = new Parse.File("yolo.txt", [1, 2, 3], "text/plain"); - file.save().then(file => { - const user = new Parse.User(); - user.set('file', file); - return user._linkWith('facebook', {}); - }).then(user => { - expect(user._isLinked("facebook")).toBeTruthy(); - return Parse.User._logInWith('facebook', {}); - }).then(user => { - const fileAgain = user.get('file'); - expect(fileAgain.name()).toMatch(/yolo.txt$/); - expect(fileAgain.url()).toMatch(/yolo.txt$/); - }).then(() => { + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(file => { + const user = new Parse.User(); + user.set('file', file); + return user._linkWith('facebook', {}); + }) + .then(user => { + expect(user._isLinked('facebook')).toBeTruthy(); + return Parse.User._logInWith('facebook', {}); + }) + .then(user => { + const fileAgain = user.get('file'); + expect(fileAgain.name()).toMatch(/yolo.txt$/); + expect(fileAgain.url()).toMatch(/yolo.txt$/); + }) + .then(() => { + done(); + }) + .catch(done.fail); + }); + + it('log in with provider twice', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + Parse.User.logOut().then(async () => { + ok(provider.loggedOut); + provider.loggedOut = false; + const innerModel = await Parse.User._logInWith('facebook'); + ok(innerModel instanceof Parse.User, 'Model should be a Parse.User'); + ok( + innerModel === Parse.User.current(), + 'Returned model should be the current user' + ); + ok(provider.authData.id === provider.synchronizedUserId); + ok(provider.authData.access_token === provider.synchronizedAuthToken); + ok(innerModel._isLinked('facebook'), 'User should be linked to facebook'); + ok(innerModel.existed(), 'User should not be newly-created'); done(); - }).catch(done.fail); + }, done.fail); }); - it("log in with provider twice", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider failed', async done => { + const provider = getMockFacebookProvider(); + provider.shouldError = true; Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - Parse.User.logOut().then(() => { - ok(provider.loggedOut); - provider.loggedOut = false; - - Parse.User._logInWith("facebook", { - success: function(innerModel) { - ok(innerModel instanceof Parse.User, - "Model should be a Parse.User"); - ok(innerModel === Parse.User.current(), - "Returned model should be the current user"); - ok(provider.authData.id === provider.synchronizedUserId); - ok(provider.authData.access_token === provider.synchronizedAuthToken); - ok(innerModel._isLinked("facebook"), - "User should be linked to facebook"); - ok(innerModel.existed(), "User should not be newly-created"); - done(); - }, - error: function(model, error) { - jfail(error); - ok(false, "LogIn should have worked"); - done(); - } - }); - }, done.fail); - }, - error: function(model, error) { - jfail(error); - ok(false, "LogIn should have worked"); - done(); - } + try { + await Parse.User._logInWith('facebook'); + done.fail(); + } catch (error) { + ok(error, 'Error should be non-null'); + done(); + } + }); + + it('log in with provider cancelled', async done => { + const provider = getMockFacebookProvider(); + provider.shouldCancel = true; + Parse.User._registerAuthenticationProvider(provider); + try { + await Parse.User._logInWith('facebook'); + done.fail(); + } catch (error) { + ok(error === null, 'Error should be null'); + done(); + } + }); + + it('login with provider should not call beforeSave trigger', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + await Parse.User._logInWith('facebook'); + Parse.User.logOut().then(async () => { + Parse.Cloud.beforeSave(Parse.User, function(req, res) { + res.error("Before save shouldn't be called on login"); + }); + await Parse.User._logInWith('facebook'); + done(); }); }); - it("log in with provider failed", (done) => { - var provider = getMockFacebookProvider(); - provider.shouldError = true; + it('signup with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function() { - ok(false, "logIn should not have succeeded"); - }, - error: function(model, error) { - ok(error, "Error should be non-null"); - done(); - } + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; }); + + await Parse.User._logInWith('facebook'); + expect(hit).toBe(0); + done(); }); - it("log in with provider cancelled", (done) => { - var provider = getMockFacebookProvider(); - provider.shouldCancel = true; + it('login with provider should call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function() { - ok(false, "logIn should not have succeeded"); - }, - error: function(model, error) { - ok(error === null, "Error should be null"); - done(); - } + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('authData')).toBeDefined(); + expect(req.object.get('name')).toBe('tupac shakur'); }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ name: 'tupac shakur' }); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + expect(hit).toBe(1); + done(); }); - it("login with provider should not call beforeSave trigger", (done) => { - var provider = getMockFacebookProvider(); + it('incorrect login with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function() { - Parse.User.logOut().then(() => { - Parse.Cloud.beforeSave(Parse.User, function(req, res) { - res.error("Before save shouldn't be called on login"); - }); - Parse.User._logInWith("facebook", { - success: function() { - done(); - }, - error: function(model, error) { - ok(undefined, error); - done(); - } - }); - }); - } + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; }); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + provider.shouldError = true; + try { + await Parse.User._logInWith('facebook'); + } catch (e) { + expect(e).toBeDefined(); + } + expect(hit).toBe(0); + done(); }); - it("link with provider", (done) => { - var provider = getMockFacebookProvider(); + it('login with provider should be blockable by beforeLogin', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function() { - user._linkWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked"); - done(); - }, - error: function() { - ok(false, "linking should have succeeded"); - done(); - } - }); - }, - error: function() { - ok(false, "signup should not have failed"); - done(); + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); } }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ isBanned: true }); + await Parse.User.logOut(); + + try { + await Parse.User._logInWith('facebook'); + throw new Error('should not have continued login.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + + expect(hit).toBe(1); + done(); + }); + + it('logout with provider should call afterLogout trigger', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let userId; + Parse.Cloud.afterLogout(req => { + expect(req.object.className).toEqual('_Session'); + expect(req.object.id).toBeDefined(); + const user = req.object.get('user'); + expect(user).toBeDefined(); + userId = user.id; + }); + const user = await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + expect(user.id).toBe(userId); + done(); + }); + + it('link with provider', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + const model = await user._linkWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked'); + done(); }); // What this means is, only one Parse User can be linked to a // particular Facebook account. - it("link with provider for already linked user", (done) => { - var provider = getMockFacebookProvider(); + it('link with provider for already linked user', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProviderToAlreadyLinkedUser"); - user.set("password", "mypass"); - user.signUp(null, { - success: function() { - user._linkWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked."); - var user2 = new Parse.User(); - user2.set("username", "testLinkWithProviderToAlreadyLinkedUser2"); - user2.set("password", "mypass"); - user2.signUp(null, { - success: function() { - user2._linkWith('facebook', { - success: (err) => { - jfail(err); - done(); - }, - error: function(model, error) { - expect(error.code).toEqual( - Parse.Error.ACCOUNT_ALREADY_LINKED); - done(); - }, - }); - }, - error: function() { - ok(false, "linking should have failed"); - done(); - } - }); - }, - error: function() { - ok(false, "linking should have succeeded"); - done(); - } - }); - }, - error: function() { - ok(false, "signup should not have failed"); - done(); - } - }); + const user = new Parse.User(); + user.set('username', 'testLinkWithProviderToAlreadyLinkedUser'); + user.set('password', 'mypass'); + await user.signUp(); + const model = await user._linkWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked.'); + const user2 = new Parse.User(); + user2.set('username', 'testLinkWithProviderToAlreadyLinkedUser2'); + user2.set('password', 'mypass'); + await user2.signUp(); + try { + await user2._linkWith('facebook'); + done.fail(); + } catch (error) { + expect(error.code).toEqual(Parse.Error.ACCOUNT_ALREADY_LINKED); + done(); + } + }); + + it('link with provider should return sessionToken', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + const query = new Parse.Query(Parse.User); + const u2 = await query.get(user.id); + const model = await u2._linkWith('facebook', {}, { useMasterKey: true }); + expect(u2.getSessionToken()).toBeDefined(); + expect(model.getSessionToken()).toBeDefined(); + expect(u2.getSessionToken()).toBe(model.getSessionToken()); + }); + + it('link with provider via sessionToken should not create new sessionToken (Regression #5799)', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProviderNoOverride'); + user.set('password', 'mypass'); + await user.signUp(); + const sessionToken = user.getSessionToken(); + + await user._linkWith('facebook', {}, { sessionToken }); + expect(sessionToken).toBe(user.getSessionToken()); + + expect(user._isLinked(provider)).toBe(true); + await user._unlinkFrom(provider, { sessionToken }); + expect(user._isLinked(provider)).toBe(false); + + const become = await Parse.User.become(sessionToken); + expect(sessionToken).toBe(become.getSessionToken()); }); - it("link with provider failed", (done) => { - var provider = getMockFacebookProvider(); + it('link with provider failed', async done => { + const provider = getMockFacebookProvider(); provider.shouldError = true; Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function() { - user._linkWith("facebook", { - success: function() { - ok(false, "linking should fail"); - done(); - }, - error: function(model, error) { - ok(error, "Linking should fail"); - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - done(); - } - }); - }, - error: function() { - ok(false, "signup should not have failed"); - done(); - } - }); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + try { + await user._linkWith('facebook'); + done.fail(); + } catch (error) { + ok(error, 'Linking should fail'); + ok(!user._isLinked('facebook'), 'User should not be linked to facebook'); + done(); + } }); - it("link with provider cancelled", (done) => { - var provider = getMockFacebookProvider(); + it('link with provider cancelled', async done => { + const provider = getMockFacebookProvider(); provider.shouldCancel = true; Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function() { - user._linkWith("facebook", { - success: function() { - ok(false, "linking should fail"); - done(); - }, - error: function(model, error) { - ok(!error, "Linking should be cancelled"); - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - done(); - } - }); - }, - error: function() { - ok(false, "signup should not have failed"); - done(); - } - }); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + try { + await user._linkWith('facebook'); + done.fail(); + } catch (error) { + ok(!error, 'Linking should be cancelled'); + ok(!user._isLinked('facebook'), 'User should not be linked to facebook'); + done(); + } }); - it("unlink with provider", (done) => { - var provider = getMockFacebookProvider(); + it('unlink with provider', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User."); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook."); - - model._unlinkFrom("facebook", { - success: function(model) { - ok(!model._isLinked("facebook"), "User should not be linked."); - ok(!provider.synchronizedUserId, "User id should be cleared."); - ok(!provider.synchronizedAuthToken, - "Auth token should be cleared."); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared."); - done(); - }, - error: function() { - ok(false, "unlinking should succeed"); - done(); - } - }); - }, - error: function() { - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User.'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook.'); + await model._unlinkFrom('facebook'); + ok(!model._isLinked('facebook'), 'User should not be linked.'); + ok(!provider.synchronizedUserId, 'User id should be cleared.'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared.'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared.'); + done(); }); - it("unlink and link", (done) => { - var provider = getMockFacebookProvider(); - Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - model._unlinkFrom("facebook", { - success: function(model) { - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - ok(!provider.synchronizedUserId, "User id should be cleared"); - ok(!provider.synchronizedAuthToken, "Auth token should be cleared"); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared"); - - model._linkWith("facebook", { - success: function(model) { - ok(provider.synchronizedUserId, "User id should have a value"); - ok(provider.synchronizedAuthToken, - "Auth token should have a value"); - ok(provider.synchronizedExpiration, - "Expiration should have a value"); - ok(model._isLinked("facebook"), - "User should be linked to facebook"); - done(); - }, - error: function() { - ok(false, "linking again should succeed"); - done(); - } - }); - }, - error: function() { - ok(false, "unlinking should succeed"); - done(); - } - }); - }, - error: function() { - ok(false, "linking should have worked"); - done(); - } - }); + it('unlink and link', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + await model._unlinkFrom('facebook'); + ok(!model._isLinked('facebook'), 'User should not be linked to facebook'); + ok(!provider.synchronizedUserId, 'User id should be cleared'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared'); + + await model._linkWith('facebook'); + ok(provider.synchronizedUserId, 'User id should have a value'); + ok(provider.synchronizedAuthToken, 'Auth token should have a value'); + ok(provider.synchronizedExpiration, 'Expiration should have a value'); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + done(); }); - it("link multiple providers", (done) => { - var provider = getMockFacebookProvider(); - var mockProvider = getMockMyOauthProvider(); + it('link multiple providers', async done => { + const provider = getMockFacebookProvider(); + const mockProvider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - Parse.User._registerAuthenticationProvider(mockProvider); - const objectId = model.id; - model._linkWith("myoauth", { - success: function(model) { - expect(model.id).toEqual(objectId); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - done(); - }, - error: function(error) { - jfail(error); - fail('SHould not fail'); - done(); - } - }) - }, - error: function() { - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + expect(model.id).toEqual(objectId); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + done(); }); - it("link multiple providers and updates token", (done) => { - var provider = getMockFacebookProvider(); - var secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); + it('link multiple providers and updates token', async done => { + const provider = getMockFacebookProvider(); + const secondProvider = getMockFacebookProviderWithIdToken( + '8675309', + 'jenny_valid_token' + ); - var errorHandler = function(model, error) { - jfail(error); - fail('Should not fail'); - done(); - } - var mockProvider = getMockMyOauthProvider(); + const mockProvider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - Parse.User._registerAuthenticationProvider(mockProvider); - const objectId = model.id; - model._linkWith("myoauth", { - success: function() { - Parse.User._registerAuthenticationProvider(secondProvider); - Parse.User.logOut().then(() => { - return Parse.User._logInWith("facebook", { - success: () => { - Parse.User.logOut().then(() => { - return Parse.User._logInWith("myoauth", { - success: (user) => { - expect(user.id).toBe(objectId); - done(); - } - }) - }) - }, - error: errorHandler - }); - }) - }, - error: errorHandler - }) - }, - error: errorHandler - }); + const model = await Parse.User._logInWith('facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + Parse.User._registerAuthenticationProvider(secondProvider); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + const user = await Parse.User._logInWith('myoauth'); + expect(user.id).toBe(objectId); + done(); }); - it("link multiple providers and update token", (done) => { - var provider = getMockFacebookProvider(); - var mockProvider = getMockMyOauthProvider(); + it('link multiple providers and update token', async done => { + const provider = getMockFacebookProvider(); + const mockProvider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - Parse.User._registerAuthenticationProvider(mockProvider); - const objectId = model.id; - model._linkWith("myoauth", { - success: function(model) { - expect(model.id).toEqual(objectId); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - model._linkWith("facebook", { - success: () => { - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - done(); - }, - error: () => { - fail('should link again'); - done(); - } - }) - }, - error: function(error) { - jfail(error); - fail('SHould not fail'); - done(); - } - }) - }, - error: function() { - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + expect(model.id).toEqual(objectId); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + await model._linkWith('facebook'); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + done(); }); - it('should fail linking with existing', (done) => { - var provider = getMockFacebookProvider(); + it('should fail linking with existing', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function() { - Parse.User.logOut().then(() => { - const user = new Parse.User(); - user.setUsername('user'); - user.setPassword('password'); - return user.signUp().then(() => { - // try to link here - user._linkWith('facebook', { - success: () => { - fail('should not succeed'); - done(); - }, - error: () => { - done(); - } - }); - }); - }); - } - }); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('password'); + await user.signUp(); + // try to link here + try { + await user._linkWith('facebook'); + done.fail(); + } catch (e) { + done(); + } }); - it('should fail linking with existing', (done) => { - var provider = getMockFacebookProvider(); + it('should fail linking with existing through REST', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - const userId = model.id; - Parse.User.logOut().then(() => { - request.post({ - url:Parse.serverURL + '/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest' - }, - json: {authData: {facebook: provider.authData}} - }, (err,res, body) => { - // make sure the location header is properly set - expect(userId).not.toBeUndefined(); - expect(body.objectId).toEqual(userId); - expect(res.headers.location).toEqual(Parse.serverURL + '/users/' + userId); - done(); - }); - }); - } + const model = await Parse.User._logInWith('facebook'); + const userId = model.id; + Parse.User.logOut().then(() => { + request({ + method: 'POST', + url: Parse.serverURL + '/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { authData: { facebook: provider.authData } }, + }).then(response => { + const body = response.data; + // make sure the location header is properly set + expect(userId).not.toBeUndefined(); + expect(body.objectId).toEqual(userId); + expect(response.headers.location).toEqual( + Parse.serverURL + '/users/' + userId + ); + done(); + }); }); }); - it('should allow login with old authData token', (done) => { + it('should allow login with old authData token', done => { const provider = { authData: { id: '12345', - access_token: 'token' + access_token: 'token', }, restoreAuthentication: function() { return true; @@ -1764,29 +1851,36 @@ describe('Parse.User testing', () => { options.success(this, provider.authData); }, getAuthType: function() { - return "shortLivedAuth"; - } - } + return 'shortLivedAuth'; + }, + }; defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("shortLivedAuth", {}).then(() => { - // Simulate a remotely expired token (like a short lived one) - // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out - defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); - return Parse.User._logInWith("shortLivedAuth", {}); - }).then(() => { - done(); - }, (err) => { - done.fail(err); - }); + Parse.User._logInWith('shortLivedAuth', {}) + .then(() => { + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken( + 'otherToken' + ); + return Parse.User._logInWith('shortLivedAuth', {}); + }) + .then( + () => { + done(); + }, + err => { + done.fail(err); + } + ); }); - it('should allow PUT request with stale auth Data', (done) => { + it('should allow PUT request with stale auth Data', done => { const provider = { authData: { id: '12345', - access_token: 'token' + access_token: 'token', }, restoreAuthentication: function() { return true; @@ -1798,67 +1892,79 @@ describe('Parse.User testing', () => { options.success(this, provider.authData); }, getAuthType: function() { - return "shortLivedAuth"; - } - } + return 'shortLivedAuth'; + }, + }; defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("shortLivedAuth", {}).then(() => { - // Simulate a remotely expired token (like a short lived one) - // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out - defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); - return rp.put({ - url: Parse.serverURL + '/users/' + Parse.User.current().id, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Javascript-Key': Parse.javaScriptKey, - 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), - 'Content-Type': 'application/json' + Parse.User._logInWith('shortLivedAuth', {}) + .then(() => { + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken( + 'otherToken' + ); + return request({ + method: 'PUT', + url: Parse.serverURL + '/users/' + Parse.User.current().id, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), + 'Content-Type': 'application/json', + }, + body: { + key: 'value', // update a key + authData: { + // pass the original auth data + shortLivedAuth: { + id: '12345', + access_token: 'token', + }, + }, + }, + }); + }) + .then( + () => { + done(); }, - json: { - key: 'value', // update a key - authData: { // pass the original auth data - shortLivedAuth: { - id: '12345', - access_token: 'token' - } - } + err => { + done.fail(err); } - }) - }).then(() => { - done(); - }, (err) => { - done.fail(err); - }); + ); }); - it('should properly error when password is missing', (done) => { - var provider = getMockFacebookProvider(); + it('should properly error when password is missing', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(user) { - user.set('username', 'myUser'); - user.set('email', 'foo@example.com'); - user.save().then(() => { - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn('myUser', 'password'); - }).then(() => { + const user = await Parse.User._logInWith('facebook'); + user.set('username', 'myUser'); + user.set('email', 'foo@example.com'); + user + .save() + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('myUser', 'password'); + }) + .then( + () => { fail('should not succeed'); done(); - }, (err) => { + }, + err => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); expect(err.message).toEqual('Invalid username/password.'); done(); - }) - } - }); + } + ); }); - it('should have authData in beforeSave and afterSave', (done) => { - - Parse.Cloud.beforeSave('_User', (request, response) => { + it('should have authData in beforeSave and afterSave', async done => { + Parse.Cloud.beforeSave('_User', request => { const authData = request.object.get('authData'); expect(authData).not.toBeUndefined(); if (authData) { @@ -1867,10 +1973,9 @@ describe('Parse.User testing', () => { } else { fail('authData should be set'); } - response.success(); }); - Parse.Cloud.afterSave('_User', (request, response) => { + Parse.Cloud.afterSave('_User', request => { const authData = request.object.get('authData'); expect(authData).not.toBeUndefined(); if (authData) { @@ -1879,1268 +1984,1600 @@ describe('Parse.User testing', () => { } else { fail('authData should be set'); } - response.success(); }); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function() { - done(); - } - }); + await Parse.User._logInWith('facebook'); + done(); }); - it('set password then change password', (done) => { - Parse.User.signUp('bob', 'barker').then((bob) => { - bob.setPassword('meower'); - return bob.save(); - }).then(() => { - return Parse.User.logIn('bob', 'meower'); - }).then((bob) => { - expect(bob.getUsername()).toEqual('bob'); - done(); - }, (e) => { - console.log(e); - fail(); - }); + it('set password then change password', done => { + Parse.User.signUp('bob', 'barker') + .then(bob => { + bob.setPassword('meower'); + return bob.save(); + }) + .then(() => { + return Parse.User.logIn('bob', 'meower'); + }) + .then( + bob => { + expect(bob.getUsername()).toEqual('bob'); + done(); + }, + e => { + console.log(e); + fail(); + } + ); }); - it("authenticated check", (done) => { - var user = new Parse.User(); - user.set("username", "darkhelmet"); - user.set("password", "onetwothreefour"); + it('authenticated check', async done => { + const user = new Parse.User(); + user.set('username', 'darkhelmet'); + user.set('password', 'onetwothreefour'); ok(!user.authenticated()); - user.signUp(null, expectSuccess({ - success: function() { - ok(user.authenticated()); - done(); - } - })); + await user.signUp(null); + ok(user.authenticated()); + done(); }); - it("log in with explicit facebook auth data", (done) => { - Parse.FacebookUtils.logIn({ - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }, expectSuccess({success: done})); + it('log in with explicit facebook auth data', async done => { + await Parse.FacebookUtils.logIn({ + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }); + done(); }); - it("log in async with explicit facebook auth data", (done) => { + it('log in async with explicit facebook auth data', done => { Parse.FacebookUtils.logIn({ - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(function() { - done(); - }, function(error) { - ok(false, error); + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then( + function() { + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); + }); + + it('link with explicit facebook auth data', async done => { + const user = await Parse.User.signUp('mask', 'open sesame'); + Parse.FacebookUtils.link(user, { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then(done, error => { + jfail(error); done(); }); }); - it("link with explicit facebook auth data", (done) => { - Parse.User.signUp("mask", "open sesame", null, expectSuccess({ - success: function(user) { - Parse.FacebookUtils.link(user, { - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(done, (error) => { - jfail(error); - done(); - }); + it('link async with explicit facebook auth data', async done => { + const user = await Parse.User.signUp('mask', 'open sesame'); + Parse.FacebookUtils.link(user, { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then( + function() { + done(); + }, + function(error) { + ok(false, error); + done(); } - })); + ); }); - it("link async with explicit facebook auth data", (done) => { - Parse.User.signUp("mask", "open sesame", null, expectSuccess({ - success: function(user) { - Parse.FacebookUtils.link(user, { - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); - }); - } - })); - }); - - it("async methods", (done) => { - var data = { foo: "bar" }; - - Parse.User.signUp("finn", "human", data).then(function(user) { - equal(Parse.User.current(), user); - equal(user.get("foo"), "bar"); - return Parse.User.logOut(); - }).then(function() { - return Parse.User.logIn("finn", "human"); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "bar"); - return Parse.User.logOut(); - }).then(function() { - var user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - user.set("foo", "baz"); - return user.signUp(); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "baz"); - user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - return user.logIn(); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "baz"); - var userAgain = new Parse.User(); - userAgain.id = user.id; - return userAgain.fetch(); - }).then(function(userAgain) { - equal(userAgain.get("foo"), "baz"); - done(); - }); - }); + it('async methods', done => { + const data = { foo: 'bar' }; - it("querying for users doesn't get session tokens", (done) => { - Parse.User.signUp("finn", "human", { foo: "bar" }) + Parse.User.signUp('finn', 'human', data) + .then(function(user) { + equal(Parse.User.current(), user); + equal(user.get('foo'), 'bar'); + return Parse.User.logOut(); + }) .then(function() { + return Parse.User.logIn('finn', 'human'); + }) + .then(function(user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'bar'); return Parse.User.logOut(); - }).then(() => { - var user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - user.set("foo", "baz"); + }) + .then(function() { + const user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + user.set('foo', 'baz'); return user.signUp(); - - }).then(function() { - return Parse.User.logOut(); - }).then(() => { - var query = new Parse.Query(Parse.User); - return query.find(); - - }).then(function(users) { - equal(users.length, 2); - for (var user of users) { - ok(!user.getSessionToken(), "user should not have a session token."); - } - - done(); - }, function(error) { - ok(false, error); + }) + .then(function(user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'baz'); + user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + return user.logIn(); + }) + .then(function(user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'baz'); + const userAgain = new Parse.User(); + userAgain.id = user.id; + return userAgain.fetch(); + }) + .then(function(userAgain) { + equal(userAgain.get('foo'), 'baz'); done(); }); }); - it("querying for users only gets the expected fields", (done) => { - Parse.User.signUp("finn", "human", { foo: "bar" }) + it("querying for users doesn't get session tokens", done => { + Parse.User.signUp('finn', 'human', { foo: 'bar' }) + .then(function() { + return Parse.User.logOut(); + }) .then(() => { - request.get({ - headers: {'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, - url: 'http://localhost:8378/1/users', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.results.length).toEqual(1); - var user = b.results[0]; - expect(Object.keys(user).length).toEqual(6); + const user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + user.set('foo', 'baz'); + return user.signUp(); + }) + .then(function() { + return Parse.User.logOut(); + }) + .then(() => { + const query = new Parse.Query(Parse.User); + return query.find({ sessionToken: null }); + }) + .then( + function(users) { + equal(users.length, 2); + users.forEach(user => { + expect(user.getSessionToken()).toBeUndefined(); + ok( + !user.getSessionToken(), + 'user should not have a session token.' + ); + }); done(); - }); + }, + function(error) { + ok(false, error); + done(); + } + ); + }); + + it('querying for users only gets the expected fields', done => { + Parse.User.signUp('finn', 'human', { foo: 'bar' }).then(() => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/users', + }).then(response => { + const b = response.data; + expect(b.results.length).toEqual(1); + const user = b.results[0]; + expect(Object.keys(user).length).toEqual(6); + done(); }); + }); }); - it('retrieve user data from fetch, make sure the session token hasn\'t changed', (done) => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - var currentSessionToken = ""; - Parse.Promise.as().then(function() { - return user.signUp(); - }).then(function(){ - currentSessionToken = user.getSessionToken(); - return user.fetch(); - }).then(function(u){ - expect(currentSessionToken).toEqual(u.getSessionToken()); - done(); - }, function(error) { - ok(false, error); - done(); - }) + it("retrieve user data from fetch, make sure the session token hasn't changed", done => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + let currentSessionToken = ''; + Promise.resolve() + .then(function() { + return user.signUp(); + }) + .then(function() { + currentSessionToken = user.getSessionToken(); + return user.fetch(); + }) + .then( + function(u) { + expect(currentSessionToken).toEqual(u.getSessionToken()); + done(); + }, + function(error) { + ok(false, error); + done(); + } + ); }); - it('user save should fail with invalid email', (done) => { - var user = new Parse.User(); + it('user save should fail with invalid email', done => { + const user = new Parse.User(); user.set('username', 'teste'); user.set('password', 'test'); user.set('email', 'invalid'); - user.signUp().then(() => { - fail('Should not have been able to save.'); - done(); - }, (error) => { - expect(error.code).toEqual(125); - done(); - }); + user.signUp().then( + () => { + fail('Should not have been able to save.'); + done(); + }, + error => { + expect(error.code).toEqual(125); + done(); + } + ); }); - it('user signup should error if email taken', (done) => { - var user = new Parse.User(); + it('user signup should error if email taken', done => { + const user = new Parse.User(); user.set('username', 'test1'); user.set('password', 'test'); user.set('email', 'test@test.com'); - user.signUp().then(() => { - var user2 = new Parse.User(); - user2.set('username', 'test2'); - user2.set('password', 'test'); - user2.set('email', 'test@test.com'); - return user2.signUp(); - }).then(() => { - fail('Should not have been able to sign up.'); - done(); - }, () => { - done(); - }); + user + .signUp() + .then(() => { + const user2 = new Parse.User(); + user2.set('username', 'test2'); + user2.set('password', 'test'); + user2.set('email', 'test@test.com'); + return user2.signUp(); + }) + .then( + () => { + fail('Should not have been able to sign up.'); + done(); + }, + () => { + done(); + } + ); }); - it('user cannot update email to existing user', (done) => { - var user = new Parse.User(); - user.set('username', 'test1'); - user.set('password', 'test'); - user.set('email', 'test@test.com'); - user.signUp().then(() => { - var user2 = new Parse.User(); - user2.set('username', 'test2'); + describe('case insensitive signup not allowed', () => { + it('signup should fail with duplicate case insensitive username with basic setter', async () => { + const user = new Parse.User(); + user.set('username', 'test1'); + user.set('password', 'test'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.set('username', 'Test1'); user2.set('password', 'test'); - return user2.signUp(); - }).then((user2) => { - user2.set('email', 'test@test.com'); - return user2.save(); - }).then(() => { - fail('Should not have been able to sign up.'); - done(); - }, () => { - done(); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ) + ); }); - }); - it('unset user email', (done) => { - var user = new Parse.User(); - user.set('username', 'test'); - user.set('password', 'test'); - user.set('email', 'test@test.com'); - user.signUp().then(() => { - user.unset('email'); - return user.save(); - }).then(() => { - return Parse.User.logIn('test', 'test'); - }).then((user) => { - expect(user.getEmail()).toBeUndefined(); - done(); + it('signup should fail with duplicate case insensitive username with field specific setter', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('Test1'); + user2.setPassword('test'); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ) + ); + }); + + it('signup should fail with duplicate case insensitive email', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + user.setEmail('test@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('test2'); + user2.setPassword('test'); + user2.setEmail('Test@Example.Com'); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ) + ); + }); + + it('edit should fail with duplicate case insensitive email', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + user.setEmail('test@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('test2'); + user2.setPassword('test'); + user2.setEmail('Foo@Example.Com'); + await user2.signUp(); + + user2.setEmail('Test@Example.Com'); + await expectAsync(user2.save()).toBeRejectedWith( + new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ) + ); + }); + + describe('anonymous users', () => { + beforeEach(() => { + const insensitiveCollisions = [ + 'abcdefghijklmnop', + 'Abcdefghijklmnop', + 'ABcdefghijklmnop', + 'ABCdefghijklmnop', + 'ABCDefghijklmnop', + 'ABCDEfghijklmnop', + 'ABCDEFghijklmnop', + 'ABCDEFGhijklmnop', + 'ABCDEFGHijklmnop', + 'ABCDEFGHIjklmnop', + 'ABCDEFGHIJklmnop', + 'ABCDEFGHIJKlmnop', + 'ABCDEFGHIJKLmnop', + 'ABCDEFGHIJKLMnop', + 'ABCDEFGHIJKLMnop', + 'ABCDEFGHIJKLMNop', + 'ABCDEFGHIJKLMNOp', + 'ABCDEFGHIJKLMNOP', + ]; + + // need a bunch of spare random strings per api request + spyOn(cryptoUtils, 'randomString').and.returnValues( + ...insensitiveCollisions + ); + }); + + it('should not fail on case insensitive matches', async () => { + const user1 = await Parse.AnonymousUtils.logIn(); + const username1 = user1.get('username'); + + const user2 = await Parse.AnonymousUtils.logIn(); + const username2 = user2.get('username'); + + expect(username1).not.toBeUndefined(); + expect(username2).not.toBeUndefined(); + expect(username1.toLowerCase()).toBe('abcdefghijklmnop'); + expect(username2.toLowerCase()).toBe('abcdefghijklmnop'); + expect(username2).not.toBe(username1); + expect(username2.toLowerCase()).toBe(username1.toLowerCase()); // this is redundant :). + }); }); }); - it('create session from user', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.post({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + it('user cannot update email to existing user', done => { + const user = new Parse.User(); + user.set('username', 'test1'); + user.set('password', 'test'); + user.set('email', 'test@test.com'); + user + .signUp() + .then(() => { + const user2 = new Parse.User(); + user2.set('username', 'test2'); + user2.set('password', 'test'); + return user2.signUp(); + }) + .then(user2 => { + user2.set('email', 'test@test.com'); + return user2.save(); + }) + .then( + () => { + fail('Should not have been able to sign up.'); + done(); }, - url: 'http://localhost:8378/1/sessions', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('create'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); + () => { + done(); + } + ); + }); + + it('unset user email', done => { + const user = new Parse.User(); + user.set('username', 'test'); + user.set('password', 'test'); + user.set('email', 'test@test.com'); + user + .signUp() + .then(() => { + user.unset('email'); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('test', 'test'); + }) + .then(user => { + expect(user.getEmail()).toBeUndefined(); done(); }); - }); }); - it('user get session from token on signup', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('signup'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); - done(); + it('create session from user', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(user => { + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + expect(typeof b.sessionToken).toEqual('string'); + expect(typeof b.createdWith).toEqual('object'); + expect(b.createdWith.action).toEqual('create'); + expect(typeof b.user).toEqual('object'); + expect(b.user.objectId).toEqual(user.id); + done(); + }); }); - }); }); - it('user get session from token on login', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then(() => { - return Parse.User.logOut().then(() => { - return Parse.User.logIn("finn", "human"); + it('user get session from token on signup', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); }) - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('login'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); - done(); + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + expect(typeof b.sessionToken).toEqual('string'); + expect(typeof b.createdWith).toEqual('object'); + expect(b.createdWith.action).toEqual('signup'); + expect(typeof b.user).toEqual('object'); + expect(b.user.objectId).toEqual(user.id); + done(); + }); }); - }); }); - it('user update session with other field', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.put({ + it('user get session from token on login', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.logOut().then(() => { + return Parse.User.logIn('finn', 'human'); + }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + b.objectId, - body: JSON.stringify({ foo: 'bar' }) - }, (error, response, body) => { - expect(error).toBe(null); - JSON.parse(body); + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + expect(typeof b.sessionToken).toEqual('string'); + expect(typeof b.createdWith).toEqual('object'); + expect(b.createdWith.action).toEqual('login'); + expect(typeof b.user).toEqual('object'); + expect(b.user.objectId).toEqual(user.id); done(); }); }); - }); }); - it('cannot update session if invalid or no session token', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.put({ + it('user update session with other field', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': 'foo', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + b.objectId, - body: JSON.stringify({ foo: 'bar' }) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.error).toBe('invalid session token'); - request.put({ + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + request({ + method: 'PUT', headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', }, url: 'http://localhost:8378/1/sessions/' + b.objectId, - body: JSON.stringify({ foo: 'bar' }) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.error).toBe('Session token required.'); + body: JSON.stringify({ foo: 'bar' }), + }).then(() => { done(); }); }); }); - }); }); - it('get session only for current user', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("test1", "test", { foo: "bar" }); - }).then(() => { - return Parse.User.signUp("test2", "test", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - try { - var b = JSON.parse(body); + it('cannot update session if invalid or no session token', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + request({ + method: 'PUT', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': 'foo', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/sessions/' + b.objectId, + body: JSON.stringify({ foo: 'bar' }), + }).then(fail, response => { + const b = response.data; + expect(b.error).toBe('Invalid session token'); + request({ + method: 'PUT', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/' + b.objectId, + body: JSON.stringify({ foo: 'bar' }), + }).then(fail, response => { + const b = response.data; + expect(b.error).toBe('Session token required.'); + done(); + }); + }); + }); + }); + }); + + it('get session only for current user', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; expect(b.results.length).toEqual(1); expect(typeof b.results[0].user).toEqual('object'); expect(b.results[0].user.objectId).toEqual(user.id); - } catch(e) { - jfail(e); - } - done(); + done(); + }); }); - }); }); - it('delete session by object', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("test1", "test", { foo: "bar" }); - }).then(() => { - return Parse.User.signUp("test2", "test", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var objId; - try { - var b = JSON.parse(body); - expect(b.results.length).toEqual(1); - objId = b.results[0].objectId; - } catch(e) { - jfail(e); - done(); - return; - } - request.del({ + it('delete session by object', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + objId - }, (error) => { - expect(error).toBe(null); - request.get({ + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + let objId; + try { + expect(b.results.length).toEqual(1); + objId = b.results[0].objectId; + } catch (e) { + jfail(e); + done(); + return; + } + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.code).toEqual(209); - expect(b.error).toBe('invalid session token'); - done(); + url: 'http://localhost:8378/1/sessions/' + objId, + }).then(() => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(209); + expect(b.error).toBe('Invalid session token'); + done(); + }); }); }); }); - }); }); - it('cannot delete session if no sessionToken', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("test1", "test", { foo: "bar" }); - }).then(() => { - return Parse.User.signUp("test2", "test", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var objId; - try { - var b = JSON.parse(body); - expect(b.results.length).toEqual(1); - objId = b.results[0].objectId; - } catch(e) { - jfail(e); - done(); - return; - } - request.del({ + it('cannot delete session if no sessionToken', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + objId - }, (error,response,body) => { - var b = JSON.parse(body); - expect(b.code).toEqual(209); - expect(b.error).toBe('invalid session token'); - done(); + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + expect(b.results.length).toEqual(1); + const objId = b.results[0].objectId; + request({ + method: 'DELETE', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/' + objId, + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(209); + expect(b.error).toBe('Invalid session token'); + done(); + }); }); }); - }); }); - it('password format matches hosted parse', (done) => { - var hashed = '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; - passwordCrypto.compare('test', hashed) - .then((pass) => { + it('password format matches hosted parse', done => { + const hashed = + '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; + passwordCrypto.compare('test', hashed).then( + pass => { expect(pass).toBe(true); done(); - }, () => { + }, + () => { fail('Password format did not match.'); done(); - }); + } + ); }); - it('changing password clears sessions', (done) => { - var sessionToken = null; - - Parse.Promise.as().then(function() { - return Parse.User.signUp("fosco", "parse"); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); - sessionToken = newUser.getSessionToken(); - ok(sessionToken); - newUser.set('password', 'facebook'); - return newUser.save(); - }).then(function() { - return Parse.User.become(sessionToken); - }).then(function() { - fail('Session should have been invalidated'); - done(); - }, function(err) { - expect(err.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); - expect(err.message).toBe('invalid session token'); - done(); - }); + it('changing password clears sessions', done => { + let sessionToken = null; + + Promise.resolve() + .then(function() { + return Parse.User.signUp('fosco', 'parse'); + }) + .then(function(newUser) { + equal(Parse.User.current(), newUser); + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + newUser.set('password', 'facebook'); + return newUser.save(); + }) + .then(function() { + return Parse.User.become(sessionToken); + }) + .then( + function() { + fail('Session should have been invalidated'); + done(); + }, + function(err) { + expect(err.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(err.message).toBe('Invalid session token'); + done(); + } + ); }); - it('test parse user become', (done) => { - var sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp("flessard", "folo",{'foo':1}); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); - sessionToken = newUser.getSessionToken(); - ok(sessionToken); - newUser.set('foo',2); - return newUser.save(); - }).then(function() { - return Parse.User.become(sessionToken); - }).then(function(newUser) { - equal(newUser.get('foo'), 2); - done(); - }, function() { - fail('The session should still be valid'); - done(); - }); + it('test parse user become', done => { + let sessionToken = null; + Promise.resolve() + .then(function() { + return Parse.User.signUp('flessard', 'folo', { foo: 1 }); + }) + .then(function(newUser) { + equal(Parse.User.current(), newUser); + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + newUser.set('foo', 2); + return newUser.save(); + }) + .then(function() { + return Parse.User.become(sessionToken); + }) + .then( + function(newUser) { + equal(newUser.get('foo'), 2); + done(); + }, + function() { + fail('The session should still be valid'); + done(); + } + ); }); - it('ensure logout works', (done) => { - var user = null; - var sessionToken = null; + it('ensure logout works', done => { + let user = null; + let sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp('log', 'out'); - }).then((newUser) => { - user = newUser; - sessionToken = user.getSessionToken(); - return Parse.User.logOut(); - }).then(() => { - user.set('foo', 'bar'); - return user.save(null, { sessionToken: sessionToken }); - }).then(() => { - fail('Save should have failed.'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.INVALID_SESSION_TOKEN); - done(); - }); + Promise.resolve() + .then(function() { + return Parse.User.signUp('log', 'out'); + }) + .then(newUser => { + user = newUser; + sessionToken = user.getSessionToken(); + return Parse.User.logOut(); + }) + .then(() => { + user.set('foo', 'bar'); + return user.save(null, { sessionToken: sessionToken }); + }) + .then( + () => { + fail('Save should have failed.'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.INVALID_SESSION_TOKEN); + done(); + } + ); }); - it('support user/password signup with empty authData block', (done) => { + it('support user/password signup with empty authData block', done => { // The android SDK can send an empty authData object along with username and password. - Parse.User.signUp('artof', 'thedeal', { authData: {} }).then(() => { - done(); - }, () => { - fail('Signup should have succeeded.'); + Parse.User.signUp('artof', 'thedeal', { authData: {} }).then( + () => { + done(); + }, + () => { + fail('Signup should have succeeded.'); + done(); + } + ); + }); + + it('session expiresAt correct format', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(response => { + const body = response.data; + expect(body.results[0].expiresAt.__type).toEqual('Date'); done(); }); }); - it("session expiresAt correct format", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function() { - request.get({ - url: 'http://localhost:8378/1/classes/_Session', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }, (error, response, body) => { - expect(body.results[0].expiresAt.__type).toEqual('Date'); - done(); - }) - } + it('Invalid session tokens are rejected', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + url: 'http://localhost:8378/1/classes/AClass', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': 'text', + }, + }).then(fail, response => { + const body = response.data; + expect(body.code).toBe(209); + expect(body.error).toBe('Invalid session token'); + done(); }); }); - it("invalid session tokens are rejected", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function() { - request.get({ - url: 'http://localhost:8378/1/classes/AClass', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Rest-API-Key': 'rest', - 'X-Parse-Session-Token': 'text' + it_exclude_dbs(['postgres'])( + 'should cleanup null authData keys (regression test for #935)', + done => { + const database = Config.get(Parse.applicationId).database; + database + .create( + '_User', + { + username: 'user', + _hashed_password: + '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _auth_data_facebook: null, }, - }, (error, response, body) => { - expect(body.code).toBe(209); - expect(body.error).toBe('invalid session token'); + {} + ) + .then(() => { + return request({ + url: 'http://localhost:8378/1/login?username=user&password=test', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(res => res.data); + }) + .then(user => { + const authData = user.authData; + expect(user.username).toEqual('user'); + expect(authData).toBeUndefined(); done(); }) - } - }); - }); + .catch(() => { + fail('this should not fail'); + done(); + }); + } + ); - it_exclude_dbs(['postgres'])('should cleanup null authData keys (regression test for #935)', (done) => { + it_exclude_dbs(['postgres'])('should not serve null authData keys', done => { const database = Config.get(Parse.applicationId).database; - database.create('_User', { - username: 'user', - _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', - _auth_data_facebook: null - }, {}).then(() => { - return new Promise((resolve, reject) => { - request.get({ - url: 'http://localhost:8378/1/login?username=user&password=test', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - json: true - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }) + database + .create( + '_User', + { + username: 'user', + _hashed_password: + '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _auth_data_facebook: null, + }, + {} + ) + .then(() => { + return new Parse.Query(Parse.User) + .equalTo('username', 'user') + .first({ useMasterKey: true }); }) - }).then((user) => { - const authData = user.authData; - expect(user.username).toEqual('user'); - expect(authData).toBeUndefined(); - done(); - }).catch(() => { - fail('this should not fail'); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('should not serve null authData keys', (done) => { - const database = Config.get(Parse.applicationId).database; - database.create('_User', { - username: 'user', - _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', - _auth_data_facebook: null - }, {}).then(() => { - return new Parse.Query(Parse.User) - .equalTo('username', 'user') - .first({useMasterKey: true}); - }).then((user) => { - const authData = user.get('authData'); - expect(user.get('username')).toEqual('user'); - expect(authData).toBeUndefined(); - done(); - }).catch(() => { - fail('this should not fail'); - done(); - }) + .then(user => { + const authData = user.get('authData'); + expect(user.get('username')).toEqual('user'); + expect(authData).toBeUndefined(); + done(); + }) + .catch(() => { + fail('this should not fail'); + done(); + }); }); - it('should cleanup null authData keys ParseUser update (regression test for #1198, #2252)', (done) => { - Parse.Cloud.beforeSave('_User', (req, res) => { + it('should cleanup null authData keys ParseUser update (regression test for #1198, #2252)', done => { + Parse.Cloud.beforeSave('_User', req => { req.object.set('foo', 'bar'); - res.success(); }); let originalSessionToken; let originalUserId; // Simulate anonymous user save - new Promise((resolve, reject) => { - request.post({ - url: 'http://localhost:8378/1/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }); - }).then((user) => { - originalSessionToken = user.sessionToken; - originalUserId = user.objectId; - // Simulate registration - return new Promise((resolve, reject) => { - request.put({ + }, + }) + .then(response => response.data) + .then(user => { + originalSessionToken = user.sessionToken; + originalUserId = user.objectId; + // Simulate registration + return request({ + method: 'PUT', url: 'http://localhost:8378/1/classes/_User/' + user.objectId, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Session-Token': user.sessionToken, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: { - authData: {anonymous: null}, + body: { + authData: { anonymous: null }, username: 'user', password: 'password', - } - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } + }, + }).then(response => { + return response.data; }); - }); - }).then((user) => { - expect(typeof user).toEqual('object'); - expect(user.authData).toBeUndefined(); - expect(user.sessionToken).not.toBeUndefined(); - // Session token should have changed - expect(user.sessionToken).not.toEqual(originalSessionToken); - // test that the sessionToken is valid - return new Promise((resolve, reject) => { - request.get({ + }) + .then(user => { + expect(typeof user).toEqual('object'); + expect(user.authData).toBeUndefined(); + expect(user.sessionToken).not.toBeUndefined(); + // Session token should have changed + expect(user.sessionToken).not.toEqual(originalSessionToken); + // test that the sessionToken is valid + return request({ url: 'http://localhost:8378/1/users/me', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Session-Token': user.sessionToken, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true - }, (err, res, body) => { + }).then(response => { + const body = response.data; expect(body.username).toEqual('user'); expect(body.objectId).toEqual(originalUserId); - if (err) { - reject(err); - } else { - resolve(body); - } done(); }); + }) + .catch(err => { + fail('no request should fail: ' + JSON.stringify(err)); + done(); }); - }).catch((err) => { - fail('no request should fail: ' + JSON.stringify(err)); - done(); - }); }); - it('should send email when upgrading from anon', (done) => { - + it('should send email when upgrading from anon', done => { let emailCalled = false; let emailOptions; - var emailAdapter = { - sendVerificationEmail: (options) => { + const emailAdapter = { + sendVerificationEmail: options => { emailOptions = options; emailCalled = true; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) + publicServerURL: 'http://localhost:8378/1', + }); // Simulate anonymous user save - return rp.post({ + return request({ + method: 'POST', url: 'http://localhost:8378/1/classes/_User', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }).then((user) => { - return rp.put({ - url: 'http://localhost:8378/1/classes/_User/' + user.objectId, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Session-Token': user.sessionToken, - 'X-Parse-REST-API-Key': 'rest', + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, }, - json: { - authData: {anonymous: null}, - username: 'user', - email: 'user@email.com', - password: 'password', - } + }, + }) + .then(response => { + const user = response.data; + return request({ + method: 'PUT', + url: 'http://localhost:8378/1/classes/_User/' + user.objectId, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': user.sessionToken, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { anonymous: null }, + username: 'user', + email: 'user@email.com', + password: 'password', + }, + }); + }) + .then(() => { + expect(emailCalled).toBe(true); + expect(emailOptions).not.toBeUndefined(); + expect(emailOptions.user.get('email')).toEqual('user@email.com'); + done(); + }) + .catch(err => { + jfail(err); + fail('no request should fail: ' + JSON.stringify(err)); + done(); }); - }).then(() => { - expect(emailCalled).toBe(true); - expect(emailOptions).not.toBeUndefined(); - expect(emailOptions.user.get('email')).toEqual('user@email.com'); - done(); - }).catch((err) => { - jfail(err); - fail('no request should fail: ' + JSON.stringify(err)); - done(); - }); }); - it('should not send email when email is not a string', (done) => { + it('should not send email when email is not a string', async done => { let emailCalled = false; let emailOptions; - var emailAdapter = { - sendVerificationEmail: (options) => { + const emailAdapter = { + sendVerificationEmail: options => { emailOptions = options; emailCalled = true; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - reconfigureServer({ + sendMail: () => Promise.resolve(), + }; + await reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, publicServerURL: 'http://localhost:8378/1', }); - var user = new Parse.User(); + const user = new Parse.User(); user.set('username', 'asdf@jkl.com'); user.set('password', 'zxcv'); user.set('email', 'asdf@jkl.com'); - user.signUp(null, { - success: (user) => { - return rp.post({ - url: 'http://localhost:8378/1/requestPasswordReset', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Session-Token': user.sessionToken, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { - email: {"$regex":"^asd"}, - } - }).then((res) => { - fail('no request should succeed: ' + JSON.stringify(res)); - done(); - }).catch((err) => { - expect(emailCalled).toBeTruthy(); - expect(emailOptions).toBeDefined(); - expect(err.statusCode).toBe(400); - expect(err.message).toMatch('{"code":125,"error":"you must provide a valid email string"}'); - done(); - }); + await user.signUp(); + request({ + method: 'POST', + url: 'http://localhost:8378/1/requestPasswordReset', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': user.sessionToken, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - }); + body: { + email: { $regex: '^asd' }, + }, + }) + .then(res => { + fail('no request should succeed: ' + JSON.stringify(res)); + done(); + }) + .catch(err => { + expect(emailCalled).toBeTruthy(); + expect(emailOptions).toBeDefined(); + expect(err.status).toBe(400); + expect(err.text).toMatch( + '{"code":125,"error":"you must provide a valid email string"}' + ); + done(); + }); }); - - it('should aftersave with full object', (done) => { - var hit = 0; + it('should aftersave with full object', done => { + let hit = 0; Parse.Cloud.afterSave('_User', (req, res) => { hit++; expect(req.object.get('username')).toEqual('User'); res.success(); }); - const user = new Parse.User() + const user = new Parse.User(); user.setUsername('User'); user.setPassword('pass'); - user.signUp().then(()=> { - user.set('hello', 'world'); - return user.save(); - }).then(() => { - expect(hit).toBe(2); - done(); - }); + user + .signUp() + .then(() => { + user.set('hello', 'world'); + return user.save(); + }) + .then(() => { + expect(hit).toBe(2); + done(); + }); }); - it('changes to a user should update the cache', (done) => { - Parse.Cloud.define('testUpdatedUser', (req, res) => { + it('changes to a user should update the cache', done => { + Parse.Cloud.define('testUpdatedUser', req => { expect(req.user.get('han')).toEqual('solo'); - res.success({}); + return {}; }); const user = new Parse.User(); user.setUsername('harrison'); user.setPassword('ford'); - user.signUp().then(() => { - user.set('han', 'solo'); - return user.save(); - }).then(() => { - return Parse.Cloud.run('testUpdatedUser'); - }).then(() => { - done(); - }, () => { - fail('Should not have failed.'); - done(); - }); - + user + .signUp() + .then(() => { + user.set('han', 'solo'); + return user.save(); + }) + .then(() => { + return Parse.Cloud.run('testUpdatedUser'); + }) + .then( + () => { + done(); + }, + () => { + fail('Should not have failed.'); + done(); + } + ); }); - it('should fail to become user with expired token', (done) => { + it('should fail to become user with expired token', done => { let token; - Parse.User.signUp("auser", "somepass", null) - .then(() => rp({ - method: 'GET', - url: 'http://localhost:8378/1/classes/_Session', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - })) - .then(body => { - var id = body.results[0].objectId; - var expiresAt = new Date((new Date()).setYear(2015)); + Parse.User.signUp('auser', 'somepass', null) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + ) + .then(response => { + const body = response.data; + const id = body.results[0].objectId; + const expiresAt = new Date(new Date().setYear(2015)); token = body.results[0].sessionToken; - return rp({ + return request({ method: 'PUT', - url: "http://localhost:8378/1/classes/_Session/" + id, - json: true, + url: 'http://localhost:8378/1/classes/_Session/' + id, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', }, body: { - expiresAt: { __type: "Date", iso: expiresAt.toISOString() }, + expiresAt: { __type: 'Date', iso: expiresAt.toISOString() }, }, - }) + }); }) .then(() => Parse.User.become(token)) - .then(() => { - fail("Should not have succeded") - done(); - }, error => { - expect(error.code).toEqual(209); - expect(error.message).toEqual("Session token is expired."); - done(); - }) + .then( + () => { + fail('Should not have succeded'); + done(); + }, + error => { + expect(error.code).toEqual(209); + expect(error.message).toEqual('Session token is expired.'); + done(); + } + ) + .catch(done.fail); }); - it('should not create extraneous session tokens', (done) => { + it('should not create extraneous session tokens', done => { const config = Config.get(Parse.applicationId); - config.database.loadSchema().then((s) => { - // Lock down the _User class for creation - return s.addClassIfNotExists('_User', {}, {create: {}}) - }).then(() => { - const user = new Parse.User(); - return user.save({'username': 'user', 'password': 'pass'}); - }).then(() => { - fail('should not be able to save the user'); - }, () => { - return Promise.resolve(); - }).then(() => { - const q = new Parse.Query('_Session'); - return q.find({useMasterKey: true}) - }).then((res) => { - // We should have no session created - expect(res.length).toBe(0); - done(); - }, () => { - fail('should not fail'); - done(); - }); + config.database + .loadSchema() + .then(s => { + // Lock down the _User class for creation + return s.addClassIfNotExists('_User', {}, { create: {} }); + }) + .then(() => { + const user = new Parse.User(); + return user.save({ username: 'user', password: 'pass' }); + }) + .then( + () => { + fail('should not be able to save the user'); + }, + () => { + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('_Session'); + return q.find({ useMasterKey: true }); + }) + .then( + res => { + // We should have no session created + expect(res.length).toBe(0); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); }); - it('should not overwrite username when unlinking facebook user (regression test for #1532)', done => { + it('should not overwrite username when unlinking facebook user (regression test for #1532)', async done => { Parse.Object.disableSingleInstance(); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp() - .then(user => user._linkWith("facebook", { - success: user => { - expect(user.get('username')).toEqual('testLinkWithProvider'); - expect(Parse.FacebookUtils.isLinked(user)).toBeTruthy(); - return user._unlinkFrom('facebook') - .then(() => user.fetch()) - .then(user => { - expect(user.get('username')).toEqual('testLinkWithProvider'); - expect(Parse.FacebookUtils.isLinked(user)).toBeFalsy(); - done(); - }); - }, - error: error => { - fail('Unexpected failure testing linking'); - fail(JSON.stringify(error)); - done(); - } - })) - .catch(error => { - fail('Unexpected failure testing in unlink user test'); - jfail(error); - done(); - }); + let user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + await user._linkWith('facebook'); + expect(user.get('username')).toEqual('testLinkWithProvider'); + expect(Parse.FacebookUtils.isLinked(user)).toBeTruthy(); + await user._unlinkFrom('facebook'); + user = await user.fetch(); + expect(user.get('username')).toEqual('testLinkWithProvider'); + expect(Parse.FacebookUtils.isLinked(user)).toBeFalsy(); + done(); }); it('should revoke sessions when converting anonymous user to "normal" user', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/classes/_User', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - Parse.User.become(body.sessionToken) - .then(user => { - const obj = new Parse.Object('TestObject'); - obj.setACL(new Parse.ACL(user)); - return obj.save() - .then(() => { - // Change password, revoking session - user.set('username', 'no longer anonymous'); - user.set('password', 'password'); - return user.save() - }) - .then(() => { - // Session token should have been recycled - expect(body.sessionToken).not.toEqual(user.getSessionToken()); - }) - .then(() => obj.fetch()) - .then(() => { - done(); - }) - .catch(() => { - fail('should not fail') - done(); - }); - }) + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }).then(response => { + const body = response.data; + Parse.User.become(body.sessionToken).then(user => { + const obj = new Parse.Object('TestObject'); + obj.setACL(new Parse.ACL(user)); + return obj + .save() + .then(() => { + // Change password, revoking session + user.set('username', 'no longer anonymous'); + user.set('password', 'password'); + return user.save(); + }) + .then(() => { + // Session token should have been recycled + expect(body.sessionToken).not.toEqual(user.getSessionToken()); + }) + .then(() => obj.fetch()) + .then(() => { + done(); + }) + .catch(() => { + fail('should not fail'); + done(); + }); + }); }); }); it('should not revoke session tokens if the server is configures to not revoke session tokens', done => { - reconfigureServer({ revokeSessionOnPasswordReset: false }) - .then(() => { - request.post({ - url: 'http://localhost:8378/1/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', + reconfigureServer({ revokeSessionOnPasswordReset: false }).then(() => { + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - Parse.User.become(body.sessionToken) - .then(user => { - const obj = new Parse.Object('TestObject'); - obj.setACL(new Parse.ACL(user)); - return obj.save() - .then(() => { - // Change password, revoking session - user.set('username', 'no longer anonymous'); - user.set('password', 'password'); - return user.save() - }) - .then(() => obj.fetch()) + }, + }).then(response => { + const body = response.data; + Parse.User.become(body.sessionToken).then(user => { + const obj = new Parse.Object('TestObject'); + obj.setACL(new Parse.ACL(user)); + return ( + obj + .save() + .then(() => { + // Change password, revoking session + user.set('username', 'no longer anonymous'); + user.set('password', 'password'); + return user.save(); + }) + .then(() => obj.fetch()) // fetch should succeed as we still have our session token - .then(done, fail); - }) + .then(done, fail) + ); }); }); + }); }); it('should not fail querying non existing relations', done => { const user = new Parse.User(); user.set({ username: 'hello', - password: 'world' - }) - user.signUp().then(() => { - return Parse.User.current().relation('relation').query().find(); - }).then((res) => { - expect(res.length).toBe(0); - done(); - }).catch((err) => { - fail(JSON.stringify(err)); - done(); + password: 'world', }); + user + .signUp() + .then(() => { + return Parse.User.current() + .relation('relation') + .query() + .find(); + }) + .then(res => { + expect(res.length).toBe(0); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); it('should not allow updates to emailVerified', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => {}, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; const user = new Parse.User(); user.set({ username: 'hello', password: 'world', - email: "test@email.com" - }) + email: 'test@email.com', + }); reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - return user.signUp(); - }).then(() => { - return Parse.User.current().set('emailVerified', true).save(); - }).then(() => { - fail("Should not be able to update emailVerified"); - done(); - }).catch((err) => { - expect(err.message).toBe("Clients aren't allowed to manually update email verification."); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => { + return Parse.User.current() + .set('emailVerified', true) + .save(); + }) + .then(() => { + fail('Should not be able to update emailVerified'); + done(); + }) + .catch(err => { + expect(err.message).toBe( + "Clients aren't allowed to manually update email verification." + ); + done(); + }); }); - it('should not retrieve hidden fields', done => { - - var emailAdapter = { + it('should not retrieve hidden fields on GET users/me (#3432)', done => { + const emailAdapter = { sendVerificationEmail: () => {}, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; const user = new Parse.User(); user.set({ username: 'hello', password: 'world', - email: "test@email.com" + email: 'test@email.com', + }); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(done.fail); + }); + + it('should not retrieve hidden fields on GET users/id (#3432)', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', + }); reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - return user.signUp(); - }).then(() => rp({ - method: 'GET', - url: 'http://localhost:8378/1/users/me', - json: true, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - })).then((res) => { - expect(res.emailVerified).toBe(false); - expect(res._email_verify_token).toBeUndefined(); - done() - }).then(() => rp({ - method: 'GET', - url: 'http://localhost:8378/1/users/' + Parse.User.current().id, - json: true, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest' - }, - })).then((res) => { - expect(res.emailVerified).toBe(false); - expect(res._email_verify_token).toBeUndefined(); - done() - }).catch((err) => { - fail(JSON.stringify(err)); - done(); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/users/' + Parse.User.current().id, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should not retrieve hidden fields on login (#3432)', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', }); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + url: + 'http://localhost:8378/1/login?email=test@email.com&username=hello&password=world', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); it('should not allow updates to hidden fields', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => {}, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; const user = new Parse.User(); user.set({ username: 'hello', password: 'world', - email: "test@email.com" - }) + email: 'test@email.com', + }); reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - return user.signUp(); - }).then(() => { - return Parse.User.current().set('_email_verify_token', 'bad').save(); - }).then(() => { - fail("Should not be able to update email verification token"); - done(); - }).catch((err) => { - expect(err).toBeDefined(); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => { + return Parse.User.current() + .set('_email_verify_token', 'bad') + .save(); + }) + .then(() => { + fail('Should not be able to update email verification token'); + done(); + }) + .catch(err => { + expect(err).toBeDefined(); + done(); + }); }); - it('should revoke sessions when setting paswword with masterKey (#3289)', (done) => { + it('should revoke sessions when setting paswword with masterKey (#3289)', done => { let user; Parse.User.signUp('username', 'password') - .then((newUser) => { + .then(newUser => { user = newUser; user.set('password', 'newPassword'); - return user.save(null, {useMasterKey: true}); - }).then(() => { + return user.save(null, { useMasterKey: true }); + }) + .then(() => { const query = new Parse.Query('_Session'); query.equalTo('user', user); - return query.find({useMasterKey: true}); - }).then((results) => { + return query.find({ useMasterKey: true }); + }) + .then(results => { expect(results.length).toBe(0); done(); }, done.fail); }); - it('should not send a verification email if the user signed up using oauth', (done) => { + xit('should not send a verification email if the user signed up using oauth', done => { let emailCalledCount = 0; const emailAdapter = { sendVerificationEmail: () => { @@ -3148,242 +3585,535 @@ describe('Parse.User testing', () => { return Promise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }); const user = new Parse.User(); user.set('email', 'email1@host.com'); Parse.FacebookUtils.link(user, { - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then((user) => { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then(user => { user.set('email', 'email2@host.com'); user.save().then(() => { expect(emailCalledCount).toBe(0); done(); }); }); - }); + }).pend( + 'this test fails. See: https://github.com/parse-community/parse-server/issues/5097' + ); - it('should be able to update user with authData passed', (done) => { + it('should be able to update user with authData passed', done => { let objectId; let sessionToken; function validate(block) { - return rp.get({ + return request({ url: `http://localhost:8378/1/classes/_User/${objectId}`, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': sessionToken + 'X-Parse-Session-Token': sessionToken, }, - json: true, - }).then(block); + }).then(response => block(response.data)); } - rp.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/classes/_User', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, }, - json: { key: "value", authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }).then((body) => { - objectId = body.objectId; - sessionToken = body.sessionToken; - expect(sessionToken).toBeDefined(); - expect(objectId).toBeDefined(); - return validate((user) => { // validate that keys are set on creation - expect(user.key).toBe("value"); - }); - }).then(() => { - // update the user - const options = { - url: `http://localhost:8378/1/classes/_User/${objectId}`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': sessionToken - }, - json: { key: "otherValue", authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - } - return rp.put(options); - }).then(() => { - return validate((user) => { // validate that keys are set on update - expect(user.key).toBe("otherValue"); - }); - }).then(() => { - done(); }) + .then(response => { + const body = response.data; + objectId = body.objectId; + sessionToken = body.sessionToken; + expect(sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + return validate(user => { + // validate that keys are set on creation + expect(user.key).toBe('value'); + }); + }) + .then(() => { + // update the user + const options = { + method: 'PUT', + url: `http://localhost:8378/1/classes/_User/${objectId}`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + return request(options); + }) + .then(() => { + return validate(user => { + // validate that keys are set on update + expect(user.key).toBe('otherValue'); + }); + }) + .then(() => { + done(); + }) .then(done) .catch(done.fail); }); - it('can login with email', (done) => { + it('can login with email', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { email: "yo@lo.com", password: 'yolopass'} - } - return rp.get(options); - }).then(done).catch(done.fail); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { email: 'yo@lo.com', password: 'yolopass' }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); }); - it('cannot login with email and invalid password', (done) => { + it('cannot login with email and invalid password', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: { email: "yo@lo.com", password: 'yolopass2'} - } - return rp.get(options); - }).then(done.fail).catch(done); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + method: 'POST', + url: `http://localhost:8378/1/login`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { email: 'yo@lo.com', password: 'yolopass2' }, + }; + return request(options); + }) + .then(done.fail) + .catch(() => done()); }); - it('can login with email through query string', (done) => { + it('can login with email through query string', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?email=yo@lo.com&password=yolopass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - } - return rp.get(options); - }).then(done).catch(done.fail); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); }); - it('can login when both email and username are passed', (done) => { + it('can login when both email and username are passed', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo&password=yolopass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - } - return rp.get(options); - }).then(done).catch(done.fail); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); }); - it("fails to login when username doesn't match email", (done) => { + it("fails to login when username doesn't match email", done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo2&password=yolopass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: true, - } - return rp.get(options); - }).then(done.fail).catch((err) => { - expect(err.response.body.error).toEqual('Invalid username/password.'); - done(); - }); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo2&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('Invalid username/password.'); + done(); + }); + }); + + it("fails to login when email doesn't match username", done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo2.com&username=yolo&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('Invalid username/password.'); + done(); + }); + }); + + it('fails to login when email and username are not provided', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('username/email is required.'); + done(); + }); }); - it("fails to login when email doesn't match username", (done) => { + it('allows login when providing email as username', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + return Parse.User.logIn('yo@lo.com', 'yolopass'); + }) + .then(user => { + expect(user.get('username')).toBe('yolo'); + }) + .then(done) + .catch(done.fail); + }); + + it('handles properly when 2 users share username / email pairs', done => { + const user = new Parse.User({ + username: 'yo@loname.com', password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?email=yo@lo2.com&username=yolo&password=yolopass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: true, - } - return rp.get(options); - }).then(done.fail).catch((err) => { - expect(err.response.body.error).toEqual('Invalid username/password.'); - done(); + email: 'yo@lo.com', }); + const user2 = new Parse.User({ + username: 'yo@lo.com', + email: 'yo@loname.com', + password: 'yolopass2', // different passwords + }); + + Parse.Object.saveAll([user, user2]) + .then(() => { + return Parse.User.logIn('yo@loname.com', 'yolopass'); + }) + .then(user => { + // the username takes precedence over the email, + // so we get the user with username as passed in + expect(user.get('username')).toBe('yo@loname.com'); + }) + .then(done) + .catch(done.fail); }); - it('fails to login when email and username are not provided', (done) => { - const user = new Parse.User(); - user.save({ - username: 'yolo', + it('handles properly when 2 users share username / email pairs, counterpart', done => { + const user = new Parse.User({ + username: 'yo@loname.com', password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?password=yolopass`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: true, - } - return rp.get(options); - }).then(done.fail).catch((err) => { - expect(err.response.body.error).toEqual('username/email is required.'); - done(); + email: 'yo@lo.com', }); + const user2 = new Parse.User({ + username: 'yo@lo.com', + email: 'yo@loname.com', + password: 'yolopass2', // different passwords + }); + + Parse.Object.saveAll([user, user2]) + .then(() => { + return Parse.User.logIn('yo@loname.com', 'yolopass2'); + }) + .then(done.fail) + .catch(err => { + expect(err.message).toEqual('Invalid username/password.'); + done(); + }); }); - it('fails to login when password is not provided', (done) => { + it('fails to login when password is not provided', done => { const user = new Parse.User(); - user.save({ - username: 'yolo', - password: 'yolopass', - email: 'yo@lo.com' - }).then(() => { - const options = { - url: `http://localhost:8378/1/login?username=yolo`, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?username=yolo`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('password is required.'); + done(); + }); + }); + + it('does not duplicate session when logging in multiple times #3451', done => { + const user = new Parse.User(); + user + .signUp({ + username: 'yolo', + password: 'yolo', + email: 'yo@lo.com', + }) + .then(() => { + const token = user.getSessionToken(); + let promise = Promise.resolve(); + let count = 0; + while (count < 5) { + promise = promise.then(() => { + return Parse.User.logIn('yolo', 'yolo').then(res => { + // ensure a new session token is generated at each login + expect(res.getSessionToken()).not.toBe(token); + }); + }); + count++; + } + return promise; + }) + .then(() => { + // wait because session destruction is not synchronous + return new Promise(resolve => { + setTimeout(resolve, 100); + }); + }) + .then(() => { + const query = new Parse.Query('_Session'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // only one session in the end + expect(results.length).toBe(1); + }) + .then(done, done.fail); + }); + + it('should throw OBJECT_NOT_FOUND instead of SESSION_MISSING when using masterKey', async () => { + // create a fake user (just so we simulate an object not found) + const non_existent_user = Parse.User.createWithoutData('fake_id'); + try { + await non_existent_user.destroy({ useMasterKey: true }); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + try { + await non_existent_user.save({}, { useMasterKey: true }); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + try { + await non_existent_user.save(); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + try { + await non_existent_user.destroy(); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + }); + + describe('issue #4897', () => { + it_only_db('mongo')( + 'should be able to login with a legacy user (no ACL)', + async () => { + // This issue is a side effect of the locked users and legacy users which don't have ACL's + // In this scenario, a legacy user wasn't be able to login as there's no ACL on it + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: 'ABCDEF1234', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_facebook: { + id: '8675309', + access_token: 'jenny', + }, + sessionToken: '', + }); + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook', {}); + expect(model.id).toBe('ABCDEF1234'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual( + provider.authData.access_token, + provider.synchronizedAuthToken + ); + strictEqual( + provider.authData.expiration_date, + provider.synchronizedExpiration + ); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + } + ); + }); +}); + +describe('Security Advisory GHSA-8w3j-g983-8jh5', function() { + it_only_db('mongo')( + 'should validate credentials first and check if account already linked afterwards ()', + async done => { + // Add User to Database with authData + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: 'ABCDEF1234', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_custom: { + id: 'linkedID', // Already linked userid }, - json: true, + sessionToken: '', + }); + const provider = { + getAuthType: () => 'custom', + restoreAuthentication: () => true, + }; // AuthProvider checks if password is 'password' + Parse.User._registerAuthenticationProvider(provider); + + // Try to link second user with wrong password + try { + const user = await Parse.AnonymousUtils.logIn(); + await user._linkWith(provider.getAuthType(), { + authData: { id: 'linkedID', password: 'wrong' }, + }); + } catch (error) { + // This should throw Parse.Error.SESSION_MISSING and not Parse.Error.ACCOUNT_ALREADY_LINKED + expect(error.code).toEqual(Parse.Error.SESSION_MISSING); + done(); + return; } - return rp.get(options); - }).then(done.fail).catch((err) => { - expect(err.response.body.error).toEqual('password is required.'); + fail(); done(); + } + ); + it_only_db('mongo')('should ignore authData field', async () => { + // Add User to Database with authData + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: '1234ABCDEF', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_custom: { + id: 'linkedID', + }, + sessionToken: '', + authData: null, // should ignore }); + const provider = { + getAuthType: () => 'custom', + restoreAuthentication: () => true, + }; + Parse.User._registerAuthenticationProvider(provider); + const query = new Parse.Query(Parse.User); + const user = await query.get('1234ABCDEF', { useMasterKey: true }); + expect(user.get('authData')).toEqual({ custom: { id: 'linkedID' } }); }); }); diff --git a/spec/ParseWebSocket.spec.js b/spec/ParseWebSocket.spec.js index 11a7ae214b..ce540b9a42 100644 --- a/spec/ParseWebSocket.spec.js +++ b/spec/ParseWebSocket.spec.js @@ -1,42 +1,42 @@ -var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; +const ParseWebSocket = require('../lib/LiveQuery/ParseWebSocketServer') + .ParseWebSocket; describe('ParseWebSocket', function() { - it('can be initialized', function() { - var ws = {}; - var parseWebSocket = new ParseWebSocket(ws); + const ws = {}; + const parseWebSocket = new ParseWebSocket(ws); expect(parseWebSocket.ws).toBe(ws); }); - it('can handle events defined in typeMap', function() { - var ws = { - on: jasmine.createSpy('on') + it('can handle disconnect event', function(done) { + const ws = { + onclose: () => {}, }; - var callback = {}; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.on('disconnect', callback); - - expect(parseWebSocket.ws.on).toHaveBeenCalledWith('close', callback); + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('disconnect', () => { + done(); + }); + ws.onclose(); }); - it('can handle events which are not defined in typeMap', function() { - var ws = { - on: jasmine.createSpy('on') + it('can handle message event', function(done) { + const ws = { + onmessage: () => {}, }; - var callback = {}; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.on('open', callback); - - expect(parseWebSocket.ws.on).toHaveBeenCalledWith('open', callback); + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('message', () => { + done(); + }); + ws.onmessage(); }); it('can send a message', function() { - var ws = { - send: jasmine.createSpy('send') + const ws = { + send: jasmine.createSpy('send'), }; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.send('message') + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.send('message'); expect(parseWebSocket.ws.send).toHaveBeenCalledWith('message'); }); diff --git a/spec/ParseWebSocketServer.spec.js b/spec/ParseWebSocketServer.spec.js index 7dc70fdbce..7bf8a5c57d 100644 --- a/spec/ParseWebSocketServer.spec.js +++ b/spec/ParseWebSocketServer.spec.js @@ -1,11 +1,13 @@ -var ParseWebSocketServer = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocketServer; +const { + ParseWebSocketServer, +} = require('../lib/LiveQuery/ParseWebSocketServer'); +const EventEmitter = require('events'); describe('ParseWebSocketServer', function() { - beforeEach(function(done) { // Mock ws server - var EventEmitter = require('events'); - var mockServer = function() { + + const mockServer = function() { return new EventEmitter(); }; jasmine.mockLibrary('ws', 'Server', mockServer); @@ -13,16 +15,20 @@ describe('ParseWebSocketServer', function() { }); it('can handle connect event when ws is open', function(done) { - var onConnectCallback = jasmine.createSpy('onConnectCallback'); - var http = require('http'); - var server = http.createServer(); - var parseWebSocketServer = new ParseWebSocketServer(server, onConnectCallback, 5).server; - var ws = { - readyState: 0, - OPEN: 0, - ping: jasmine.createSpy('ping') - }; - parseWebSocketServer.emit('connection', ws); + const onConnectCallback = jasmine.createSpy('onConnectCallback'); + const http = require('http'); + const server = http.createServer(); + const parseWebSocketServer = new ParseWebSocketServer( + server, + onConnectCallback, + { websocketTimeout: 5 } + ).server; + const ws = new EventEmitter(); + ws.readyState = 0; + ws.OPEN = 0; + ws.ping = jasmine.createSpy('ping'); + + parseWebSocketServer.onConnection(ws); // Make sure callback is called expect(onConnectCallback).toHaveBeenCalled(); @@ -31,10 +37,49 @@ describe('ParseWebSocketServer', function() { expect(ws.ping).toHaveBeenCalled(); server.close(); done(); - }, 10) + }, 10); + }); + + it('can handle error event', async () => { + jasmine.restoreLibrary('ws', 'Server'); + const WebSocketServer = require('ws').Server; + let wssError; + class WSSAdapter { + constructor(options) { + this.options = options; + } + onListen() {} + onConnection() {} + onError() {} + start() { + const wss = new WebSocketServer({ server: this.options.server }); + wss.on('listening', this.onListen); + wss.on('connection', this.onConnection); + wss.on('error', error => { + wssError = error; + this.onError(error); + }); + this.wss = wss; + } + } + + const server = await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + liveQueryServerOptions: { + wssAdapter: WSSAdapter, + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const wssAdapter = server.liveQueryServer.parseWebSocketServer.server; + wssAdapter.wss.emit('error', 'Invalid Packet'); + expect(wssError).toBe('Invalid Packet'); }); - afterEach(function(){ + afterEach(function() { jasmine.restoreLibrary('ws', 'Server'); }); }); diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js index b03ca7fff9..6bc076af78 100644 --- a/spec/PasswordPolicy.spec.js +++ b/spec/PasswordPolicy.spec.js @@ -1,9 +1,8 @@ -"use strict"; +'use strict'; -const requestp = require('request-promise'); - -describe("Password Policy: ", () => { +const request = require('../lib/request'); +describe('Password Policy: ', () => { it('should show the invalid link page if the user clicks on the password reset link after the token expires', done => { const user = new Parse.User(); let sendEmailOptions; @@ -12,8 +11,7 @@ describe("Password Policy: ", () => { sendPasswordResetEmail: options => { sendEmailOptions = options; }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', @@ -21,40 +19,48 @@ describe("Password Policy: ", () => { passwordPolicy: { resetTokenValidityDuration: 0.5, // 0.5 second }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - user.setUsername("testResetTokenValidity"); - user.setPassword("original"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - Parse.User.requestPasswordReset("user@parse.com").catch((err) => { + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + expect(sendEmailOptions).not.toBeUndefined(); + + request({ + url: sendEmailOptions.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html' + ); + done(); + }) + .catch(error => { + fail(error); + }); + }, 1000); + }) + .catch(err => { jfail(err); - fail("Reset password request should not fail"); done(); }); - }).then(() => { - // wait for a bit more than the validity duration set - setTimeout(() => { - expect(sendEmailOptions).not.toBeUndefined(); - - requestp.get({ - uri: sendEmailOptions.link, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }).catch((error) => { - fail(error); - }); - }, 1000); - }).catch((err) => { - jfail(err); - done(); - }); }); it('should show the reset password page if the user clicks on the password reset link before the token expires', done => { @@ -65,8 +71,7 @@ describe("Password Policy: ", () => { sendPasswordResetEmail: options => { sendEmailOptions = options; }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', @@ -74,376 +79,464 @@ describe("Password Policy: ", () => { passwordPolicy: { resetTokenValidityDuration: 5, // 5 seconds }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - user.setUsername("testResetTokenValidity"); - user.setPassword("original"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - Parse.User.requestPasswordReset('user@parse.com').catch((err) => { + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + // wait for a bit but less than the validity duration + setTimeout(() => { + expect(sendEmailOptions).not.toBeUndefined(); + + request({ + url: sendEmailOptions.link, + simple: false, + resolveWithFullResponse: true, + followRedirects: false, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=testResetTokenValidity/; + expect(response.text.match(re)).not.toBe(null); + done(); + }) + .catch(error => { + fail(error); + }); + }, 1000); + }) + .catch(err => { jfail(err); - fail("Reset password request should not fail"); done(); }); - }).then(() => { - // wait for a bit but less than the validity duration - setTimeout(() => { - expect(sendEmailOptions).not.toBeUndefined(); - - requestp.get({ - uri: sendEmailOptions.link, - simple: false, - resolveWithFullResponse: true, - followRedirect: false - }).then((response) => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=testResetTokenValidity/; - expect(response.body.match(re)).not.toBe(null); - done(); - }).catch((error) => { - fail(error); - }); - }, 1000); - }).catch((err) => { - jfail(err); - done(); - }); }); it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - resetTokenValidityDuration: "not a number" + resetTokenValidityDuration: 'not a number', }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.resetTokenValidityDuration "not a number" test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail( + 'passwordPolicy.resetTokenValidityDuration "not a number" test failed' + ); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.resetTokenValidityDuration must be a positive number' + ); + done(); + }); }); it('should fail if passwordPolicy.resetTokenValidityDuration is zero or a negative number', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - resetTokenValidityDuration: 0 + resetTokenValidityDuration: 0, }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('resetTokenValidityDuration negative number test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('resetTokenValidityDuration negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.resetTokenValidityDuration must be a positive number' + ); + done(); + }); }); it('should fail if passwordPolicy.validatorPattern setting is invalid type', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: 1234 // number is not a valid setting + validatorPattern: 1234, // number is not a valid setting }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.validatorPattern type test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.validatorPattern must be a regex string or RegExp object.'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.validatorPattern type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.validatorPattern must be a regex string or RegExp object.' + ); + done(); + }); }); it('should fail if passwordPolicy.validatorCallback setting is invalid type', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorCallback: "abc" // string is not a valid setting + validatorCallback: 'abc', // string is not a valid setting }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.validatorCallback type test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.validatorCallback must be a function.'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.validatorCallback type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.validatorCallback must be a function.' + ); + done(); + }); }); - it('signup should fail if password does not conform to the policy enforced using validatorPattern', (done) => { + it('signup should fail if password does not conform to the policy enforced using validatorPattern', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[0-9]+/ // password should contain at least one digit + validatorPattern: /[0-9]+/, // password should contain at least one digit }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("nodigit"); + user.setUsername('user1'); + user.setPassword('nodigit'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); }); - it('signup should fail if password does not conform to the policy enforced using validatorPattern string', (done) => { + it('signup should fail if password does not conform to the policy enforced using validatorPattern string', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: "^.{8,}" // password should contain at least 8 char + validatorPattern: '^.{8,}', // password should contain at least 8 char }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("less"); + user.setUsername('user1'); + user.setPassword('less'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); }); - it('signup should fail if password is empty', (done) => { + it('signup should fail if password is empty', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: "^.{8,}" // password should contain at least 8 char + validatorPattern: '^.{8,}', // password should contain at least 8 char }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword(""); + user.setUsername('user1'); + user.setPassword(''); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.message).toEqual('Cannot sign up user with an empty password.'); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.message).toEqual( + 'Cannot sign up user with an empty password.' + ); + done(); + }); + }); }); - it('signup should succeed if password conforms to the policy enforced using validatorPattern', (done) => { + it('signup should succeed if password conforms to the policy enforced using validatorPattern', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[0-9]+/ // password should contain at least one digit + validatorPattern: /[0-9]+/, // password should contain at least one digit }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("1digit"); + user.setUsername('user1'); + user.setPassword('1digit'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logOut().then(() => { - Parse.User.logIn("user1", "1digit").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("Should be able to login"); - done(); - }); - }).catch((error) => { + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', '1digit') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { jfail(error); - fail('logout should have succeeded'); + fail( + 'Signup should have succeeded as password conforms to the policy.' + ); done(); }); - }).catch((error) => { - jfail(error); - fail('Signup should have succeeded as password conforms to the policy.'); - done(); - }); - }) + }); }); - it('signup should succeed if password conforms to the policy enforced using validatorPattern string', (done) => { + it('signup should succeed if password conforms to the policy enforced using validatorPattern string', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: "[!@#$]+" // password should contain at least one special char + validatorPattern: '[!@#$]+', // password should contain at least one special char }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("p@sswrod"); + user.setUsername('user1'); + user.setPassword('p@sswrod'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logOut().then(() => { - Parse.User.logIn("user1", "p@sswrod").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("Should be able to login"); - done(); - }); - }).catch((error) => { + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'p@sswrod') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { jfail(error); - fail('logout should have succeeded'); + fail( + 'Signup should have succeeded as password conforms to the policy.' + ); done(); }); - }).catch((error) => { - jfail(error); - fail('Signup should have succeeded as password conforms to the policy.'); - done(); - }); - }) + }); }); - it('signup should fail if password does not conform to the policy enforced using validatorCallback', (done) => { + it('signup should fail if password does not conform to the policy enforced using validatorCallback', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorCallback: () => false // just fail + validatorCallback: () => false, // just fail }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("any"); + user.setUsername('user1'); + user.setPassword('any'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); }); - it('signup should succeed if password conforms to the policy enforced using validatorCallback', (done) => { + it('signup should succeed if password conforms to the policy enforced using validatorCallback', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorCallback: () => true // never fail + validatorCallback: () => true, // never fail }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("oneUpper"); + user.setUsername('user1'); + user.setPassword('oneUpper'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logOut().then(() => { - Parse.User.logIn("user1", "oneUpper").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("Should be able to login"); - done(); - }); - }).catch(error => { + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'oneUpper') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Logout should have succeeded'); + done(); + }); + }) + .catch(error => { jfail(error); - fail("Logout should have succeeded"); + fail('Should have succeeded as password conforms to the policy.'); done(); }); - }).catch((error) => { - jfail(error); - fail('Should have succeeded as password conforms to the policy.'); - done(); - }); - }) + }); }); - it('signup should fail if password does not match validatorPattern but succeeds validatorCallback', (done) => { + it('signup should fail if password does not match validatorPattern but succeeds validatorCallback', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter - validatorCallback: () => true + validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter + validatorCallback: () => true, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("all lower"); + user.setUsername('user1'); + user.setPassword('all lower'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); }); - it('signup should fail if password matches validatorPattern but fails validatorCallback', (done) => { + it('signup should fail if password matches validatorPattern but fails validatorCallback', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter - validatorCallback: () => false + validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter + validatorCallback: () => false, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("oneUpper"); + user.setUsername('user1'); + user.setPassword('oneUpper'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password does not conform to the policy.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail( + 'Should have failed as password does not conform to the policy.' + ); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); }); - it('signup should succeed if password conforms to both validatorPattern and validatorCallback', (done) => { + it('signup should succeed if password conforms to both validatorPattern and validatorCallback', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[A-Z]+/, // password should contain at least one digit - validatorCallback: () => true + validatorPattern: /[A-Z]+/, // password should contain at least one digit + validatorCallback: () => true, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("oneUpper"); + user.setUsername('user1'); + user.setPassword('oneUpper'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logOut().then(() => { - Parse.User.logIn("user1", "oneUpper").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("Should be able to login"); - done(); - }); - }).catch(error => { + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'oneUpper') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { jfail(error); - fail("logout should have succeeded"); + fail('Should have succeeded as password conforms to the policy.'); done(); }); - }).catch((error) => { - jfail(error); - fail('Should have succeeded as password conforms to the policy.'); - done(); - }); - }) + }); }); it('should reset password if new password conforms to password policy', done => { @@ -451,79 +544,90 @@ describe("Password Policy: ", () => { const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, + request({ + url: options.link, + followRedirects: false, simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - done(); - return; - } - const token = match[1]; + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", - body: `new_password=has2init&token=${token}&username=user1`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=has2init&token=${token}&username=user1`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + ); - Parse.User.logIn("user1", "has2init").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("should login with new password"); - done(); - }); - }).catch((error) => { + Parse.User.logIn('user1', 'has2init') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { jfail(error); - fail("Failed to POST request password reset"); + fail('Failed to get the reset link'); done(); }); - }).catch((error) => { - jfail(error); - fail("Failed to get the reset link"); - done(); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, emailAdapter: emailAdapter, passwordPolicy: { - validatorPattern: /[0-9]+/ // password should contain at least one digit + validatorPattern: /[0-9]+/, // password should contain at least one digit }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("has 1 digit"); + user.setUsername('user1'); + user.setPassword('has 1 digit'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { - jfail(err); - fail("Reset password request should not fail"); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); done(); }); - }).catch(error => { - jfail(error); - fail("signUp should not fail"); - done(); - }); }); }); @@ -532,161 +636,189 @@ describe("Password Policy: ", () => { const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, + request({ + url: options.link, + followRedirects: false, simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - done(); - return; - } - const token = match[1]; + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", - body: `new_password=hasnodigit&token=${token}&username=user1`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20meet%20the%20Password%20Policy%20requirements.&app=passwordPolicy`); + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=hasnodigit&token=${token}&username=user1`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20should%20contain%20at%20least%20one%20digit.&app=passwordPolicy` + ); - Parse.User.logIn("user1", "has 1 digit").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("should login with old password"); - done(); - }); - }).catch((error) => { + Parse.User.logIn('user1', 'has 1 digit') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with old password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { jfail(error); - fail("Failed to POST request password reset"); + fail('Failed to get the reset link'); done(); }); - }).catch((error) => { - jfail(error); - fail("Failed to get the reset link"); - done(); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, emailAdapter: emailAdapter, passwordPolicy: { - validatorPattern: /[0-9]+/ // password should contain at least one digit + validatorPattern: /[0-9]+/, // password should contain at least one digit + validationError: 'Password should contain at least one digit.', }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("has 1 digit"); + user.setUsername('user1'); + user.setPassword('has 1 digit'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { - jfail(err); - fail("Reset password request should not fail"); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); done(); }); - }).catch(error => { - jfail(error); - fail("signUp should not fail"); - done(); - }); }); }); - it('should fail if passwordPolicy.doNotAllowUsername is not a boolean value', (done) => { + it('should fail if passwordPolicy.doNotAllowUsername is not a boolean value', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - doNotAllowUsername: 'no' + doNotAllowUsername: 'no', }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.doNotAllowUsername type test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.doNotAllowUsername must be a boolean value.'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.doNotAllowUsername type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.doNotAllowUsername must be a boolean value.' + ); + done(); + }); }); - it('signup should fail if password contains the username and is not allowed by policy', (done) => { + it('signup should fail if password contains the username and is not allowed by policy', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { validatorPattern: /[0-9]+/, - doNotAllowUsername: true + doNotAllowUsername: true, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("@user11"); + user.setUsername('user1'); + user.setPassword('@user11'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - fail('Should have failed as password contains username.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(142); - done(); - }); - }) + user + .signUp() + .then(() => { + fail('Should have failed as password contains username.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + expect(error.message).toEqual( + 'Password cannot contain your username.' + ); + done(); + }); + }); }); - it('signup should succeed if password does not contain the username and is not allowed by policy', (done) => { + it('signup should succeed if password does not contain the username and is not allowed by policy', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - doNotAllowUsername: true + doNotAllowUsername: true, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("r@nd0m"); + user.setUsername('user1'); + user.setPassword('r@nd0m'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - done(); - }).catch(() => { - fail('Should have succeeded as password does not contain username.'); - done(); - }); - }) + user + .signUp() + .then(() => { + done(); + }) + .catch(() => { + fail('Should have succeeded as password does not contain username.'); + done(); + }); + }); }); - it('signup should succeed if password contains the username and it is allowed by policy', (done) => { + it('signup should succeed if password contains the username and it is allowed by policy', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - validatorPattern: /[0-9]+/ + validatorPattern: /[0-9]+/, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - done(); - }).catch(() => { - fail('Should have succeeded as policy allows username in password.'); - done(); - }); - }) + user + .signUp() + .then(() => { + done(); + }) + .catch(() => { + fail('Should have succeeded as policy allows username in password.'); + done(); + }); + }); }); it('should fail to reset password if the new password contains username and not allowed by password policy', done => { @@ -694,136 +826,212 @@ describe("Password Policy: ", () => { const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, + request({ + url: options.link, + followRedirects: false, simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - done(); - return; - } - const token = match[1]; - - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", - body: `new_password=xuser12&token=${token}&username=user1`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then((response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20does%20not%20meet%20the%20Password%20Policy%20requirements.&app=passwordPolicy`); - - Parse.User.logIn("user1", "r@nd0m").then(function () { - done(); - }).catch((err) => { - jfail(err); - fail("should login with old password"); + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); done(); - }); + return; + } + const token = match[1]; - }).catch((error) => { + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=xuser12&token=${token}&username=user1`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=Password%20cannot%20contain%20your%20username.&app=passwordPolicy` + ); + + Parse.User.logIn('user1', 'r@nd0m') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with old password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { jfail(error); - fail("Failed to POST request password reset"); + fail('Failed to get the reset link'); done(); }); - }).catch((error) => { - jfail(error); - fail("Failed to get the reset link"); - done(); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, emailAdapter: emailAdapter, passwordPolicy: { - doNotAllowUsername: true + doNotAllowUsername: true, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("r@nd0m"); + user.setUsername('user1'); + user.setPassword('r@nd0m'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { - jfail(err); - fail("Reset password request should not fail"); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); done(); }); - }).catch(error => { - jfail(error); - fail("signUp should not fail"); - done(); - }); }); }); - it('should reset password even if the new password contains user name while the policy allows', done => { + it('Should return error when password violates Password Policy and reset through ajax', async done => { const user = new Parse.User(); const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, + sendPasswordResetEmail: async options => { + const response = await request({ + url: options.link, + followRedirects: false, simple: false, - resolveWithFullResponse: true - }).then(response => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - done(); - return; - } - const token = match[1]; + resolveWithFullResponse: true, + }); + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return; + } + const token = match[1]; - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", - body: `new_password=uuser11&token=${token}&username=user1`, + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=xuser12&token=${token}&username=user1`, headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', }, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then(response => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1'); + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual( + '{"code":-1,"error":"Password cannot contain your username."}' + ); + } + await Parse.User.logIn('user1', 'r@nd0m'); + done(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + doNotAllowUsername: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('user1'); + user.setPassword('r@nd0m'); + user.set('email', 'user1@parse.com'); + await user.signUp(); - Parse.User.logIn("user1", "uuser11").then(function () { - done(); - }).catch(err => { - jfail(err); - fail("should login with new password"); + await Parse.User.requestPasswordReset('user1@parse.com'); + }); + + it('should reset password even if the new password contains user name while the policy allows', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); done(); - }); + return; + } + const token = match[1]; - }).catch(error => { + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=uuser11&token=${token}&username=user1`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + ); + + Parse.User.logIn('user1', 'uuser11') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + }); + }) + .catch(error => { jfail(error); - fail("Failed to POST request password reset"); + fail('Failed to get the reset link'); }); - }).catch(error => { - jfail(error); - fail("Failed to get the reset link"); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', @@ -831,173 +1039,209 @@ describe("Password Policy: ", () => { emailAdapter: emailAdapter, passwordPolicy: { validatorPattern: /[0-9]+/, - doNotAllowUsername: false + doNotAllowUsername: false, }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - user.setUsername("user1"); - user.setPassword("has 1 digit"); - user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { - jfail(err); - fail("Reset password request should not fail"); - done(); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('user1'); + user.setPassword('has 1 digit'); + user.set('email', 'user1@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); + done(); }); - }).catch(error => { - jfail(error); - fail("signUp should not fail"); - done(); - }); }); it('should fail if passwordPolicy.maxPasswordAge is not a number', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordAge: "not a number" + maxPasswordAge: 'not a number', }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.maxPasswordAge "not a number" test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordAge "not a number" test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.maxPasswordAge must be a positive number' + ); + done(); + }); }); it('should fail if passwordPolicy.maxPasswordAge is a negative number', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordAge: -100 + maxPasswordAge: -100, }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.maxPasswordAge negative number test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordAge negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.maxPasswordAge must be a positive number' + ); + done(); + }); }); - it('should succeed if logged in before password expires', (done) => { + it('should succeed if logged in before password expires', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordAge: 1 // 1 day + maxPasswordAge: 1, // 1 day }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logIn("user1", "user1").then(() => { - done(); - }).catch((error) => { + user + .signUp() + .then(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + done(); + }) + .catch(error => { + jfail(error); + fail('Login should have succeeded before password expiry.'); + done(); + }); + }) + .catch(error => { jfail(error); - fail('Login should have succeeded before password expiry.'); + fail('Signup failed.'); done(); }); - }).catch((error) => { - jfail(error); - fail('Signup failed.'); - done(); - }); - }) + }); }); - it('should fail if logged in after password expires', (done) => { + it('should fail if logged in after password expires', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordAge: 0.5 / (24 * 60 * 60) // 0.5 sec + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - // wait for a bit more than the validity duration set - setTimeout(() => { - Parse.User.logIn("user1", "user1").then(() => { - fail("logIn should have failed"); - done(); - }).catch((error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - expect(error.message).toEqual('Your password has expired. Please reset your password.'); - done(); - }); - }, 1000); - }).catch((error) => { - jfail(error); - fail('Signup failed.'); - done(); - }); + user + .signUp() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + done(); + }); + }, 1000); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); }); }); - it('should apply password expiry policy to existing user upon first login after policy is enabled', (done) => { + it('should apply password expiry policy to existing user upon first login after policy is enabled', done => { const user = new Parse.User(); reconfigureServer({ appName: 'passwordPolicy', - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - Parse.User.logOut().then(() => { - reconfigureServer({ - appName: 'passwordPolicy', - passwordPolicy: { - maxPasswordAge: 0.5 / (24 * 60 * 60) // 0.5 sec - }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - Parse.User.logIn("user1", "user1").then(() => { - Parse.User.logOut().then(() => { - // wait for a bit more than the validity duration set - setTimeout(() => { - Parse.User.logIn("user1", "user1").then(() => { - fail("logIn should have failed"); - done(); - }).catch((error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - expect(error.message).toEqual('Your password has expired. Please reset your password.'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + Parse.User.logOut() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual( + Parse.Error.OBJECT_NOT_FOUND + ); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + done(); + }); + }, 2000); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Login failed.'); done(); }); - }, 2000); - }).catch(error => { - jfail(error); - fail("logout should have succeeded"); - done(); }); - }).catch((error) => { + }) + .catch(error => { jfail(error); - fail('Login failed.'); + fail('logout should have succeeded'); done(); }); - }); - }).catch(error => { + }) + .catch(error => { jfail(error); - fail("logout should have succeeded"); + fail('Signup failed.'); done(); }); - }).catch((error) => { - jfail(error); - fail('Signup failed.'); - done(); - }); }); - }); it('should reset password timestamp when password is reset', done => { @@ -1005,86 +1249,103 @@ describe("Password Policy: ", () => { const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, + request({ + url: options.link, + followRedirects: false, simple: false, - resolveWithFullResponse: true - }).then(response => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - done(); - return; - } - const token = match[1]; + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", - body: `new_password=uuser11&token=${token}&username=user1`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then(response => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=uuser11&token=${token}&username=user1`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=user1' + ); - Parse.User.logIn("user1", "uuser11").then(function () { - done(); - }).catch(err => { - jfail(err); - fail("should login with new password"); - done(); - }); - }).catch(error => { + Parse.User.logIn('user1', 'uuser11') + .then(function() { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + }); + }) + .catch(error => { jfail(error); - fail("Failed to POST request password reset"); + fail('Failed to get the reset link'); }); - }).catch(error => { - jfail(error); - fail("Failed to get the reset link"); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', emailAdapter: emailAdapter, passwordPolicy: { - maxPasswordAge: 0.5 / (24 * 60 * 60) // 0.5 sec + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - // wait for a bit more than the validity duration set - setTimeout(() => { - Parse.User.logIn("user1", "user1").then(() => { - fail("logIn should have failed"); - done(); - }).catch((error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - expect(error.message).toEqual('Your password has expired. Please reset your password.'); - Parse.User.requestPasswordReset('user1@parse.com').catch((err) => { - jfail(err); - fail("Reset password request should not fail"); - done(); - }); - }); - }, 1000); - }).catch((error) => { - jfail(error); - fail('Signup failed.'); - done(); - }); + user + .signUp() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + Parse.User.requestPasswordReset('user1@parse.com').catch( + err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + } + ); + }); + }, 1000); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); }); }); @@ -1092,48 +1353,60 @@ describe("Password Policy: ", () => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordHistory: "not a number" + maxPasswordHistory: 'not a number', }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.maxPasswordHistory "not a number" test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory "not a number" test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20' + ); + done(); + }); }); it('should fail if passwordPolicy.maxPasswordHistory is a negative number', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordHistory: -10 + maxPasswordHistory: -10, }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.maxPasswordHistory negative number test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20' + ); + done(); + }); }); it('should fail if passwordPolicy.maxPasswordHistory is greater than 20', done => { reconfigureServer({ appName: 'passwordPolicy', passwordPolicy: { - maxPasswordHistory: 21 + maxPasswordHistory: 21, }, - publicServerURL: "http://localhost:8378/1" - }).then(() => { - fail('passwordPolicy.maxPasswordHistory negative number test failed'); - done(); - }).catch(err => { - expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20' + ); + done(); + }); }); it('should fail to reset if the new password is same as the last password', done => { @@ -1141,78 +1414,81 @@ describe("Password Policy: ", () => { const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - requestp.get({ - uri: options.link, - followRedirect: false, - simple: false, - resolveWithFullResponse: true - }).then(response => { - expect(response.statusCode).toEqual(302); - const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; - const match = response.body.match(re); - if (!match) { - fail("should have a token"); - return Promise.reject("Invalid password link"); - } - return Promise.resolve(match[1]); // token - }).then(token => { - return new Promise((resolve, reject) => { - requestp.post({ - uri: "http://localhost:8378/1/apps/test/request_password_reset", + request({ + url: options.link, + followRedirects: false, + }) + .then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=user1/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return Promise.reject('Invalid password link'); + } + return Promise.resolve(match[1]); // token + }) + .then(token => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', body: `new_password=user1&token=${token}&username=user1`, headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', }, - followRedirect: false, + followRedirects: false, simple: false, - resolveWithFullResponse: true + resolveWithFullResponse: true, }).then(response => { - resolve([response, token]); - }).catch(error => { - reject(error); + return [response, token]; }); + }) + .then(data => { + const response = data[0]; + const token = data[1]; + expect(response.status).toEqual(302); + expect(response.text).toEqual( + `Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy` + ); + done(); + return Promise.resolve(); + }) + .catch(error => { + fail(error); + fail('Repeat password test failed'); + done(); }); - }).then(data => { - const response = data[0]; - const token = data[1]; - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual(`Found. Redirecting to http://localhost:8378/1/apps/choose_password?username=user1&token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy`); - done(); - return Promise.resolve(); - }).catch(error => { - jfail(error); - fail("Repeat password test failed"); - done(); - }); }, - sendMail: () => { - } + sendMail: () => {}, }; reconfigureServer({ appName: 'passwordPolicy', verifyUserEmails: false, emailAdapter: emailAdapter, passwordPolicy: { - maxPasswordHistory: 1 + maxPasswordHistory: 1, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - return Parse.User.logOut(); - }).then(() => { - return Parse.User.requestPasswordReset('user1@parse.com'); - }).catch(error => { - jfail(error); - fail("SignUp or reset request failed"); - done(); - }); + user + .signUp() + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.requestPasswordReset('user1@parse.com'); + }) + .catch(error => { + jfail(error); + fail('SignUp or reset request failed'); + done(); + }); }); }); - it('should fail if the new password is same as the previous one', done => { const user = new Parse.User(); @@ -1220,25 +1496,33 @@ describe("Password Policy: ", () => { appName: 'passwordPolicy', verifyUserEmails: false, passwordPolicy: { - maxPasswordHistory: 5 + maxPasswordHistory: 5, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - // try to set the same password as the previous one - user.setPassword('user1'); - return user.save(); - }).then(() => { - fail("should have failed because the new password is same as the old"); - done(); - }).catch(error => { - expect(error.message).toEqual('New password should not be the same as last 5 passwords.'); - expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); - done(); - }); + user + .signUp() + .then(() => { + // try to set the same password as the previous one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + fail( + 'should have failed because the new password is same as the old' + ); + done(); + }) + .catch(error => { + expect(error.message).toEqual( + 'New password should not be the same as last 5 passwords.' + ); + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + done(); + }); }); }); @@ -1249,38 +1533,50 @@ describe("Password Policy: ", () => { appName: 'passwordPolicy', verifyUserEmails: false, passwordPolicy: { - maxPasswordHistory: 5 + maxPasswordHistory: 5, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - // build history - user.setPassword('user2'); - return user.save(); - }).then(() => { - user.setPassword('user3'); - return user.save(); - }).then(() => { - user.setPassword('user4'); - return user.save(); - }).then(() => { - user.setPassword('user5'); - return user.save(); - }).then(() => { - // set the same password as the initial one - user.setPassword('user1'); - return user.save(); - }).then(() => { - fail("should have failed because the new password is same as the old"); - done(); - }).catch(error => { - expect(error.message).toEqual('New password should not be the same as last 5 passwords.'); - expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); - done(); - }); + user + .signUp() + .then(() => { + // build history + user.setPassword('user2'); + return user.save(); + }) + .then(() => { + user.setPassword('user3'); + return user.save(); + }) + .then(() => { + user.setPassword('user4'); + return user.save(); + }) + .then(() => { + user.setPassword('user5'); + return user.save(); + }) + .then(() => { + // set the same password as the initial one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + fail( + 'should have failed because the new password is same as the old' + ); + done(); + }) + .catch(error => { + expect(error.message).toEqual( + 'New password should not be the same as last 5 passwords.' + ); + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + done(); + }); }); }); @@ -1291,39 +1587,84 @@ describe("Password Policy: ", () => { appName: 'passwordPolicy', verifyUserEmails: false, passwordPolicy: { - maxPasswordHistory: 5 + maxPasswordHistory: 5, }, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }).then(() => { - user.setUsername("user1"); - user.setPassword("user1"); + user.setUsername('user1'); + user.setPassword('user1'); user.set('email', 'user1@parse.com'); - user.signUp().then(() => { - // build history - user.setPassword('user2'); - return user.save(); - }).then(() => { - user.setPassword('user3'); - return user.save(); - }).then(() => { - user.setPassword('user4'); - return user.save(); - }).then(() => { - user.setPassword('user5'); - return user.save(); - }).then(() => { - user.setPassword('user6'); // this pushes initial password out of history - return user.save(); - }).then(() => { - // set the same password as the initial one - user.setPassword('user1'); - return user.save(); - }).then(() => { - done(); - }).catch(() => { - fail("should have succeeded because the new password is not in history"); - done(); - }); + user + .signUp() + .then(() => { + // build history + user.setPassword('user2'); + return user.save(); + }) + .then(() => { + user.setPassword('user3'); + return user.save(); + }) + .then(() => { + user.setPassword('user4'); + return user.save(); + }) + .then(() => { + user.setPassword('user5'); + return user.save(); + }) + .then(() => { + user.setPassword('user6'); // this pushes initial password out of history + return user.save(); + }) + .then(() => { + // set the same password as the initial one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + done(); + }) + .catch(() => { + fail( + 'should have succeeded because the new password is not in history' + ); + done(); + }); }); }); -}) + + it('should not infinitely loop if maxPasswordHistory is 1 (#4918)', async () => { + const user = new Parse.User(); + const query = new Parse.Query(Parse.User); + + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + passwordPolicy: { + maxPasswordHistory: 1, + }, + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + await user.signUp(); + + user.setPassword('user2'); + await user.save(); + + const result1 = await query.get(user.id, { useMasterKey: true }); + expect(result1.get('_password_history').length).toBe(1); + + user.setPassword('user3'); + await user.save(); + + const result2 = await query.get(user.id, { useMasterKey: true }); + expect(result2.get('_password_history').length).toBe(1); + + expect(result1.get('_password_history')).not.toEqual( + result2.get('_password_history') + ); + }); +}); diff --git a/spec/PointerPermissions.spec.js b/spec/PointerPermissions.spec.js index 945620732e..a3a1b9f498 100644 --- a/spec/PointerPermissions.spec.js +++ b/spec/PointerPermissions.spec.js @@ -1,733 +1,3152 @@ 'use strict'; -var Config = require('../src/Config'); +const Config = require('../lib/Config'); describe('Pointer Permissions', () => { - beforeEach(() => { Config.get(Parse.applicationId).database.schemaCache.clear(); }); - it('should work with find', (done) => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' + describe('using single user-pointers', () => { + it('should work with find', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj2.set('owner', user2); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); }); - user2.set({ - username: 'user2', - password: 'password' + + it('should work with write', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj.set('reader', user2); + obj2.set('owner', user2); + obj2.set('reader', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { + writeUserFields: ['owner'], + readUserFields: ['reader', 'owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + obj2.set('hello', 'world'); + return obj2.save(); + }) + .then( + () => { + fail('User should not be able to update obj2'); + }, + err => { + // User 1 should not be able to update obj2 + expect(err.code).toBe(101); + return Promise.resolve(); + } + ) + .then(() => { + obj.set('hello', 'world'); + return obj.save(); + }) + .then( + () => { + return Parse.User.logIn('user2', 'password'); + }, + () => { + fail('User should be able to update'); + return Promise.resolve(); + } + ) + .then( + () => { + const q = new Parse.Query('AnObject'); + return q.find(); + }, + () => { + fail('should login with user 2'); + } + ) + .then( + res => { + expect(res.length).toBe(2); + res.forEach(result => { + if (result.id == obj.id) { + expect(result.get('hello')).toBe('world'); + } else { + expect(result.id).toBe(obj2.id); + } + }); + done(); + }, + () => { + fail('failed'); + done(); + } + ); }); - const obj = new Parse.Object('AnObject'); - const obj2 = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - obj.set('owner', user); - obj2.set('owner', user2); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {readUserFields: ['owner']}) - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(1); - expect(res[0].id).toBe(obj.id); - done(); - }).catch(error => { - fail(JSON.stringify(error)); - done(); + it('should let a proper user find', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + user + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + Parse.User.logOut(); + }) + .then(() => { + obj.set('owner', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + const q = new Parse.Query('AnObject'); + return q.get(obj.id); + }) + .then( + () => { + fail('User 2 should not get the obj1 object'); + }, + err => { + expect(err.code).toBe(101); + expect(err.message).toBe('Object not found.'); + return Promise.resolve(); + } + ) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); + + it('should query on pointer permission enabled column', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + user + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + Parse.User.logOut(); + }) + .then(() => { + obj.set('owner', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + q.equalTo('owner', user2); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); + + it('should not allow creating objects', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + user + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + obj.set('owner', user); + return obj.save(); + }) + .then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(119); + done(); + } + ); + }); + + it('should handle multiple writeUserFields', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj.set('otherOwner', user2); + return obj.save(); + }) + .then(() => config.database.loadSchema()) + .then(schema => + schema.updateClass( + 'AnObject', + {}, + { find: { '*': true }, writeUserFields: ['owner', 'otherOwner'] } + ) + ) + .then(() => Parse.User.logIn('user1', 'password')) + .then(() => obj.save({ hello: 'fromUser1' })) + .then(() => Parse.User.logIn('user2', 'password')) + .then(() => obj.save({ hello: 'fromUser2' })) + .then(() => Parse.User.logOut()) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.first(); + }) + .then(result => { + expect(result.get('hello')).toBe('fromUser2'); + done(); + }) + .catch(() => { + fail('should not fail'); + done(); + }); + }); + + it('should prevent creating pointer permission on missing field', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (of wrong type)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'String' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (non-user pointer)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_Session' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (non-existing)', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('owner', 'value'); + object + .save() + .then(() => { + return config.database.loadSchema(); + }) + .then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('tests CLP / Pointer Perms / ACL write (PP Locked)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by PP + return obj.save({ key: 'value' }); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(101); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL write (ACL Locked)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by ACL + return obj.save({ key: 'value' }); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(101); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setWriteAccess(user, true); + ACL.setWriteAccess(user2, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by ACL + return obj.save({ key: 'value' }); + }) + .then( + objAgain => { + expect(objAgain.get('key')).toBe('value'); + done(); + }, + () => { + fail('Should not fail saving'); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (PP locked)', done => { + /* + tests: + CLP: find/get open ({}) + PointerPerm: "owner" : read + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be block + return obj.fetch(); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(101); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owner" : read + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + ACL.setReadAccess(user2, true); + ACL.setWriteAccess(user2, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be block + return obj.fetch(); + }) + .then( + objAgain => { + expect(objAgain.id).toBe(obj.id); + done(); + }, + () => { + fail('Should not fail fetching'); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (ACL locked)', done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owner" : read // proper owner + ACL: logged in user has not access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user2 has ACL read/write but should be block by ACL + return obj.fetch(); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(101); + done(); + } + ); + }); + + it('should let master key find objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(101); + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find({ useMasterKey: true }); + }) + .then( + objects => { + expect(objects.length).toBe(1); + done(); + }, + () => { + fail('master key should find the object'); + done(); + } + ); + }); + + it('should let master key get objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.get(object.id); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(101); + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.get(object.id, { useMasterKey: true }); + }) + .then( + objectAgain => { + expect(objectAgain).not.toBeUndefined(); + expect(objectAgain.id).toBe(object.id); + done(); + }, + () => { + fail('master key should find the object'); + done(); + } + ); + }); + + it('should let master key update objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { update: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return object.save({ hello: 'bar' }); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(101); + return Promise.resolve(); + } + ) + .then(() => { + return object.save({ hello: 'baz' }, { useMasterKey: true }); + }) + .then( + objectAgain => { + expect(objectAgain.get('hello')).toBe('baz'); + done(); + }, + () => { + fail('master key should save the object'); + done(); + } + ); + }); + + it('should let master key delete objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return object.destroy(); + }) + .then( + () => { + fail(); + }, + err => { + expect(err.code).toBe(101); + return Promise.resolve(); + } + ) + .then(() => { + return object.destroy({ useMasterKey: true }); + }) + .then( + () => { + done(); + }, + () => { + fail('master key should destroy the object'); + done(); + } + ); }); - }); + it('should fail with invalid pointer perms (not array)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + // Lock the update, and let only owner write + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: 'owner' } + ); + }) + .catch(err => { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + }); + }); - it('should work with write', (done) => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' + it('should fail with invalid pointer perms (non-existing field)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + // Lock the update, and let only owner write + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: ['owner', 'invalid'] } + ); + }) + .catch(err => { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + }); }); - user2.set({ - username: 'user2', - password: 'password' + }); + + describe('using arrays of user-pointers', () => { + it('should work with find', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + + obj.set('owners', [user]); + obj2.set('owners', [user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { readUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + done(); + } catch (err) { + done.fail(JSON.stringify(err)); + } }); - const obj = new Parse.Object('AnObject'); - const obj2 = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('should work with write', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + obj.set('owner', user); - obj.set('reader', user2); + obj.set('readers', [user2]); obj2.set('owner', user2); - obj2.set('reader', user); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {writeUserFields: ['owner'], readUserFields: ['reader', 'owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { + obj2.set('readers', [user]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + writeUserFields: ['owner'], + readUserFields: ['readers', 'owner'], + } + ); + + await Parse.User.logIn('user1', 'password'); + obj2.set('hello', 'world'); - return obj2.save(); - }).then(() => { - fail('User should not be able to update obj2'); - }, (err) => { - // User 1 should not be able to update obj2 - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(()=> { + try { + await obj2.save(); + done.fail('User should not be able to update obj2'); + } catch (err) { + // User 1 should not be able to update obj2 + expect(err.code).toBe(101); + } + obj.set('hello', 'world'); - return obj.save(); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }, () => { - fail('User should be able to update'); - return Promise.resolve(); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }, () => { - fail('should login with user 2'); - }).then((res) => { - expect(res.length).toBe(2); - res.forEach((result) => { - if (result.id == obj.id) { - expect(result.get('hello')).toBe('world'); - } else { - expect(result.id).toBe(obj2.id); + try { + await obj.save(); + } catch (err) { + done.fail('User should be able to update'); + } + + await Parse.User.logIn('user2', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(2); + res.forEach(result => { + if (result.id == obj.id) { + expect(result.get('hello')).toBe('world'); + } else { + expect(result.id).toBe(obj2.id); + } + }); + done(); + } catch (err) { + done.fail('failed'); + } + }); + + it('should let a proper user find', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); + + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owners'] } + ); + + let q = new Parse.Query('AnObject'); + let result = await q.find(); + expect(result.length).toBe(0); + + Parse.User.logIn('user3', 'password'); + q = new Parse.Query('AnObject'); + result = await q.find(); + + expect(result.length).toBe(0); + q = new Parse.Query('AnObject'); + + try { + await q.get(obj.id); + done.fail('User 3 should not get the obj1 object'); + } catch (err) { + expect(err.code).toBe(101); + expect(err.message).toBe('Object not found.'); + } + + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + const q = new Parse.Query('AnObject'); + result = await q.find(); + expect(result.length).toBe(1); + } catch (err) { + done.fail('should not fail'); } - }) + } done(); - }, () => { - fail("failed"); - done(); - }) - }); + }); - it('should let a proper user find', (done) => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - const obj2 = new Parse.Object('AnObject'); - user.signUp().then(() => { - return user2.signUp() - }).then(() => { - Parse.User.logOut(); - }).then(() => { - obj.set('owner', user); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {find: {}, get:{}, readUserFields: ['owner']}) + it('should query on pointer permission enabled column', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', }); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(0); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(0); - const q = new Parse.Query('AnObject'); - return q.get(obj.id); - }).then(() => { - fail('User 2 should not get the obj1 object'); - }, (err) => { - expect(err.code).toBe(101); - expect(err.message).toBe('Object not found.'); - return Promise.resolve(); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(1); - done(); - }).catch((err) => { - jfail(err); - fail('should not fail'); - done(); - }) - }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); - it('should query on pointer permission enabled column', (done) => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - const obj2 = new Parse.Object('AnObject'); - user.signUp().then(() => { - return user2.signUp() - }).then(() => { - Parse.User.logOut(); - }).then(() => { - obj.set('owner', user); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {find: {}, get:{}, readUserFields: ['owner']}) - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - const q = new Parse.Query('AnObject'); - q.equalTo('owner', user2); - return q.find(); - }).then((res) => { - expect(res.length).toBe(0); - done(); - }).catch((err) => { - jfail(err); - fail('should not fail'); + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); + + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owners'] } + ); + + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + const q = new Parse.Query('AnObject'); + q.equalTo('owners', user3); + const result = await q.find(); + expect(result.length).toBe(0); + } catch (err) { + done.fail('should not fail'); + } + } done(); - }) - }); + }); - it('should not allow creating objects', (done) => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - user.save().then(() => { - return config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {owner: {type:'Pointer', targetClass: '_User'}}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - obj.set('owner', user); - return obj.save(); - }).then(() => { - fail('should not succeed'); + it('should not query using arrays on pointer permission enabled column', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); + + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owners'] } + ); + + for (const owner of ['user1', 'user2']) { + try { + await Parse.User.logIn(owner, 'password'); + // Since querying for arrays is not supported this should throw an error + const q = new Parse.Query('AnObject'); + q.equalTo('owners', [user3]); + await q.find(); + done.fail('should fail'); + // eslint-disable-next-line no-empty + } catch (error) {} + } done(); - }, (err) => { - expect(err.code).toBe(119); + }); + + it('should not allow creating objects', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + await Parse.Object.saveAll([user, user2]); + + const schema = await config.database.loadSchema(); + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + obj.set('owners', [user, user2]); + await obj.save(); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(119); + } + } done(); - }) - }); + }); + + it('should handle multiple writeUserFields', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + obj.set('owners', [user]); + obj.set('otherOwners', [user2]); + await obj.save(); - it('should handle multiple writeUserFields', done => { - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]) - .then(() => { - obj.set('owner', user); - obj.set('otherOwner', user2); - return obj.save(); - }) - .then(() => config.database.loadSchema()) - .then(schema => schema.updateClass('AnObject', {}, {find: {"*": true},writeUserFields: ['owner', 'otherOwner']})) - .then(() => Parse.User.logIn('user1', 'password')) - .then(() => obj.save({hello: 'fromUser1'})) - .then(() => Parse.User.logIn('user2', 'password')) - .then(() => obj.save({hello: 'fromUser2'})) - .then(() => Parse.User.logOut()) - .then(() => { + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { find: { '*': true }, writeUserFields: ['owners', 'otherOwners'] } + ); + + await Parse.User.logIn('user1', 'password'); + await obj.save({ hello: 'fromUser1' }); + await Parse.User.logIn('user2', 'password'); + await obj.save({ hello: 'fromUser2' }); + await Parse.User.logOut(); + + try { const q = new Parse.Query('AnObject'); - return q.first(); - }) - .then(result => { + const result = await q.first(); expect(result.get('hello')).toBe('fromUser2'); done(); - }).catch(() => { - fail('should not fail'); + } catch (err) { + done.fail('should not fail'); + } + }); + + it('should prevent creating pointer permission on missing field', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + await schema.addClassIfNotExists( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); done(); - }) - }); + } + }); - it('should prevent creating pointer permission on missing field', (done) => { - const config = Config.get(Parse.applicationId); - config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); - done(); - }) - }); + it('should prevent creating pointer permission on bad field (of wrong type)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'String' } }, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + } + }); - it('should prevent creating pointer permission on bad field', (done) => { - const config = Config.get(Parse.applicationId); - config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {owner: {type: 'String'}}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); - done(); - }) - }); + it('should prevent creating pointer permission on bad field (non-existing)', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('owners', 'value'); + await object.save(); - it('should prevent creating pointer permission on bad field', (done) => { - const config = Config.get(Parse.applicationId); - const object = new Parse.Object('AnObject'); - object.set('owner', 'value'); - object.save().then(() => { - return config.database.loadSchema(); - }).then((schema) => { - return schema.updateClass('AnObject', {}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); - done(); - }) - }); + const schema = await config.database.loadSchema(); + try { + await schema.updateClass( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + } + }); + + it('should work with arrays containing valid & invalid elements', async done => { + /* Since there is no way to check the validity of objects in arrays before querying invalid + elements in arrays should be ignored. */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + + obj.set('owners', [user, '', -1, true, [], { invalid: -1 }]); + await Parse.Object.saveAll([obj]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { readUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + } catch (err) { + done.fail(JSON.stringify(err)); + } + + await Parse.User.logOut(); + await Parse.User.logIn('user2', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(0); + done(); + } catch (err) { + done.fail(JSON.stringify(err)); + } + }); + + it('tests CLP / Pointer Perms / ACL write (PP Locked)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); - it('tests CLP / Pointer Perms / ACL write (PP Locked)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - - The owner is another user than the ACL - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by PP - return obj.save({key: 'value'}); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); - done(); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owners'] } + ); + + await Parse.User.logIn('user1', 'password'); + try { + // user1 has ACL read/write but should be blocked by PP + await obj.save({ key: 'value' }); + done.fail('Should not succeed saving'); + } catch (err) { + expect(err.code).toBe(101); + done(); + } }); - }); - it('tests CLP / Pointer Perms / ACL write (ACL Locked)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('tests CLP / Pointer Perms / ACL write (ACL Locked)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by ACL - return obj.save({key: 'value'}); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owners'] } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + await obj.save({ key: 'value' }); + done.fail('Should not succeed saving'); + } catch (err) { + expect(err.code).toBe(101); + } + } done(); }); - }); - it('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); const ACL = new Parse.ACL(); ACL.setWriteAccess(user, true); ACL.setWriteAccess(user2, true); + ACL.setWriteAccess(user3, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by ACL - return obj.save({key: 'value'}); - }).then((objAgain) => { - expect(objAgain.get('key')).toBe('value'); - done(); - }, () => { - fail('Should not fail saving'); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass( + 'AnObject', + {}, + { update: {}, writeUserFields: ['owners'] } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + const objectAgain = await obj.save({ key: 'value' }); + expect(objectAgain.get('key')).toBe('value'); + } catch (err) { + done.fail('Should not fail saving'); + } + } done(); }); - }); - it('tests CLP / Pointer Perms / ACL read (PP locked)', (done) => { - /* - tests: - CLP: find/get open ({}) - PointerPerm: "owner" : read - ACL: logged in user has access - - The owner is another user than the ACL - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('tests CLP / Pointer Perms / ACL read (PP locked)', async done => { + /* + tests: + CLP: find/get open ({}) + PointerPerm: "owners" : read + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - // user1 has ACL read/write but should be block - return obj.fetch(); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock reading, and let only owners read + await schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owners'] } + ); + + await Parse.User.logIn('user1', 'password'); + try { + // user1 has ACL read/write but should be blocked + await obj.fetch(); + done.fail('Should not succeed fetching'); + } catch (err) { + expect(err.code).toBe(101); + done(); + } done(); }); - }); - it('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', (done) => { - /* - tests: - CLP: find/get open ({"*": true}) - PointerPerm: "owner" : read - ACL: logged in user has access - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', async done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owners" : read + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); ACL.setReadAccess(user2, true); ACL.setWriteAccess(user2, true); + ACL.setReadAccess(user3, true); + ACL.setWriteAccess(user3, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {"*": true}, get: {"*": true}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be block - return obj.fetch(); - }).then((objAgain) => { - expect(objAgain.id).toBe(obj.id); - done(); - }, () => { - fail('Should not fail fetching'); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Allow public and owners read + await schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owners'], + } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + const objectAgain = await obj.fetch(); + expect(objectAgain.id).toBe(obj.id); + } catch (err) { + done.fail('Should not fail fetching'); + } + } done(); }); - }); - it('tests CLP / Pointer Perms / ACL read (ACL locked)', (done) => { - /* - tests: - CLP: find/get open ({"*": true}) - PointerPerm: "owner" : read // proper owner - ACL: logged in user has not access - */ - const config = Config.get(Parse.applicationId); - const user = new Parse.User(); - const user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it('tests CLP / Pointer Perms / ACL read (ACL locked)', async done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owners" : read // proper owner + ACL: logged in user has not access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + await Parse.Object.saveAll([user, user2, user3]); + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {"*": true}, get: {"*": true}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user2 has ACL read/write but should be block by ACL - return obj.fetch(); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Allow public and owners read + await schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owners'], + } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + await obj.fetch(); + done.fail('Should not succeed fetching'); + } catch (err) { + expect(err.code).toBe(101); + } + } done(); }); - }); - it('should let master key find objects', (done) => { - const config = Config.get(Parse.applicationId); - const object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find(); - }).then(() => { + it('should let master key find objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.find({useMasterKey: true}); - }).then((objects) => { - expect(objects.length).toBe(1); - done(); - }, () => { - fail('master key should find the object'); - done(); - }) - }); + const schema = await config.database.loadSchema(); + // Lock the find/get, and let only owners read + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { find: {}, get: {}, readUserFields: ['owners'] } + ); - it('should let master key get objects', (done) => { - const config = Config.get(Parse.applicationId); - const object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { const q = new Parse.Query('AnObject'); - return q.get(object.id); - }).then(() => { + const objects = await q.find(); + expect(objects.length).toBe(0); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - const q = new Parse.Query('AnObject'); - return q.get(object.id, {useMasterKey: true}); - }).then((objectAgain) => { - expect(objectAgain).not.toBeUndefined(); - expect(objectAgain.id).toBe(object.id); - done(); - }, () => { - fail('master key should find the object'); - done(); - }) - }); + try { + const objects = await q.find({ useMasterKey: true }); + expect(objects.length).toBe(1); + done(); + } catch (err) { + done.fail('master key should find the object'); + } + }); + it('should let master key get objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); - it('should let master key update objects', (done) => { - const config = Config.get(Parse.applicationId); - const object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return object.save({'hello': 'bar'}); - }).then(() => { - - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - return object.save({'hello': 'baz'}, {useMasterKey: true}); - }).then((objectAgain) => { - expect(objectAgain.get('hello')).toBe('baz'); - done(); - }, () => { - fail('master key should save the object'); - done(); - }) - }); + await object.save(); + const schema = await config.database.loadSchema(); + // Lock the find/get, and let only owners read + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { find: {}, get: {}, readUserFields: ['owners'] } + ); - it('should let master key delete objects', (done) => { - const config = Config.get(Parse.applicationId); - const object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return object.destroy(); - }).then(() => { - fail(); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - return object.destroy({useMasterKey: true}); - }).then(() => { - done(); - }, () => { - fail('master key should destroy the object'); - done(); - }) - }); + const q = new Parse.Query('AnObject'); + try { + await q.get(object.id); + done.fail(); + } catch (err) { + expect(err.code).toBe(101); + } - it('should fail with invalid pointer perms', (done) => { - const config = Config.get(Parse.applicationId); - config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.addClassIfNotExists('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: 'owner'}); - }).catch((err) => { - expect(err.code).toBe(Parse.Error.INVALID_JSON); - done(); + try { + const objectAgain = await q.get(object.id, { useMasterKey: true }); + expect(objectAgain).not.toBeUndefined(); + expect(objectAgain.id).toBe(object.id); + done(); + } catch (err) { + done.fail('master key should get the object'); + } + }); + + it('should let master key update objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { update: {}, writeUserFields: ['owners'] } + ); + + try { + await object.save({ hello: 'bar' }); + done.fail(); + } catch (err) { + expect(err.code).toBe(101); + } + + try { + const objectAgain = await object.save( + { hello: 'baz' }, + { useMasterKey: true } + ); + expect(objectAgain.get('hello')).toBe('baz'); + done(); + } catch (err) { + done.fail('master key should save the object'); + } + }); + + it('should let master key delete objects', async done => { + const config = Config.get(Parse.applicationId); + + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); + + const schema = await config.database.loadSchema(); + // Lock the delete, and let only owners write + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: ['owners'] } + ); + + try { + await object.destroy(); + done.fail(); + } catch (err) { + expect(err.code).toBe(101); + } + try { + await object.destroy({ useMasterKey: true }); + done(); + } catch (err) { + done.fail('master key should destroy the object'); + } + }); + + it('should fail with invalid pointer perms (not array)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + // Lock the delete, and let only owners write + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: 'owners' } + ); + } catch (err) { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + } + }); + + it('should fail with invalid pointer perms (non-existing field)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + // Lock the delete, and let only owners write + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: ['owners', 'invalid'] } + ); + } catch (err) { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + } }); }); - it('should fail with invalid pointer perms', (done) => { - const config = Config.get(Parse.applicationId); - config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.addClassIfNotExists('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: ['owner', 'invalid']}); - }).catch((err) => { - expect(err.code).toBe(Parse.Error.INVALID_JSON); - done(); + describe('Granular ', () => { + const className = 'AnObject'; + + const actionGet = id => new Parse.Query(className).get(id); + const actionFind = () => new Parse.Query(className).find(); + const actionCount = () => new Parse.Query(className).count(); + const actionCreate = () => new Parse.Object(className).save(); + const actionUpdate = obj => obj.save({ revision: 2 }); + const actionDelete = obj => obj.destroy(); + const actionAddFieldOnCreate = () => + new Parse.Object(className, { ['extra' + Date.now()]: 'field' }).save(); + const actionAddFieldOnUpdate = obj => + obj.save({ ['another' + Date.now()]: 'field' }); + + const OBJECT_NOT_FOUND = new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + const PERMISSION_DENIED = jasmine.stringMatching('Permission denied'); + + async function createUser(username, password = 'password') { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + + await user.save(); + + return user; + } + + async function logIn(userObject) { + return await Parse.User.logIn(userObject.getUsername(), 'password'); + } + + async function updateCLP(clp) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(className, {}, clp); + } + + describe('on single-pointer fields', () => { + /** owns: **obj1** */ + let user1; + + /** owns: **obj2** */ + let user2; + + /** owned by: **user1** */ + let obj1; + + /** owned by: **user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + obj1 = new Parse.Object(className, { + owner: user1, + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionFind()).toBeResolved(); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + it('should be allowed', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + const user3 = await createUser('user3'); + await logIn(user3); + + const p = await actionCount(); + expect(p).toBe(0); + + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionDelete(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + // For Pointer permissions create is different from other operations + // since there's no object holding the pointer before created + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + xit('should have no effect when creating object (and allowed by explicit userid permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + xit('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + owner: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith( + PERMISSION_DENIED + ); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it('should be denied when updating object for user without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); + }); + + describe('on array of pointers', () => { + /** + * owns: **obj1** + * + * moderates: **obj1** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owns: **obj3** + * + * moderates: **obj1, obj2, obj3 ** */ + let user3; + + /** + * owned by: **user1** + * + * moderated by: **user1, user2, user3** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user2, user3** */ + let obj2; + + /** + * owned by: **user3** + * + * moderated by: **user3** */ + let obj3; + + /** + * owned by: **noboody** + * + * moderated by: **nobody** */ + let objNobody; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2, user3] = await Promise.all([ + createUser('user1'), + createUser('user2'), + createUser('user3'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + obj3 = new Parse.Object(className); + objNobody = new Parse.Object(className); + + obj1.set({ + owners: [user1], + moderators: [user3, user2, user1], + revision: 0, + }); + + obj2.set({ + owners: [user2], + moderators: [user3, user2], + revision: 0, + }); + + obj3.set({ + owners: [user3], + moderators: [user3], + revision: 0, + }); + + objNobody.set({ + owners: [], + moderators: [], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2, obj3, objNobody], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionGet(obj3.id)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj2), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj2), + actionDelete(obj2), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(2); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + beforeEach(async () => { + await updateCLP({ + count: { + pointerFields: ['moderators'], + }, + }); + }); + + it('should be allowed', async done => { + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await logIn(user2); + + const count = await actionCount(); + expect(count).toBe(2); + + done(); + }); + + it('should not allow other actions', async done => { + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj3)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user3); + + await expectAsync(actionDelete(obj2)).toBeResolved(); + done(); + }); + + it('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj3)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + /* For Pointer permissions 'create' is different from other operations + since there's no object holding the pointer before created */ + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + it('should have no effect on create (allowed by explicit userid)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + it('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + moderators: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith( + PERMISSION_DENIED + ); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it('should be restricted when updating object without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj2)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); }); - }) + + describe('combined with grouped', () => { + /** + * owns: **obj1** + * + * moderates: **obj2** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owned by: **user1** + * + * moderated by: **user2** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user1, user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + // User1 owns object1 + // User2 owns object2 + obj1 = new Parse.Object(className, { + owner: user1, + moderators: [user2], + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + moderators: [user1, user2], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should not limit the scope of grouped read permissions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeResolved(); + + const found = await actionFind(); + expect(found.length).toBe(2); + + const counted = await actionCount(); + expect(counted).toBe(2); + + done(); + }); + + it('should not limit the scope of grouped write permissions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + writeUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + await expectAsync(actionDelete(obj1)).toBeResolved(); + // [create] and [addField on create] can't be enabled with pointer by design + + done(); + }); + + it('should not inherit scope of grouped read permissions from another field', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user1); + + const found = await actionFind(); + expect(found.length).toBe(1); + + const counted = await actionCount(); + expect(counted).toBe(1); + + done(); + }); + + it('should not inherit scope of grouped write permissions from another field', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + writeUserFields: ['owner'], + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj2)).toBeRejectedWith( + OBJECT_NOT_FOUND + ); + + done(); + }); + }); + + describe('using pointer-fields and queries with keys projection', () => { + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + /** + * Clear cache, create user and object, login user + */ + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { pointerFields: ['owner'] }, + update: { pointerFields: ['owner'] }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const [object] = await query.find({ objectId: obj.id }); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + }); + }); }); diff --git a/spec/PostgresConfigParser.spec.js b/spec/PostgresConfigParser.spec.js index 0cb84cf444..40aab5772f 100644 --- a/spec/PostgresConfigParser.spec.js +++ b/spec/PostgresConfigParser.spec.js @@ -1,48 +1,46 @@ -const parser = require('../src/Adapters/Storage/Postgres/PostgresConfigParser'); +const parser = require('../lib/Adapters/Storage/Postgres/PostgresConfigParser'); const queryParamTests = { 'a=1&b=2': { a: '1', b: '2' }, 'a=abcd%20efgh&b=abcd%3Defgh': { a: 'abcd efgh', b: 'abcd=efgh' }, - 'a=1&b&c=true': { a: '1', b: '', c: 'true' } -} + 'a=1&b&c=true': { a: '1', b: '', c: 'true' }, +}; describe('PostgresConfigParser.parseQueryParams', () => { it('creates a map from a query string', () => { - for (const key in queryParamTests) { const result = parser.parseQueryParams(key); const testObj = queryParamTests[key]; - expect(Object.keys(result).length) - .toEqual(Object.keys(testObj).length); + expect(Object.keys(result).length).toEqual(Object.keys(testObj).length); for (const k in result) { expect(result[k]).toEqual(testObj[k]); } } - - }) + }); }); -const baseURI = 'postgres://username:password@localhost:5432/db-name' +const baseURI = 'postgres://username:password@localhost:5432/db-name'; const dbOptionsTest = {}; -dbOptionsTest[`${baseURI}?ssl=true&binary=true&application_name=app_name&fallback_application_name=f_app_name&poolSize=10`] = { +dbOptionsTest[ + `${baseURI}?ssl=true&binary=true&application_name=app_name&fallback_application_name=f_app_name&poolSize=10` +] = { ssl: true, binary: true, application_name: 'app_name', fallback_application_name: 'f_app_name', - poolSize: 10 + poolSize: 10, }; dbOptionsTest[`${baseURI}?ssl=&binary=aa`] = { ssl: false, - binary: false -} + binary: false, +}; describe('PostgresConfigParser.getDatabaseOptionsFromURI', () => { it('creates a db options map from a query string', () => { - for (const key in dbOptionsTest) { const result = parser.getDatabaseOptionsFromURI(key); @@ -52,14 +50,11 @@ describe('PostgresConfigParser.getDatabaseOptionsFromURI', () => { expect(result[k]).toEqual(testObj[k]); } } - }); it('sets the poolSize to 10 if the it is not a number', () => { - const result = parser.getDatabaseOptionsFromURI(`${baseURI}?poolSize=sdf`); expect(result.poolSize).toEqual(10); - }); }); diff --git a/spec/PostgresInitOptions.spec.js b/spec/PostgresInitOptions.spec.js index 7feb30c970..956d8e543a 100644 --- a/spec/PostgresInitOptions.spec.js +++ b/spec/PostgresInitOptions.spec.js @@ -1,51 +1,48 @@ const Parse = require('parse/node').Parse; -const PostgresStorageAdapter = require('../src/Adapters/Storage/Postgres/PostgresStorageAdapter'); -const postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; -const ParseServer = require("../src/index"); +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const postgresURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +const ParseServer = require('../lib/index'); const express = require('express'); //public schema const databaseOptions1 = { initOptions: { - connect: function (client, dc, isFresh) { - if (isFresh) { - client.query('SET search_path = public'); - } - } - } + schema: 'public', + }, }; //not exists schema const databaseOptions2 = { initOptions: { - connect: function (client, dc, isFresh) { - if (isFresh) { - client.query('SET search_path = not_exists_schema'); - } - } - } + schema: 'not_exists_schema', + }, }; const GameScore = Parse.Object.extend({ - className: "GameScore" + className: 'GameScore', }); function createParseServer(options) { return new Promise((resolve, reject) => { - const parseServer = new ParseServer.default(Object.assign({}, - defaultConfiguration, options, { - serverURL: "http://localhost:12666/parse", - __indexBuildCompletionCallbackForTests: promise => { - promise - .then(() => { - expect(Parse.applicationId).toEqual("test"); - var app = express(); - app.use('/parse', parseServer.app); + const parseServer = new ParseServer.default( + Object.assign({}, defaultConfiguration, options, { + serverURL: 'http://localhost:12666/parse', + serverStartComplete: error => { + if (error) { + reject(error); + } else { + expect(Parse.applicationId).toEqual('test'); + const app = express(); + app.use('/parse', parseServer.app); - const server = app.listen(12666); - Parse.serverURL = "http://localhost:12666/parse"; - resolve(server); - }, reject); - }})); + const server = app.listen(12666); + Parse.serverURL = 'http://localhost:12666/parse'; + resolve(server); + } + }, + }) + ); }); } @@ -56,27 +53,37 @@ describe_only_db('postgres')('Postgres database init options', () => { if (server) { server.close(); } - }) + }); - it('should create server with public schema databaseOptions', (done) => { + it('should create server with public schema databaseOptions', done => { const adapter = new PostgresStorageAdapter({ - uri: postgresURI, collectionPrefix: 'test_', - databaseOptions: databaseOptions1 - }) + uri: postgresURI, + collectionPrefix: 'test_', + databaseOptions: databaseOptions1, + }); - createParseServer({ databaseAdapter: adapter }).then((newServer) => { - server = newServer; - var score = new GameScore({ "score": 1337, "playerName": "Sean Plott", "cheatMode": false }); - return score.save(); - }).then(done, done.fail); + createParseServer({ databaseAdapter: adapter }) + .then(newServer => { + server = newServer; + const score = new GameScore({ + score: 1337, + playerName: 'Sean Plott', + cheatMode: false, + }); + return score.save(); + }) + .then(done, done.fail); }); - it('should fail to create server if schema databaseOptions does not exist', (done) => { + it('should fail to create server if schema databaseOptions does not exist', done => { const adapter = new PostgresStorageAdapter({ - uri: postgresURI, collectionPrefix: 'test_', - databaseOptions: databaseOptions2 - }) + uri: postgresURI, + collectionPrefix: 'test_', + databaseOptions: databaseOptions2, + }); - createParseServer({ databaseAdapter: adapter }).then(done.fail, done); + createParseServer({ databaseAdapter: adapter }).then(done.fail, () => + done() + ); }); }); diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js new file mode 100644 index 0000000000..e722fa4cec --- /dev/null +++ b/spec/PostgresStorageAdapter.spec.js @@ -0,0 +1,156 @@ +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const databaseURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +const Config = require('../lib/Config'); + +const getColumns = (client, className) => { + return client.map( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $', + { className }, + a => a.column_name + ); +}; + +const dropTable = (client, className) => { + return client.none('DROP TABLE IF EXISTS $', { className }); +}; + +describe_only_db('postgres')('PostgresStorageAdapter', () => { + let adapter; + beforeEach(() => { + const config = Config.get('test'); + adapter = config.database.adapter; + return adapter.deleteAllClasses(); + }); + + it('schemaUpgrade, upgrade the database schema when schema changes', done => { + const client = adapter._client; + const className = '_PushStatus'; + const schema = { + fields: { + pushTime: { type: 'String' }, + source: { type: 'String' }, + query: { type: 'String' }, + }, + }; + + adapter + .createTable(className, schema) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns).toContain('pushTime'); + expect(columns).toContain('source'); + expect(columns).toContain('query'); + expect(columns).not.toContain('expiration_interval'); + + schema.fields.expiration_interval = { type: 'Number' }; + return adapter.schemaUpgrade(className, schema); + }) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns).toContain('pushTime'); + expect(columns).toContain('source'); + expect(columns).toContain('query'); + expect(columns).toContain('expiration_interval'); + done(); + }) + .catch(error => done.fail(error)); + }); + + it('schemaUpgrade, maintain correct schema', done => { + const client = adapter._client; + const className = 'Table'; + const schema = { + fields: { + columnA: { type: 'String' }, + columnB: { type: 'String' }, + columnC: { type: 'String' }, + }, + }; + + adapter + .createTable(className, schema) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + expect(columns).toContain('columnC'); + + return adapter.schemaUpgrade(className, schema); + }) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toEqual(3); + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + expect(columns).toContain('columnC'); + done(); + }) + .catch(error => done.fail(error)); + }); + + it('Create a table without columns and upgrade with columns', done => { + const client = adapter._client; + const className = 'EmptyTable'; + dropTable(client, className) + .then(() => adapter.createTable(className, {})) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toBe(0); + + const newSchema = { + fields: { + columnA: { type: 'String' }, + columnB: { type: 'String' }, + }, + }; + + return adapter.schemaUpgrade(className, newSchema); + }) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toEqual(2); + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + done(); + }) + .catch(done); + }); + + it('getClass if exists', async () => { + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + await adapter.createClass('MyClass', schema); + const myClassSchema = await adapter.getClass('MyClass'); + expect(myClassSchema).toBeDefined(); + }); + + it('getClass if not exists', async () => { + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + await adapter.createClass('MyClass', schema); + await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith( + undefined + ); + }); +}); + +describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => { + it('handleShutdown, close connection', () => { + const adapter = new PostgresStorageAdapter({ uri: databaseURI }); + expect(adapter._client.$pool.ending).toEqual(false); + adapter.handleShutdown(); + expect(adapter._client.$pool.ending).toEqual(true); + }); +}); diff --git a/spec/PromiseRouter.spec.js b/spec/PromiseRouter.spec.js index 0ee92c823c..51a4ce21a1 100644 --- a/spec/PromiseRouter.spec.js +++ b/spec/PromiseRouter.spec.js @@ -1,25 +1,33 @@ -var PromiseRouter = require("../src/PromiseRouter").default; +const PromiseRouter = require('../lib/PromiseRouter').default; -describe("PromiseRouter", () => { - it("should properly handle rejects", (done) => { - var router = new PromiseRouter(); - router.route("GET", "/dummy", ()=> { - return Promise.reject({ - error: "an error", - code: -1 - }) - }, () => { - fail("this should not be called"); - }); +describe('PromiseRouter', () => { + it('should properly handle rejects', done => { + const router = new PromiseRouter(); + router.route( + 'GET', + '/dummy', + () => { + return Promise.reject({ + error: 'an error', + code: -1, + }); + }, + () => { + fail('this should not be called'); + } + ); - router.routes[0].handler({}).then((result) => { - jfail(result); - fail("this should not be called"); - done(); - }, (error)=> { - expect(error.error).toEqual("an error"); - expect(error.code).toEqual(-1); - done(); - }); + router.routes[0].handler({}).then( + result => { + jfail(result); + fail('this should not be called'); + done(); + }, + error => { + expect(error.error).toEqual('an error'); + expect(error.code).toEqual(-1); + done(); + } + ); }); -}) +}); diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js new file mode 100644 index 0000000000..fdc3c2d10a --- /dev/null +++ b/spec/ProtectedFields.spec.js @@ -0,0 +1,1735 @@ +const Config = require('../lib/Config'); +const Parse = require('parse/node'); +const request = require('../lib/request'); +const { + className, + createRole, + createUser, + logIn, + updateCLP, +} = require('./dev'); + +describe('ProtectedFields', function() { + it('should handle and empty protectedFields', async function() { + const protectedFields = {}; + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('favoriteColor', 'yellow'); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + + describe('interaction with legacy userSensitiveFields', function() { + it('should fall back on sensitive fields if protected fields are not configured', async function() { + const userSensitiveFields = ['phoneNumber', 'timeZone']; + + const protectedFields = { _User: { '*': ['email'] } }; + + await reconfigureServer({ userSensitiveFields, protectedFields }); + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('phoneNumber')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + + it('should merge protected and sensitive for extra safety', async function() { + const userSensitiveFields = ['phoneNumber', 'timeZone']; + + const protectedFields = { _User: { '*': ['email', 'favoriteFood'] } }; + + await reconfigureServer({ userSensitiveFields, protectedFields }); + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('phoneNumber')).toBeFalsy(); + expect(fetched.has('favoriteFood')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + }); + + describe('non user class', function() { + it('should hide fields in a non user class', async function() { + const protectedFields = { + ClassA: { '*': ['foo'] }, + ClassB: { '*': ['bar'] }, + }; + await reconfigureServer({ protectedFields }); + + const objA = await new Parse.Object('ClassA') + .set('foo', 'zzz') + .set('bar', 'yyy') + .save(); + + const objB = await new Parse.Object('ClassB') + .set('foo', 'zzz') + .set('bar', 'yyy') + .save(); + + const [fetchedA, fetchedB] = await Promise.all([ + new Parse.Query('ClassA').get(objA.id), + new Parse.Query('ClassB').get(objB.id), + ]); + + expect(fetchedA.has('foo')).toBeFalsy(); + expect(fetchedA.has('bar')).toBeTruthy(); + + expect(fetchedB.has('foo')).toBeTruthy(); + expect(fetchedB.has('bar')).toBeFalsy(); + }); + + it('should hide fields in non user class and non standard user field at same time', async function() { + const protectedFields = { + _User: { '*': ['phoneNumber'] }, + ClassA: { '*': ['foo'] }, + ClassB: { '*': ['bar'] }, + }; + + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + await user.save(); + + const objA = await new Parse.Object('ClassA') + .set('foo', 'zzz') + .set('bar', 'yyy') + .save(); + + const objB = await new Parse.Object('ClassB') + .set('foo', 'zzz') + .set('bar', 'yyy') + .save(); + + const [fetchedUser, fetchedA, fetchedB] = await Promise.all([ + new Parse.Query(Parse.User).get(user.id), + new Parse.Query('ClassA').get(objA.id), + new Parse.Query('ClassB').get(objB.id), + ]); + + expect(fetchedA.has('foo')).toBeFalsy(); + expect(fetchedA.has('bar')).toBeTruthy(); + + expect(fetchedB.has('foo')).toBeTruthy(); + expect(fetchedB.has('bar')).toBeFalsy(); + + expect(fetchedUser.has('email')).toBeFalsy(); + expect(fetchedUser.has('phoneNumber')).toBeFalsy(); + expect(fetchedUser.has('favoriteColor')).toBeTruthy(); + }); + }); + + describe('using the pointer-permission variant', () => { + let user1, user2; + beforeEach(async () => { + Config.get(Parse.applicationId).database.schemaCache.clear(); + user1 = await Parse.User.signUp('user1', 'password'); + user2 = await Parse.User.signUp('user2', 'password'); + await Parse.User.logOut(); + }); + + describe('and get/fetch', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner').id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + let objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[0].id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + await Parse.User.logIn('user2', 'password'); + objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[1].id).toBe(user2.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should intersect protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners').length).toBe(1); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).not.toBe(undefined); + expect(objectAgain.get('owner')).not.toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + }); + + describe('and find', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner').id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner').id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[0].id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[0].id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + + await Parse.User.logIn('user2', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[1].id).toBe(user2.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[1].id).toBe(user2.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should intersect protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners').length).toBe(1); + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).not.toBe(undefined); + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).not.toBe(undefined); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should filter only fields from objects not owned by the user', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + const obj3 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user2); + obj2.set('test', 'test2'); + obj3.set('owner', user2); + obj3.set('test', 'test3'); + await Parse.Object.saveAll([obj, obj2, obj3]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner'], + 'userField:owner': [], + }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + + await Parse.User.logIn('user2', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).not.toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + done(); + }); + }); + }); + + describe('schema setup', () => { + let object; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + object = new Parse.Object(className); + + object.set('revision', 0); + object.set('test', 'test'); + + await object.save(null, { useMasterKey: true }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should fail setting non-existing protected field', async done => { + const field = 'non-existing'; + const entity = '*'; + + await expectAsync( + updateCLP({ + protectedFields: { + [entity]: [field], + }, + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `Field '${field}' in protectedFields:${entity} does not exist` + ) + ); + done(); + }); + + it('should allow setting authenticated', async () => { + await expectAsync( + updateCLP({ + protectedFields: { + authenticated: ['test'], + }, + }) + ).toBeResolved(); + }); + + it('should not allow protecting default fields', async () => { + const defaultFields = ['objectId', 'createdAt', 'updatedAt', 'ACL']; + for (const field of defaultFields) { + await expectAsync( + updateCLP({ + protectedFields: { + '*': [field], + }, + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `Default field '${field}' can not be protected` + ) + ); + } + }); + }); + + describe('targeting public access', () => { + let obj1; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + obj1 = new Parse.Object(className); + + obj1.set('foo', 'foo'); + obj1.set('bar', 'bar'); + obj1.set('qux', 'qux'); + + await obj1.save(null, { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should hide mutiple fields', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo', 'bar'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBe(undefined); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBeDefined(); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + }); + + describe('targeting authenticated', () => { + /** + * is **owner** of: _obj1_ + * + * is **tester** of: [ _obj1, obj2_ ] + */ + let user1; + + /** + * is **owner** of: _obj2_ + * + * is **tester** of: [ _obj1_ ] + */ + let user2; + + /** + * **owner**: _user1_ + * + * **testers**: [ _user1,user2_ ] + */ + let obj1; + + /** + * **owner**: _user2_ + * + * **testers**: [ _user1_ ] + */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + await Parse.User.logOut(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: [], + }, + }); + + // authenticated + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide fields for authenticated users only (* not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // not authenticated + const objectNonAuth = await obj1.fetch(); + + expect(objectNonAuth.get('test')).toBeDefined(); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + + done(); + }); + + it('should intersect public and auth for authenticated user', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner', 'testers'], + authenticated: ['testers'], + }, + }); + + // authenticated + await logIn(user1); + const objectAuth = await obj1.fetch(); + + // ( {A,B} intersect {B} ) == {B} + + expect(objectAuth.get('testers')).not.toBeDefined( + 'Should not be visible - protected for * and authenticated' + ); + expect(objectAuth.get('test')).toBeDefined( + 'Should be visible - not protected for everyone (* and authenticated)' + ); + expect(objectAuth.get('owner')).toBeDefined( + 'Should be visible - not protected for authenticated' + ); + + done(); + }); + + it('should have higher prio than public for logged in users (intersect)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test'], + authenticated: [], + }, + }); + // authenticated, permitted + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should have no effect on unauthenticated users (public not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // unauthenticated, protected + const objectNonAuth = await obj1.fetch(); + expect(objectNonAuth.get('test')).toBe('test'); + + done(); + }); + + it('should protect multiple fields for authenticated users', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test', 'owner'], + }, + }); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + expect(object.get('owner')).toBe(undefined); + + done(); + }); + + it('should not be affected by rules not applicable to user (smoke)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['owner', 'testers'], + [`role:${roleName}`]: ['test'], + 'userField:owner': [], + [user1.id]: [], + }, + }); + + // authenticated, non-owner, no role + await logIn(user2); + const objectNotOwned = await obj1.fetch(); + + expect(objectNotOwned.get('owner')).toBe(undefined); + expect(objectNotOwned.get('testers')).toBe(undefined); + expect(objectNotOwned.get('test')).toBeDefined(); + + done(); + }); + }); + + describe('targeting roles', () => { + let user1, user2; + + /** + * owner: user1 + * + * testers: [user1,user2] + */ + let obj1; + + /** + * owner: user2 + * + * testers: [user1] + */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + [user1, user2] = await Promise.all([ + createUser('user1'), + createUser('user2'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe(undefined); // field protected + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: [], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide multiple fields when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('test')).toBe( + undefined, + 'Field should not be visible - protected by role' + ); + expect(object.get('owner')).toBe( + undefined, + 'Field should not be visible - protected by role' + ); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not protect when user does not belong to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user doesn't have role + await logIn(user2); + const object = await obj1.fetch(); + + expect(object.get('test')).toBeDefined(); + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles', async done => { + const role1 = await createRole({ users: user1 }); + const role2 = await createRole({ users: user1 }); + + const role1name = role1.get('name'); + const role2name = role2.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${role1name}`]: ['owner'], + [`role:${role2name}`]: ['test', 'owner'], + }, + }); + + // user has both roles + await logIn(user1); + const object = await obj1.fetch(); + + // "owner" is a result of intersection + expect(object.get('owner')).toBe( + undefined, + 'Must not be visible - protected for all roles the user belongs to' + ); + expect(object.get('test')).toBeDefined( + 'Has to be visible - is not protected for users with role1' + ); + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles hierarchy', async done => { + const admin = await createRole({ + users: user1, + roleName: 'admin', + }); + + const moder = await createRole({ + users: [user1, user2], + roleName: 'moder', + }); + + const tester = await createRole({ + roleName: 'tester', + }); + + // admin supersets moder role + moder.relation('roles').add(admin); + await moder.save(null, { useMasterKey: true }); + + tester.relation('roles').add(moder); + await tester.save(null, { useMasterKey: true }); + + const roleAdmin = `role:${admin.get('name')}`; + const roleModer = `role:${moder.get('name')}`; + const roleTester = `role:${tester.get('name')}`; + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [roleAdmin]: [], + [roleModer]: ['owner'], + [roleTester]: ['test', 'owner'], + }, + }); + + // user1 has admin & moder & tester roles, (moder includes tester). + await logIn(user1); + const object = await obj1.fetch(); + + // being admin makes all fields visible + expect(object.get('test')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + expect(object.get('owner')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + + // user2 has moder & tester role, moder includes tester. + await logIn(user2); + const objectAgain = await obj1.fetch(); + + // being moder allows "test" field + expect(objectAgain.get('owner')).toBe( + undefined, + '"owner" should not be visible - protected for each role user belongs to' + ); + expect(objectAgain.get('test')).toBeDefined( + 'Should be visible - moder role does not protect "test" field' + ); + + done(); + }); + + it('should be able to clear protected fields for role (protected for authenticated)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: [], + }, + }); + + // user has role, test field visible + await logIn(user1); + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should determine protectedFields as intersection of field sets for public and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test', 'owner'], + [`role:${roleName}`]: ['owner', 'testers'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Should be visible - "test" is not protected for role user belongs to' + ); + expect(object.get('testers')).toBeDefined( + 'Should be visible - "testers" is allowed for everyone (*)' + ); + expect(object.get('owner')).toBe( + undefined, + 'Should not be visible - "test" is not allowed for both public(*) and role' + ); + done(); + }); + + it('should be determined as an intersection of protecedFields for authenticated and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + // this is an example of misunderstood configuration. + // If you allow (== do not restrict) some field for broader audience + // (having a role implies user inheres to 'authenticated' group) + // it's not possible to narrow by protecting field for a role. + // You'd have to protect it for 'authenticated' as well. + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: ['owner'], + }, + }); + + // user has role + await logIn(user1); + const object = await obj1.fetch(); + + // + expect(object.get('test')).toBeDefined( + "Being both auhenticated and having a role leads to clearing protection on 'test' (by role rules)" + ); + expect(object.get('owner')).toBeDefined( + 'All authenticated users allowed to see "owner"' + ); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide fields when user does not belong to a role protectedFields set for', async done => { + const role = await createRole({ users: user2 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + }); + + // relate user1 to some role, no protectedFields for it + await createRole({ users: user1 }); + + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Field should be visible - user belongs to a role that has no protectedFields set' + ); + + done(); + }); + }); + + describe('using pointer-fields and queries with keys projection', () => { + /* + * Pointer variant ("userField:column") relies on User ids + * returned after query executed (hides fields before sending it to client) + * If such column is excluded/not included (not returned from db because of 'project') + * there will be no user ids to check against + * and protectedFields won't be applied correctly. + */ + + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + let headers; + + /** + * Clear cache, create user and object, login user and setup rest headers with token + */ + async function initialize() { + await Config.get(Parse.applicationId).database.schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + // await user1.fetch(); + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + headers = { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', + 'X-Parse-Session-Token': user1.getSessionToken(), + }; + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST GET)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + qs: { + keys: 'field,test', + }, + headers: headers, + }); + + expect(object.field).toBe( + 'field', + 'Should BE in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should NOT be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST FIND)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data } = await request({ + url: `${Parse.serverURL}/classes/${className}`, + qs: { + keys: 'field,test', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is in excludeKeys (REST GET)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + qs: { + excludeKeys: 'owner', + }, + headers, + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + }); + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object['test']).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object['owner']).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + it('should protect fields for query where pointer field is in excludedKeys (REST FIND)', async done => { + await updateCLP({ + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + const { data } = await request({ + qs: { + excludeKeys: 'owner', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + url: `${Parse.serverURL}/classes/${className}`, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe( + undefined, + 'Should not be in response - not included in "keys"' + ); + done(); + }); + + xit('todo: should be enforced regardless of pointer-field being excluded', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + + /* TODO: this has some caching problems on JS-SDK (2.11.) side */ + // query.exclude('owner') + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + expect(object.get('owner')).toBe(undefined); + done(); + }); + }); +}); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index f34ea0af55..22c1383d5a 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -1,65 +1,211 @@ +const req = require('../lib/request'); -var request = require('request'); +const request = function(url, callback) { + return req({ + url, + }).then(response => callback(null, response), err => callback(err, err)); +}; -describe("public API", () => { - it("should get invalid_link.html", (done) => { - request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(200); - done(); +describe('public API', () => { + it('should return missing username error on ajax request without username provided', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/1', + }); + + try { + await req({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=43634643&username=`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual('{"code":200,"error":"Missing username"}'); + } + }); + + it('should return missing token error on ajax request without token provided', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/1', }); + + try { + await req({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=&username=Johnny`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual('{"code":-1,"error":"Missing token"}'); + } }); - it("should get choose_password", (done) => { + it('should return missing password error on ajax request without password provided', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/1', + }); + + try { + await req({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=&token=132414&username=Johnny`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual('{"code":201,"error":"Missing password"}'); + } + }); + + it('should get invalid_link.html', done => { + request( + 'http://localhost:8378/1/apps/invalid_link.html', + (err, httpResponse) => { + expect(httpResponse.status).toBe(200); + done(); + } + ); + }); + + it('should get choose_password', done => { reconfigureServer({ appName: 'unused', publicServerURL: 'http://localhost:8378/1', - }) - .then(() => { - request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(200); + }).then(() => { + request( + 'http://localhost:8378/1/apps/choose_password?id=test', + (err, httpResponse) => { + expect(httpResponse.status).toBe(200); done(); - }); - }) + } + ); + }); }); - it("should get verify_email_success.html", (done) => { - request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); + it('should get verify_email_success.html', done => { + request( + 'http://localhost:8378/1/apps/verify_email_success.html', + (err, httpResponse) => { + expect(httpResponse.status).toBe(200); + done(); + } + ); }); - it("should get password_reset_success.html", (done) => { - request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); + it('should get password_reset_success.html', done => { + request( + 'http://localhost:8378/1/apps/password_reset_success.html', + (err, httpResponse) => { + expect(httpResponse.status).toBe(200); + done(); + } + ); }); }); -describe("public API without publicServerURL", () => { +describe('public API without publicServerURL', () => { beforeEach(done => { - reconfigureServer({ appName: 'unused' }) - .then(done, fail); + reconfigureServer({ appName: 'unused' }).then(done, fail); }); - it("should get 404 on verify_email", (done) => { - request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(404); - done(); - }); + it('should get 404 on verify_email', done => { + request( + 'http://localhost:8378/1/apps/test/verify_email', + (err, httpResponse) => { + expect(httpResponse.status).toBe(404); + done(); + } + ); }); - it("should get 404 choose_password", (done) => { - request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(404); - done(); - }); + it('should get 404 choose_password', done => { + request( + 'http://localhost:8378/1/apps/choose_password?id=test', + (err, httpResponse) => { + expect(httpResponse.status).toBe(404); + done(); + } + ); }); - it("should get 404 on request_password_reset", (done) => { - request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse) => { - expect(httpResponse.statusCode).toBe(404); + it('should get 404 on request_password_reset', done => { + request( + 'http://localhost:8378/1/apps/test/request_password_reset', + (err, httpResponse) => { + expect(httpResponse.status).toBe(404); + done(); + } + ); + }); +}); + +describe('public API supplied with invalid application id', () => { + beforeEach(done => { + reconfigureServer({ appName: 'unused' }).then(done, fail); + }); + + it('should get 403 on verify_email', done => { + request( + 'http://localhost:8378/1/apps/invalid/verify_email', + (err, httpResponse) => { + expect(httpResponse.status).toBe(403); + done(); + } + ); + }); + + it('should get 403 choose_password', done => { + request( + 'http://localhost:8378/1/apps/choose_password?id=invalid', + (err, httpResponse) => { + expect(httpResponse.status).toBe(403); + done(); + } + ); + }); + + it('should get 403 on get of request_password_reset', done => { + request( + 'http://localhost:8378/1/apps/invalid/request_password_reset', + (err, httpResponse) => { + expect(httpResponse.status).toBe(403); + done(); + } + ); + }); + + it('should get 403 on post of request_password_reset', done => { + req({ + url: 'http://localhost:8378/1/apps/invalid/request_password_reset', + method: 'POST', + }).then(done.fail, httpResponse => { + expect(httpResponse.status).toBe(403); done(); }); }); + + it('should get 403 on resendVerificationEmail', done => { + request( + 'http://localhost:8378/1/apps/invalid/resend_verification_email', + (err, httpResponse) => { + expect(httpResponse.status).toBe(403); + done(); + } + ); + }); }); diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js index 01987c6843..f3cf64eb8f 100644 --- a/spec/PurchaseValidation.spec.js +++ b/spec/PurchaseValidation.spec.js @@ -1,208 +1,219 @@ -var request = require("request"); +const request = require('../lib/request'); function createProduct() { - const file = new Parse.File("name", { - base64: new Buffer("download_file", "utf-8").toString("base64") - }, "text"); - return file.save().then(function(){ - var product = new Parse.Object("_Product"); + const file = new Parse.File( + 'name', + { + base64: new Buffer('download_file', 'utf-8').toString('base64'), + }, + 'text' + ); + return file.save().then(function() { + const product = new Parse.Object('_Product'); product.set({ download: file, icon: file, - title: "a product", - subtitle: "a product", + title: 'a product', + subtitle: 'a product', order: 1, - productIdentifier: "a-product" - }) + productIdentifier: 'a-product', + }); return product.save(); - }) - + }); } -describe("test validate_receipt endpoint", () => { +describe('test validate_receipt endpoint', () => { beforeEach(done => { - createProduct().then(done).fail(function(){ - done(); - }); - }) - - it("should bypass appstore validation", (done) => { + createProduct() + .then(done) + .catch(function(err) { + console.error({ err }); + done(); + }); + }); - request.post({ + it('should bypass appstore validation', async () => { + const httpResponse = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + method: 'POST', url: 'http://localhost:8378/1/validate_purchase', - json: true, body: { - productIdentifier: "a-product", + productIdentifier: 'a-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.__type).toEqual("File"); - const url = body.url; - request.get({ - url: url - }, function(err, res, body) { - expect(body).toEqual("download_file"); - done(); - }); - } + bypassAppStoreValidation: true, + }, }); + const body = httpResponse.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.__type).toEqual('File'); + const url = body.url; + const otherResponse = await request({ + url: url, + }); + expect(otherResponse.text).toBe('download_file'); + } }); - it("should fail for missing receipt", (done) => { - request.post({ + it('should fail for missing receipt', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "a-product", - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.INVALID_JSON); - done(); - } - }); + productIdentifier: 'a-product', + bypassAppStoreValidation: true, + }, + }).then(fail, res => res); + const body = response.data; + expect(body.code).toEqual(Parse.Error.INVALID_JSON); }); - it("should fail for missing product identifier", (done) => { - request.post({ + it('should fail for missing product identifier', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.INVALID_JSON); - done(); - } - }); + bypassAppStoreValidation: true, + }, + }).then(fail, res => res); + const body = response.data; + expect(body.code).toEqual(Parse.Error.INVALID_JSON); }); - it("should bypass appstore validation and not find product", (done) => { - - request.post({ + it('should bypass appstore validation and not find product', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "another-product", + productIdentifier: 'another-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - expect(body.error).toEqual('Object not found.'); - done(); - } - }); + bypassAppStoreValidation: true, + }, + }).catch(error => error); + const body = response.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(body.error).toEqual('Object not found.'); + } }); - it("should fail at appstore validation", done => { - request.post({ + it('should fail at appstore validation', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "a-product", + productIdentifier: 'a-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - } else { - expect(body.status).toBe(21002); - expect(body.error).toBe('The data in the receipt-data property was malformed or missing.'); - } - done(); + }, }); + const body = response.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.status).toBe(21002); + expect(body.error).toBe( + 'The data in the receipt-data property was malformed or missing.' + ); + } }); - it("should not create a _Product", (done) => { - var product = new Parse.Object("_Product"); - product.save().then(function(){ - fail("Should not be able to save"); - done(); - }, function(err){ - expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); - done(); - }) + it('should not create a _Product', done => { + const product = new Parse.Object('_Product'); + product.save().then( + function() { + fail('Should not be able to save'); + done(); + }, + function(err) { + expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); + done(); + } + ); }); - it("should be able to update a _Product", (done) => { - var query = new Parse.Query("_Product"); - query.first().then(function(product) { - if (!product) { - return Promise.reject(new Error('Product should be found')); - } - product.set("title", "a new title"); - return product.save(); - }).then(function(productAgain){ - expect(productAgain.get('downloadName')).toEqual(productAgain.get('download').name()); - expect(productAgain.get("title")).toEqual("a new title"); - done(); - }).fail(function(err){ - fail(JSON.stringify(err)); - done(); - }); + it('should be able to update a _Product', done => { + const query = new Parse.Query('_Product'); + query + .first() + .then(function(product) { + if (!product) { + return Promise.reject(new Error('Product should be found')); + } + product.set('title', 'a new title'); + return product.save(); + }) + .then(function(productAgain) { + expect(productAgain.get('downloadName')).toEqual( + productAgain.get('download').name() + ); + expect(productAgain.get('title')).toEqual('a new title'); + done(); + }) + .catch(function(err) { + fail(JSON.stringify(err)); + done(); + }); }); - it("should not be able to remove a require key in a _Product", (done) => { - var query = new Parse.Query("_Product"); - query.first().then(function(product){ - if (!product) { - return Promise.reject(new Error('Product should be found')); - } - product.unset("title"); - return product.save(); - }).then(function(){ - fail("Should not succeed"); - done(); - }).fail(function(err){ - expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(err.message).toEqual("title is required."); - done(); - }); + it('should not be able to remove a require key in a _Product', done => { + const query = new Parse.Query('_Product'); + query + .first() + .then(function(product) { + if (!product) { + return Promise.reject(new Error('Product should be found')); + } + product.unset('title'); + return product.save(); + }) + .then(function() { + fail('Should not succeed'); + done(); + }) + .catch(function(err) { + expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(err.message).toEqual('title is required.'); + done(); + }); }); }); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 23ce7a60f7..abed0577a1 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -1,453 +1,609 @@ -"use strict"; -var PushController = require('../src/Controllers/PushController').PushController; -var StatusHandler = require('../src/StatusHandler'); -var Config = require('../src/Config'); -var validatePushType = require('../src/Push/utils').validatePushType; +'use strict'; +const PushController = require('../lib/Controllers/PushController') + .PushController; +const StatusHandler = require('../lib/StatusHandler'); +const Config = require('../lib/Config'); +const validatePushType = require('../lib/Push/utils').validatePushType; const successfulTransmissions = function(body, installations) { - - const promises = installations.map((device) => { + const promises = installations.map(device => { return Promise.resolve({ transmitted: true, device: device, - }) + }); }); return Promise.all(promises); -} +}; const successfulIOS = function(body, installations) { - - const promises = installations.map((device) => { + const promises = installations.map(device => { return Promise.resolve({ - transmitted: device.deviceType == "ios", + transmitted: device.deviceType == 'ios', device: device, - }) + }); }); return Promise.all(promises); -} +}; describe('PushController', () => { - it('can validate device type when no device type is set', (done) => { + it('can validate device type when no device type is set', done => { // Make query condition - var where = { - }; - var validPushTypes = ['ios', 'android']; + const where = {}; + const validPushTypes = ['ios', 'android']; - expect(function(){ + expect(function() { validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can validate device type when single valid device type is set', (done) => { + it('can validate device type when single valid device type is set', done => { // Make query condition - var where = { - 'deviceType': 'ios' + const where = { + deviceType: 'ios', }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ + expect(function() { validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can validate device type when multiple valid device types are set', (done) => { + it('can validate device type when multiple valid device types are set', done => { // Make query condition - var where = { - 'deviceType': { - '$in': ['android', 'ios'] - } + const where = { + deviceType: { + $in: ['android', 'ios'], + }, }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ + expect(function() { validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can throw on validateDeviceType when single invalid device type is set', (done) => { + it('can throw on validateDeviceType when single invalid device type is set', done => { // Make query condition - var where = { - 'deviceType': 'osx' + const where = { + deviceType: 'osx', }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ + expect(function() { validatePushType(where, validPushTypes); }).toThrow(); done(); }); - it('can throw on validateDeviceType when single invalid device type is set', (done) => { + it('can throw on validateDeviceType when single invalid device type is set', done => { // Make query condition - var where = { - 'deviceType': 'osx' + const where = { + deviceType: 'osx', }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ + expect(function() { validatePushType(where, validPushTypes); }).toThrow(); done(); }); - it('can get expiration time in string format', (done) => { + it('can get expiration time in string format', done => { // Make mock request - var timeStr = '2015-03-19T22:05:08Z'; - var body = { - 'expiration_time': timeStr - } + const timeStr = '2015-03-19T22:05:08Z'; + const body = { + expiration_time: timeStr, + }; - var time = PushController.getExpirationTime(body); + const time = PushController.getExpirationTime(body); expect(time).toEqual(new Date(timeStr).valueOf()); done(); }); - it('can get expiration time in number format', (done) => { + it('can get expiration time in number format', done => { // Make mock request - var timeNumber = 1426802708; - var body = { - 'expiration_time': timeNumber - } + const timeNumber = 1426802708; + const body = { + expiration_time: timeNumber, + }; - var time = PushController.getExpirationTime(body); + const time = PushController.getExpirationTime(body); expect(time).toEqual(timeNumber * 1000); done(); }); - it('can throw on getExpirationTime in invalid format', (done) => { + it('can throw on getExpirationTime in invalid format', done => { // Make mock request - var body = { - 'expiration_time': 'abcd' - } + const body = { + expiration_time: 'abcd', + }; - expect(function(){ + expect(function() { PushController.getExpirationTime(body); }).toThrow(); done(); }); - it('can get push time in string format', (done) => { + it('can get push time in string format', done => { // Make mock request - var timeStr = '2015-03-19T22:05:08Z'; - var body = { - 'push_time': timeStr - } + const timeStr = '2015-03-19T22:05:08Z'; + const body = { + push_time: timeStr, + }; - var { date } = PushController.getPushTime(body); + const { date } = PushController.getPushTime(body); expect(date).toEqual(new Date(timeStr)); done(); }); - it('can get push time in number format', (done) => { + it('can get push time in number format', done => { // Make mock request - var timeNumber = 1426802708; - var body = { - 'push_time': timeNumber - } + const timeNumber = 1426802708; + const body = { + push_time: timeNumber, + }; - var { date } = PushController.getPushTime(body); + const { date } = PushController.getPushTime(body); expect(date.valueOf()).toEqual(timeNumber * 1000); done(); }); - it('can throw on getPushTime in invalid format', (done) => { + it('can throw on getPushTime in invalid format', done => { // Make mock request - var body = { - 'push_time': 'abcd' - } + const body = { + push_time: 'abcd', + }; - expect(function(){ + expect(function() { PushController.getPushTime(body); }).toThrow(); done(); }); - it('properly increment badges', (done) => { - var pushAdapter = { + it('properly increment badges', done => { + const pushAdapter = { send: function(body, installations) { - var badge = body.data.badge; - installations.forEach((installation) => { + const badge = body.data.badge; + installations.forEach(installation => { expect(installation.badge).toEqual(badge); expect(installation.originalBadge + 1).toEqual(installation.badge); - }) + }); return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios", "android"]; - } + return ['ios', 'android']; + }, + }; + const payload = { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); } - var payload = {data:{ - alert: "Hello World!", - badge: "Increment", - }} - var installations = []; - while(installations.length != 10) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'android'); installations.push(installation); } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + + const pushController = new PushController(); + reconfigureServer({ + push: { adapter: pushAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // Check we actually sent 15 pushes. + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(15); + }) + .then(() => { + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(15); + for (let i = 0; i < 15; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe( + parseInt(installation.get('originalBadge')) + 1 + ); + } + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); - while(installations.length != 15) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length); - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "android"); + it('properly increment badges by more than 1', done => { + const pushAdapter = { + send: function(body, installations) { + const badge = body.data.badge; + installations.forEach(installation => { + expect(installation.badge).toEqual(badge); + expect(installation.originalBadge + 3).toEqual(installation.badge); + }); + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function() { + return ['ios', 'android']; + }, + }; + const payload = { + data: { + alert: 'Hello World!', + badge: { __op: 'Increment', amount: 3 }, + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true + + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'android'); + installations.push(installation); } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - return Parse.Object.saveAll(installations) - }).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - // Check we actually sent 15 pushes. - const query = new Parse.Query('_PushStatus'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(15); - }).then(() => { - // Check that the installations were actually updated. - const query = new Parse.Query('_Installation'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(15); - for (var i = 0; i < 15; i++) { - const installation = results[i]; - expect(installation.get('badge')).toBe(parseInt(installation.get('originalBadge')) + 1); - } - done() - }).catch((err) => { - jfail(err); - done(); - }); + push: { adapter: pushAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // Check we actually sent 15 pushes. + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(15); + }) + .then(() => { + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(15); + for (let i = 0; i < 15; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe( + parseInt(installation.get('originalBadge')) + 3 + ); + } + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('properly set badges to 1', (done) => { - - var pushAdapter = { + it('properly set badges to 1', done => { + const pushAdapter = { send: function(body, installations) { - var badge = body.data.badge; - installations.forEach((installation) => { + const badge = body.data.badge; + installations.forEach(installation => { expect(installation.badge).toEqual(badge); expect(1).toEqual(installation.badge); - }) + }); return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - return Parse.Object.saveAll(installations) - }).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - // Check we actually sent the pushes. - const query = new Parse.Query('_PushStatus'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(10); - }).then(() => { - // Check that the installations were actually updated. - const query = new Parse.Query('_Installation'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(10); - for (var i = 0; i < 10; i++) { - const installation = results[i]; - expect(installation.get('badge')).toBe(1); - } - done() - }).catch((err) => { - jfail(err); - done(); - }); + push: { adapter: pushAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + // Check we actually sent the pushes. + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(10); + }) + .then(() => { + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(10); + for (let i = 0; i < 10; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe(1); + } + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('properly set badges to 1 with complex query #2903 #3022', (done) => { - - var payload = { + it('properly set badges to 1 with complex query #2903 #3022', done => { + const payload = { data: { - alert: "Hello World!", + alert: 'Hello World!', badge: 1, - } - } - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } let matchedInstallationsCount = 0; - var pushAdapter = { + const pushAdapter = { send: function(body, installations) { matchedInstallationsCount += installations.length; - var badge = body.data.badge; - installations.forEach((installation) => { + const badge = body.data.badge; + installations.forEach(installation => { expect(installation.badge).toEqual(badge); expect(1).toEqual(installation.badge); - }) + }); return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } - var pushController = new PushController(); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushController = new PushController(); reconfigureServer({ push: { - adapter: pushAdapter - } - }).then(() => { - return Parse.Object.saveAll(installations) - }).then((installations) => { - const objectIds = installations.map(installation => { - return installation.id; - }) - const where = { - objectId: {'$in': objectIds.slice(0, 5)} - } - return pushController.sendPush(payload, where, config, auth); - }).then(() => { - return new Promise((res) => { - setTimeout(res, 300); + adapter: pushAdapter, + }, + }) + .then(() => { + return Parse.Object.saveAll(installations); + }) + .then(installations => { + const objectIds = installations.map(installation => { + return installation.id; + }); + const where = { + objectId: { $in: objectIds.slice(0, 5) }, + }; + return pushController.sendPush(payload, where, config, auth); + }) + .then(() => { + return new Promise(res => { + setTimeout(res, 300); + }); + }) + .then(() => { + expect(matchedInstallationsCount).toBe(5); + const query = new Parse.Query(Parse.Installation); + query.equalTo('badge', 1); + return query.find({ useMasterKey: true }); + }) + .then(installations => { + expect(installations.length).toBe(5); + done(); + }) + .catch(() => { + fail('should not fail'); + done(); }); - }).then(() => { - expect(matchedInstallationsCount).toBe(5); - const query = new Parse.Query(Parse.Installation); - query.equalTo('badge', 1); - return query.find({useMasterKey: true}); - }).then((installations) => { - expect(installations.length).toBe(5); - done(); - }).catch(() => { - fail("should not fail"); - done(); - }); }); - it('properly creates _PushStatus', (done) => { + it('properly creates _PushStatus', done => { const pushStatusAfterSave = { - handler: function() {} + handler: function() {}, }; const spy = spyOn(pushStatusAfterSave, 'handler').and.callThrough(); Parse.Cloud.afterSave('_PushStatus', pushStatusAfterSave.handler); - var installations = []; - while(installations.length != 10) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } - while(installations.length != 15) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("deviceType", "android"); + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('deviceType', 'android'); installations.push(installation); } - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; - var pushAdapter = { + const pushAdapter = { send: function(body, installations) { return successfulIOS(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } - var pushController = new PushController(); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - return Parse.Object.saveAll(installations); + push: { adapter: pushAdapter }, }) + .then(() => { + return Parse.Object.saveAll(installations); + }) .then(() => { return pushController.sendPush(payload, {}, config, auth); - }).then(() => { + }) + .then(() => { // it is enqueued so it can take time - return new Promise((resolve) => { + return new Promise(resolve => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { + }) + .then(() => { const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}); - }).then((results) => { + return query.find({ useMasterKey: true }); + }) + .then(results => { expect(results.length).toBe(1); const result = results[0]; expect(result.createdAt instanceof Date).toBe(true); @@ -455,21 +611,22 @@ describe('PushController', () => { expect(result.id.length).toBe(10); expect(result.get('source')).toEqual('rest'); expect(result.get('query')).toEqual(JSON.stringify({})); - expect(typeof result.get('payload')).toEqual("string"); + expect(typeof result.get('payload')).toEqual('string'); expect(JSON.parse(result.get('payload'))).toEqual(payload.data); expect(result.get('status')).toEqual('succeeded'); expect(result.get('numSent')).toEqual(10); expect(result.get('sentPerType')).toEqual({ - 'ios': 10 // 10 ios + ios: 10, // 10 ios }); expect(result.get('numFailed')).toEqual(5); expect(result.get('failedPerType')).toEqual({ - 'android': 5 // android + android: 5, // android }); // Try to get it without masterKey const query = new Parse.Query('_PushStatus'); return query.find(); - }).catch((error) => { + }) + .catch(error => { expect(error.code).toBe(119); }) .then(() => { @@ -479,8 +636,8 @@ describe('PushController', () => { expect(spy).toHaveBeenCalled(); expect(spy.calls.count()).toBe(4); const allCalls = spy.calls.all(); - allCalls.forEach((call) => { - expect(call.args.length).toBe(2); + allCalls.forEach(call => { + expect(call.args.length).toBe(1); const object = call.args[0].object; expect(object instanceof Parse.Object).toBe(true); }); @@ -493,335 +650,409 @@ describe('PushController', () => { // Those are updated from a nested . operation, this would // not render correctly before expect(getPushStatus(2).get('failedPerType')).toEqual({ - android: 5 + android: 5, }); expect(getPushStatus(2).get('sentPerType')).toEqual({ - ios: 10 + ios: 10, }); expect(getPushStatus(3).get('status')).toBe('succeeded'); }) - .then(done).catch(done.fail); + .then(done) + .catch(done.fail); }); - it('properly creates _PushStatus without serverURL', (done) => { + it('properly creates _PushStatus without serverURL', done => { const pushStatusAfterSave = { - handler: function() {} + handler: function() {}, }; Parse.Cloud.afterSave('_PushStatus', pushStatusAfterSave.handler); - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation"); - installation.set("deviceToken","device_token") - installation.set("badge", 0); - installation.set("originalBadge", 0); - installation.set("deviceType", "ios"); - - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - - var pushAdapter = { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation'); + installation.set('deviceToken', 'device_token'); + installation.set('badge', 0); + installation.set('originalBadge', 0); + installation.set('deviceType', 'ios'); + + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + + const pushAdapter = { send: function(body, installations) { return successfulIOS(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } - var pushController = new PushController(); - return installation.save().then(() => { - return reconfigureServer({ - serverURL: 'http://localhost:8378/', // server with borked URL - push: { adapter: pushAdapter } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushController = new PushController(); + return installation + .save() + .then(() => { + return reconfigureServer({ + serverURL: 'http://localhost:8378/', // server with borked URL + push: { adapter: pushAdapter }, + }); }) - }) .then(() => { return pushController.sendPush(payload, {}, config, auth); - }).then(() => { + }) + .then(() => { // it is enqueued so it can take time - return new Promise((resolve) => { + return new Promise(resolve => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { + }) + .then(() => { Parse.serverURL = 'http://localhost:8378/1'; // GOOD url const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}); - }).then((results) => { + return query.find({ useMasterKey: true }); + }) + .then(results => { expect(results.length).toBe(1); }) - .then(done).catch(done.fail); + .then(done) + .catch(done.fail); }); - it('should properly report failures in _PushStatus', (done) => { - var pushAdapter = { + it('should properly report failures in _PushStatus', done => { + const pushAdapter = { send: function(body, installations) { - return installations.map((installation) => { + return installations.map(installation => { return Promise.resolve({ - deviceType: installation.deviceType - }) - }) + deviceType: installation.deviceType, + }); + }); }, getValidPushTypes: function() { - return ["ios"]; - } - } - const where = { 'channels': { - '$ins': ['Giants', 'Mets'] - }}; - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } - var pushController = new PushController(); - reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - return pushController.sendPush(payload, where, config, auth) - }).then(() => { - fail('should not succeed'); - done(); - }).catch(() => { - const query = new Parse.Query('_PushStatus'); - query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('status')).toBe('failed'); - done(); - }); - }); - }); - - it('should support full RESTQuery for increment', (done) => { - var payload = {data: { - alert: "Hello World!", - badge: 'Increment', - }} - - var pushAdapter = { - send: function(body, installations) { - return successfulTransmissions(body, installations); + return ['ios']; }, - getValidPushTypes: function() { - return ["ios"]; - } - } - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } + }; + const where = { + channels: { + $ins: ['Giants', 'Mets'], + }, + }; + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushController = new PushController(); + reconfigureServer({ + push: { adapter: pushAdapter }, + }) + .then(() => { + return pushController.sendPush(payload, where, config, auth); + }) + .then(() => { + fail('should not succeed'); + done(); + }) + .catch(() => { + const query = new Parse.Query('_PushStatus'); + query.find({ useMasterKey: true }).then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).toBe('failed'); + done(); + }); + }); + }); + + it('should support full RESTQuery for increment', done => { + const payload = { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + }; + + const pushAdapter = { + send: function(body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function() { + return ['ios']; + }, + }; + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; const where = { - 'deviceToken': { - '$in': ['device_token_0', 'device_token_1', 'device_token_2'] - } - } + deviceToken: { + $in: ['device_token_0', 'device_token_1', 'device_token_2'], + }, + }; - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var installations = []; - while (installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken", "device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - return Parse.Object.saveAll(installations); - }).then(() => { - return pushController.sendPush(payload, where, config, auth); - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(3); - done(); - }).catch((err) => { - jfail(err); - done(); - }); + push: { adapter: pushAdapter }, + }) + .then(() => { + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, where, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(3); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('should support object type for alert', (done) => { - var payload = {data: { - alert: { - 'loc-key': 'hello_world', + it('should support object type for alert', done => { + const payload = { + data: { + alert: { + 'loc-key': 'hello_world', + }, }, - }} + }; - var pushAdapter = { + const pushAdapter = { send: function(body, installations) { return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; const where = { - 'deviceType': 'ios' - } + deviceType: 'ios', + }; - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var installations = []; - while (installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken", "device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - return Parse.Object.saveAll(installations); - }).then(() => { - return pushController.sendPush(payload, where, config, auth) - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({ useMasterKey: true }) - }).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(5); - done(); - }).catch(() => { - fail('should not fail'); - done(); - }); + push: { adapter: pushAdapter }, + }) + .then(() => { + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, where, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(5); + done(); + }) + .catch(() => { + fail('should not fail'); + done(); + }); }); it('should flatten', () => { - var res = StatusHandler.flatten([1, [2], [[3, 4], 5], [[[6]]]]) - expect(res).toEqual([1,2,3,4,5,6]); + const res = StatusHandler.flatten([1, [2], [[3, 4], 5], [[[6]]]]); + expect(res).toEqual([1, 2, 3, 4, 5, 6]); }); it('properly transforms push time', () => { expect(PushController.getPushTime()).toBe(undefined); - expect(PushController.getPushTime({ - 'push_time': 1000 - }).date).toEqual(new Date(1000 * 1000)); - expect(PushController.getPushTime({ - 'push_time': '2017-01-01' - }).date).toEqual(new Date('2017-01-01')); - - expect(() => {PushController.getPushTime({ - 'push_time': 'gibberish-time' - })}).toThrow(); - expect(() => {PushController.getPushTime({ - 'push_time': Number.NaN - })}).toThrow(); - - expect(PushController.getPushTime({ - push_time: '2017-09-06T13:42:48.369Z' - })).toEqual({ + expect( + PushController.getPushTime({ + push_time: 1000, + }).date + ).toEqual(new Date(1000 * 1000)); + expect( + PushController.getPushTime({ + push_time: '2017-01-01', + }).date + ).toEqual(new Date('2017-01-01')); + + expect(() => { + PushController.getPushTime({ + push_time: 'gibberish-time', + }); + }).toThrow(); + expect(() => { + PushController.getPushTime({ + push_time: Number.NaN, + }); + }).toThrow(); + + expect( + PushController.getPushTime({ + push_time: '2017-09-06T13:42:48.369Z', + }) + ).toEqual({ date: new Date('2017-09-06T13:42:48.369Z'), isLocalTime: false, }); - expect(PushController.getPushTime({ - push_time: '2007-04-05T12:30-02:00', - })).toEqual({ + expect( + PushController.getPushTime({ + push_time: '2007-04-05T12:30-02:00', + }) + ).toEqual({ date: new Date('2007-04-05T12:30-02:00'), isLocalTime: false, }); - expect(PushController.getPushTime({ - push_time: '2007-04-05T12:30', - })).toEqual({ + expect( + PushController.getPushTime({ + push_time: '2007-04-05T12:30', + }) + ).toEqual({ date: new Date('2007-04-05T12:30'), isLocalTime: true, }); }); - it('should not schedule push when not configured', (done) => { - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } - var pushAdapter = { + it('should not schedule push when not configured', done => { + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushAdapter = { send: function(body, installations) { return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var pushController = new PushController(); + const pushController = new PushController(); const payload = { data: { alert: 'hello', }, - push_time: new Date().getTime() - } + push_time: new Date().getTime(), + }; - var installations = []; - while(installations.length != 10) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - return Parse.Object.saveAll(installations).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then(() => new Promise(resolve => setTimeout(resolve, 300))); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('status')).not.toBe('scheduled'); + push: { adapter: pushAdapter }, + }) + .then(() => { + return Parse.Object.saveAll(installations) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => new Promise(resolve => setTimeout(resolve, 300))); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }).then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).not.toBe('scheduled'); + done(); + }); + }) + .catch(err => { + console.error(err); + fail('should not fail'); done(); }); - }).catch((err) => { - console.error(err); - fail('should not fail'); - done(); - }); }); - it('should schedule push when configured', (done) => { - var auth = { - isMaster: true - } - var pushAdapter = { + it('should schedule push when configured', done => { + const auth = { + isMaster: true, + }; + const pushAdapter = { send: function(body, installations) { - const promises = installations.map((device) => { + const promises = installations.map(device => { if (!device.deviceToken) { // Simulate error when device token is not set return Promise.reject(); @@ -829,60 +1060,69 @@ describe('PushController', () => { return Promise.resolve({ transmitted: true, device: device, - }) + }); }); return Promise.all(promises); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var pushController = new PushController(); + const pushController = new PushController(); const payload = { data: { alert: 'hello', }, - push_time: new Date().getTime() / 1000 - } + push_time: new Date().getTime() / 1000, + }; - var installations = []; - while(installations.length != 10) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } reconfigureServer({ push: { adapter: pushAdapter }, - scheduledPush: true - }).then(() => { - var config = Config.get(Parse.applicationId); - return Parse.Object.saveAll(installations).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then(() => new Promise(resolve => setTimeout(resolve, 300))); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('status')).toBe('scheduled'); - }); - }).then(done).catch(done.err); + scheduledPush: true, + }) + .then(() => { + const config = Config.get(Parse.applicationId); + return Parse.Object.saveAll(installations) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => new Promise(resolve => setTimeout(resolve, 300))); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }).then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).toBe('scheduled'); + }); + }) + .then(done) + .catch(done.err); }); - it('should not enqueue push when device token is not set', (done) => { - var auth = { - isMaster: true - } - var pushAdapter = { + it('should not enqueue push when device token is not set', done => { + const auth = { + isMaster: true, + }; + const pushAdapter = { send: function(body, installations) { - const promises = installations.map((device) => { + const promises = installations.map(device => { if (!device.deviceToken) { // Simulate error when device token is not set return Promise.reject(); @@ -890,74 +1130,85 @@ describe('PushController', () => { return Promise.resolve({ transmitted: true, device: device, - }) + }); }); return Promise.all(promises); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var pushController = new PushController(); + const pushController = new PushController(); const payload = { data: { alert: 'hello', }, - push_time: new Date().getTime() / 1000 - } + push_time: new Date().getTime() / 1000, + }; - var installations = []; - while(installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } - while(installations.length != 15) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var config = Config.get(Parse.applicationId); - return Parse.Object.saveAll(installations).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then(() => new Promise(resolve => setTimeout(resolve, 100))); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(5); - expect(pushStatus.get('status')).toBe('succeeded'); + push: { adapter: pushAdapter }, + }) + .then(() => { + const config = Config.get(Parse.applicationId); + return Parse.Object.saveAll(installations) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => new Promise(resolve => setTimeout(resolve, 100))); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }).then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(5); + expect(pushStatus.get('status')).toBe('succeeded'); + done(); + }); + }) + .catch(err => { + console.error(err); + fail('should not fail'); done(); }); - }).catch((err) => { - console.error(err); - fail('should not fail'); - done(); - }); }); - it('should mark the _PushStatus as failed when audience has no deviceToken', (done) => { - var auth = { - isMaster: true - } - var pushAdapter = { + it('should not mark the _PushStatus as failed when audience has no deviceToken', done => { + const auth = { + isMaster: true, + }; + const pushAdapter = { send: function(body, installations) { - const promises = installations.map((device) => { + const promises = installations.map(device => { if (!device.deviceToken) { // Simulate error when device token is not set return Promise.reject(); @@ -965,298 +1216,383 @@ describe('PushController', () => { return Promise.resolve({ transmitted: true, device: device, - }) + }); }); return Promise.all(promises); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var pushController = new PushController(); + const pushController = new PushController(); const payload = { data: { alert: 'hello', }, - push_time: new Date().getTime() / 1000 - } + push_time: new Date().getTime() / 1000, + }; - var installations = []; - while(installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var config = Config.get(Parse.applicationId); - return Parse.Object.saveAll(installations).then(() => { - return pushController.sendPush(payload, {}, config, auth) - .then(() => { done.fail('should not success') }) - .catch(() => {}) - }).then(() => new Promise(resolve => setTimeout(resolve, 100))); - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - const pushStatus = results[0]; - expect(pushStatus.get('numSent')).toBe(0); - expect(pushStatus.get('status')).toBe('failed'); + push: { adapter: pushAdapter }, + }) + .then(() => { + const config = Config.get(Parse.applicationId); + return Parse.Object.saveAll(installations) + .then(() => { + return pushController.sendPush(payload, {}, config, auth); + }) + .then(() => new Promise(resolve => setTimeout(resolve, 100))); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.find({ useMasterKey: true }).then(results => { + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('numSent')).toBe(0); + expect(pushStatus.get('status')).toBe('succeeded'); + done(); + }); + }) + .catch(err => { + console.error(err); + fail('should not fail'); done(); }); - }).catch((err) => { - console.error(err); - fail('should not fail'); - done(); - }); }); - it('should support localized payload data', (done) => { - var payload = {data: { - alert: 'Hello!', - 'alert-fr': 'Bonjour', - 'alert-es': 'Ola' - }} + it('should support localized payload data', done => { + const payload = { + data: { + alert: 'Hello!', + 'alert-fr': 'Bonjour', + 'alert-es': 'Ola', + }, + }; - var pushAdapter = { + const pushAdapter = { send: function(body, installations) { return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; const where = { - 'deviceType': 'ios' - } + deviceType: 'ios', + }; spyOn(pushAdapter, 'send').and.callThrough(); - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var installations = []; - while (installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken", "device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - installations[0].set('localeIdentifier', 'fr-CA'); - installations[1].set('localeIdentifier', 'fr-FR'); - installations[2].set('localeIdentifier', 'en-US'); - return Parse.Object.saveAll(installations); - }).then(() => { - return pushController.sendPush(payload, where, config, auth) - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - expect(pushAdapter.send.calls.count()).toBe(2); - const firstCall = pushAdapter.send.calls.first(); - expect(firstCall.args[0].data).toEqual({ - alert: 'Hello!' - }); - expect(firstCall.args[1].length).toBe(3); // 3 installations + push: { adapter: pushAdapter }, + }) + .then(() => { + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + installations[0].set('localeIdentifier', 'fr-CA'); + installations[1].set('localeIdentifier', 'fr-FR'); + installations[2].set('localeIdentifier', 'en-US'); + return Parse.Object.saveAll(installations); + }) + .then(() => { + return pushController.sendPush(payload, where, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + expect(pushAdapter.send.calls.count()).toBe(2); + const firstCall = pushAdapter.send.calls.first(); + expect(firstCall.args[0].data).toEqual({ + alert: 'Hello!', + }); + expect(firstCall.args[1].length).toBe(3); // 3 installations - const lastCall = pushAdapter.send.calls.mostRecent(); - expect(lastCall.args[0].data).toEqual({ - alert: 'Bonjour' - }); - expect(lastCall.args[1].length).toBe(2); // 2 installations - // No installation is in es so only 1 call for fr, and another for default - done(); - }).catch(done.fail); + const lastCall = pushAdapter.send.calls.mostRecent(); + expect(lastCall.args[0].data).toEqual({ + alert: 'Bonjour', + }); + expect(lastCall.args[1].length).toBe(2); // 2 installations + // No installation is in es so only 1 call for fr, and another for default + done(); + }) + .catch(done.fail); }); - it('should update audiences', (done) => { - var pushAdapter = { + it('should update audiences', done => { + const pushAdapter = { send: function(body, installations) { return successfulTransmissions(body, installations); }, getValidPushTypes: function() { - return ["ios"]; - } - } + return ['ios']; + }, + }; - var config = Config.get(Parse.applicationId); - var auth = { - isMaster: true - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; - var audienceId = null; - var now = new Date(); - var timesUsed = 0; + let audienceId = null; + const now = new Date(); + let timesUsed = 0; const where = { - 'deviceType': 'ios' - } + deviceType: 'ios', + }; spyOn(pushAdapter, 'send').and.callThrough(); - var pushController = new PushController(); + const pushController = new PushController(); reconfigureServer({ - push: { adapter: pushAdapter } - }).then(() => { - var installations = []; - while (installations.length != 5) { - const installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - return Parse.Object.saveAll(installations); - }).then(() => { - // Create an audience - const query = new Parse.Query("_Audience"); - query.descending("createdAt"); - query.equalTo("query", JSON.stringify(where)); - const parseResults = (results) => { - if (results.length > 0) { - audienceId = results[0].id; - timesUsed = results[0].get('timesUsed'); - if (!isFinite(timesUsed)) { - timesUsed = 0; - } + push: { adapter: pushAdapter }, + }) + .then(() => { + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); } - } - const audience = new Parse.Object("_Audience"); - audience.set("name", "testAudience") - audience.set("query", JSON.stringify(where)); - return Parse.Object.saveAll(audience).then(() => { - return query.find({ useMasterKey: true }).then(parseResults); - }); - }).then(() => { - var body = { - data: { alert: 'hello' }, - audience_id: audienceId - } - return pushController.sendPush(body, where, config, auth) - }).then(() => { - // Wait so the push is completed. - return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); - }).then(() => { - expect(pushAdapter.send.calls.count()).toBe(1); - const firstCall = pushAdapter.send.calls.first(); - expect(firstCall.args[0].data).toEqual({ - alert: 'hello' - }); - expect(firstCall.args[1].length).toBe(5); - }).then(() => { - // Get the audience we used above. - const query = new Parse.Query("_Audience"); - query.equalTo("objectId", audienceId); - return query.find({ useMasterKey: true }) - }).then((results) => { - const audience = results[0]; - expect(audience.get('query')).toBe(JSON.stringify(where)); - expect(audience.get('timesUsed')).toBe(timesUsed + 1); - expect(audience.get('lastUsed')).not.toBeLessThan(now); - }).then(() => { - done(); - }).catch(done.fail); + return Parse.Object.saveAll(installations); + }) + .then(() => { + // Create an audience + const query = new Parse.Query('_Audience'); + query.descending('createdAt'); + query.equalTo('query', JSON.stringify(where)); + const parseResults = results => { + if (results.length > 0) { + audienceId = results[0].id; + timesUsed = results[0].get('timesUsed'); + if (!isFinite(timesUsed)) { + timesUsed = 0; + } + } + }; + const audience = new Parse.Object('_Audience'); + audience.set('name', 'testAudience'); + audience.set('query', JSON.stringify(where)); + return Parse.Object.saveAll(audience).then(() => { + return query.find({ useMasterKey: true }).then(parseResults); + }); + }) + .then(() => { + const body = { + data: { alert: 'hello' }, + audience_id: audienceId, + }; + return pushController.sendPush(body, where, config, auth); + }) + .then(() => { + // Wait so the push is completed. + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }) + .then(() => { + expect(pushAdapter.send.calls.count()).toBe(1); + const firstCall = pushAdapter.send.calls.first(); + expect(firstCall.args[0].data).toEqual({ + alert: 'hello', + }); + expect(firstCall.args[1].length).toBe(5); + }) + .then(() => { + // Get the audience we used above. + const query = new Parse.Query('_Audience'); + query.equalTo('objectId', audienceId); + return query.find({ useMasterKey: true }); + }) + .then(results => { + const audience = results[0]; + expect(audience.get('query')).toBe(JSON.stringify(where)); + expect(audience.get('timesUsed')).toBe(timesUsed + 1); + expect(audience.get('lastUsed')).not.toBeLessThan(now); + }) + .then(() => { + done(); + }) + .catch(done.fail); }); describe('pushTimeHasTimezoneComponent', () => { it('should be accurate', () => { - expect(PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048Z')) - .toBe(true, 'UTC time'); - expect(PushController.pushTimeHasTimezoneComponent('2007-04-05T12:30-02:00')) - .toBe(true, 'Timezone offset'); - expect(PushController.pushTimeHasTimezoneComponent('2007-04-05T12:30:00.000Z-02:00')) - .toBe(true, 'Seconds + Milliseconds + Timezone offset'); - - expect(PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048')) - .toBe(false, 'No timezone'); - expect(PushController.pushTimeHasTimezoneComponent('2017-09-06')) - .toBe(false, 'YY-MM-DD'); + expect( + PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048Z') + ).toBe(true, 'UTC time'); + expect( + PushController.pushTimeHasTimezoneComponent('2007-04-05T12:30-02:00') + ).toBe(true, 'Timezone offset'); + expect( + PushController.pushTimeHasTimezoneComponent( + '2007-04-05T12:30:00.000Z-02:00' + ) + ).toBe(true, 'Seconds + Milliseconds + Timezone offset'); + + expect( + PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048') + ).toBe(false, 'No timezone'); + expect(PushController.pushTimeHasTimezoneComponent('2017-09-06')).toBe( + false, + 'YY-MM-DD' + ); }); }); describe('formatPushTime', () => { it('should format as ISO string', () => { - expect(PushController.formatPushTime({ - date: new Date('2017-09-06T17:14:01.048Z'), - isLocalTime: false, - })).toBe('2017-09-06T17:14:01.048Z', 'UTC time'); - expect(PushController.formatPushTime({ - date: new Date('2007-04-05T12:30-02:00'), - isLocalTime: false - })).toBe('2007-04-05T14:30:00.000Z', 'Timezone offset'); - - expect(PushController.formatPushTime({ - date: new Date('2017-09-06T17:14:01.048'), - isLocalTime: true, - })).toBe('2017-09-06T17:14:01.048', 'No timezone'); - expect(PushController.formatPushTime({ - date: new Date('2017-09-06'), - isLocalTime: true - })).toBe('2017-09-06T00:00:00.000', 'YY-MM-DD'); + expect( + PushController.formatPushTime({ + date: new Date('2017-09-06T17:14:01.048Z'), + isLocalTime: false, + }) + ).toBe('2017-09-06T17:14:01.048Z', 'UTC time'); + expect( + PushController.formatPushTime({ + date: new Date('2007-04-05T12:30-02:00'), + isLocalTime: false, + }) + ).toBe('2007-04-05T14:30:00.000Z', 'Timezone offset'); + + const noTimezone = new Date('2017-09-06T17:14:01.048'); + let expectedHour = 17 + noTimezone.getTimezoneOffset() / 60; + let day = '06'; + if (expectedHour >= 24) { + expectedHour = expectedHour - 24; + day = '07'; + } + expect( + PushController.formatPushTime({ + date: noTimezone, + isLocalTime: true, + }) + ).toBe( + `2017-09-${day}T${expectedHour.toString().padStart(2, '0')}:14:01.048`, + 'No timezone' + ); + expect( + PushController.formatPushTime({ + date: new Date('2017-09-06'), + isLocalTime: true, + }) + ).toBe('2017-09-06T00:00:00.000', 'YY-MM-DD'); }); }); describe('Scheduling pushes in local time', () => { - it('should preserve the push time', (done) => { - const auth = {isMaster: true}; + it('should preserve the push time', done => { + const auth = { isMaster: true }; const pushAdapter = { send(body, installations) { return successfulTransmissions(body, installations); }, getValidPushTypes() { - return ["ios"]; - } + return ['ios']; + }, }; const pushTime = '2017-09-06T17:14:01.048'; + let expectedHour = 17 + new Date(pushTime).getTimezoneOffset() / 60; + let day = '06'; + if (expectedHour >= 24) { + expectedHour = expectedHour - 24; + day = '07'; + } reconfigureServer({ - push: {adapter: pushAdapter}, - scheduledPush: true + push: { adapter: pushAdapter }, + scheduledPush: true, }) .then(() => { const config = Config.get(Parse.applicationId); return new Promise((resolve, reject) => { const pushController = new PushController(); - pushController.sendPush({ - data: { - alert: "Hello World!", - badge: "Increment", - }, - push_time: pushTime - }, {}, config, auth, resolve) + pushController + .sendPush( + { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + push_time: pushTime, + }, + {}, + config, + auth, + resolve + ) .catch(reject); - }) + }); }) - .then((pushStatusId) => { + .then(pushStatusId => { const q = new Parse.Query('_PushStatus'); - return q.get(pushStatusId, {useMasterKey: true}); + return q.get(pushStatusId, { useMasterKey: true }); }) - .then((pushStatus) => { + .then(pushStatus => { expect(pushStatus.get('status')).toBe('scheduled'); - expect(pushStatus.get('pushTime')).toBe('2017-09-06T17:14:01.048'); + expect(pushStatus.get('pushTime')).toBe( + `2017-09-${day}T${expectedHour + .toString() + .padStart(2, '0')}:14:01.048` + ); }) .then(done, done.fail); }); }); describe('With expiration defined', () => { - const auth = {isMaster: true}; + const auth = { isMaster: true }; const pushController = new PushController(); let config = Config.get(Parse.applicationId); @@ -1268,13 +1604,13 @@ describe('PushController', () => { return successfulTransmissions(body, installations); }, getValidPushTypes() { - return ["ios"]; - } + return ['ios']; + }, }; - beforeEach((done) => { + beforeEach(done => { reconfigureServer({ - push: {adapter: pushAdapter}, + push: { adapter: pushAdapter }, }) .then(() => { config = Config.get(Parse.applicationId); @@ -1283,51 +1619,91 @@ describe('PushController', () => { }); it('should throw if both expiration_time and expiration_interval are set', () => { - expect(() => pushController.sendPush({ - expiration_time: '2017-09-25T13:21:20.841Z', - expiration_interval: 1000, - }, {}, config, auth)).toThrow() + expect(() => + pushController.sendPush( + { + expiration_time: '2017-09-25T13:21:20.841Z', + expiration_interval: 1000, + }, + {}, + config, + auth + ) + ).toThrow(); }); it('should throw on invalid expiration_interval', () => { - expect(() => pushController.sendPush({ - expiration_interval: -1 - }, {}, config, auth)).toThrow(); - expect(() => pushController.sendPush({ - expiration_interval: '', - }, {}, config, auth)).toThrow(); - expect(() => pushController.sendPush({ - expiration_time: {}, - }, {}, config, auth)).toThrow(); + expect(() => + pushController.sendPush( + { + expiration_interval: -1, + }, + {}, + config, + auth + ) + ).toThrow(); + expect(() => + pushController.sendPush( + { + expiration_interval: '', + }, + {}, + config, + auth + ) + ).toThrow(); + expect(() => + pushController.sendPush( + { + expiration_time: {}, + }, + {}, + config, + auth + ) + ).toThrow(); }); - describe('For immediate pushes',() => { - it('should transform the expiration_interval into an absolute time', (done) => { + describe('For immediate pushes', () => { + it('should transform the expiration_interval into an absolute time', done => { const now = new Date('2017-09-25T13:30:10.452Z'); reconfigureServer({ - push: {adapter: pushAdapter}, + push: { adapter: pushAdapter }, }) - .then(() => - new Promise((resolve) => { - pushController.sendPush({ - data: { - alert: 'immediate push', - }, - expiration_interval: 20 * 60, // twenty minutes - }, {}, Config.get(Parse.applicationId), auth, resolve, now) - })) - .then((pushStatusId) => { + .then( + () => + new Promise(resolve => { + pushController.sendPush( + { + data: { + alert: 'immediate push', + }, + expiration_interval: 20 * 60, // twenty minutes + }, + {}, + Config.get(Parse.applicationId), + auth, + resolve, + now + ); + }) + ) + .then(pushStatusId => { const p = new Parse.Object('_PushStatus'); p.id = pushStatusId; - return p.fetch({useMasterKey: true}); + return p.fetch({ useMasterKey: true }); }) - .then((pushStatus) => { + .then(pushStatus => { expect(pushStatus.get('expiry')).toBeDefined('expiry must be set'); - expect(pushStatus.get('expiry')) - .toEqual(new Date('2017-09-25T13:50:10.452Z').valueOf()); + expect(pushStatus.get('expiry')).toEqual( + new Date('2017-09-25T13:50:10.452Z').valueOf() + ); - expect(pushStatus.get('expiration_interval')).toBeDefined('expiration_interval must be defined'); + expect(pushStatus.get('expiration_interval')).toBeDefined( + 'expiration_interval must be defined' + ); expect(pushStatus.get('expiration_interval')).toBe(20 * 60); }) .then(done, done.fail); diff --git a/spec/PushQueue.spec.js b/spec/PushQueue.spec.js index 3e9aedae98..8cf42e81a7 100644 --- a/spec/PushQueue.spec.js +++ b/spec/PushQueue.spec.js @@ -1,14 +1,14 @@ -import Config from "../src/Config"; -import {PushQueue} from "../src/Push/PushQueue"; +const Config = require('../lib/Config'); +const { PushQueue } = require('../lib/Push/PushQueue'); describe('PushQueue', () => { describe('With a defined channel', () => { - it('should be propagated to the PushWorker and PushQueue', (done) => { + it('should be propagated to the PushWorker and PushQueue', done => { reconfigureServer({ push: { queueOptions: { disablePushWorker: false, - channel: 'my-specific-channel' + channel: 'my-specific-channel', }, adapter: { send() { @@ -16,25 +16,31 @@ describe('PushQueue', () => { }, getValidPushTypes() { return []; - } - } - } + }, + }, + }, }) .then(() => { const config = Config.get(Parse.applicationId); - expect(config.pushWorker.channel).toEqual('my-specific-channel', 'pushWorker.channel'); - expect(config.pushControllerQueue.channel).toEqual('my-specific-channel', 'pushWorker.channel'); + expect(config.pushWorker.channel).toEqual( + 'my-specific-channel', + 'pushWorker.channel' + ); + expect(config.pushControllerQueue.channel).toEqual( + 'my-specific-channel', + 'pushWorker.channel' + ); }) .then(done, done.fail); }); }); describe('Default channel', () => { - it('should be prefixed with the applicationId', (done) => { + it('should be prefixed with the applicationId', done => { reconfigureServer({ push: { queueOptions: { - disablePushWorker: false + disablePushWorker: false, }, adapter: { send() { @@ -42,15 +48,19 @@ describe('PushQueue', () => { }, getValidPushTypes() { return []; - } - } - } + }, + }, + }, }) .then(() => { const config = Config.get(Parse.applicationId); - expect(PushQueue.defaultPushChannel()).toEqual('test-parse-server-push'); + expect(PushQueue.defaultPushChannel()).toEqual( + 'test-parse-server-push' + ); expect(config.pushWorker.channel).toEqual('test-parse-server-push'); - expect(config.pushControllerQueue.channel).toEqual('test-parse-server-push'); + expect(config.pushControllerQueue.channel).toEqual( + 'test-parse-server-push' + ); }) .then(done, done.fail); }); diff --git a/spec/PushRouter.spec.js b/spec/PushRouter.spec.js index 411076a270..388729221a 100644 --- a/spec/PushRouter.spec.js +++ b/spec/PushRouter.spec.js @@ -1,47 +1,46 @@ -var PushRouter = require('../src/Routers/PushRouter').PushRouter; -var request = require('request'); +const PushRouter = require('../lib/Routers/PushRouter').PushRouter; +const request = require('../lib/request'); describe('PushRouter', () => { - it('can get query condition when channels is set', (done) => { + it('can get query condition when channels is set', done => { // Make mock request - var request = { + const request = { body: { - channels: ['Giants', 'Mets'] - } - } + channels: ['Giants', 'Mets'], + }, + }; - var where = PushRouter.getQueryCondition(request); + const where = PushRouter.getQueryCondition(request); expect(where).toEqual({ - 'channels': { - '$in': ['Giants', 'Mets'] - } + channels: { + $in: ['Giants', 'Mets'], + }, }); done(); }); - it('can get query condition when where is set', (done) => { + it('can get query condition when where is set', done => { // Make mock request - var request = { + const request = { body: { - 'where': { - 'injuryReports': true - } - } - } + where: { + injuryReports: true, + }, + }, + }; - var where = PushRouter.getQueryCondition(request); + const where = PushRouter.getQueryCondition(request); expect(where).toEqual({ - 'injuryReports': true + injuryReports: true, }); done(); }); - it('can get query condition when nothing is set', (done) => { + it('can get query condition when nothing is set', done => { // Make mock request - var request = { - body: { - } - } + const request = { + body: {}, + }; expect(function() { PushRouter.getQueryCondition(request); @@ -49,18 +48,18 @@ describe('PushRouter', () => { done(); }); - it('can throw on getQueryCondition when channels and where are set', (done) => { + it('can throw on getQueryCondition when channels and where are set', done => { // Make mock request - var request = { + const request = { body: { - 'channels': { - '$in': ['Giants', 'Mets'] + channels: { + $in: ['Giants', 'Mets'], }, - 'where': { - 'injuryReports': true - } - } - } + where: { + injuryReports: true, + }, + }, + }; expect(function() { PushRouter.getQueryCondition(request); @@ -68,24 +67,24 @@ describe('PushRouter', () => { done(); }); - it('sends a push through REST', (done) => { - request.post({ - url: Parse.serverURL + "/push", - json: true, + it('sends a push through REST', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/push', body: { - 'channels': { - '$in': ['Giants', 'Mets'] - } + channels: { + $in: ['Giants', 'Mets'], + }, }, headers: { 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey - } - }, function(err, res, body){ + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + }).then(res => { expect(res.headers['x-parse-push-status-id']).not.toBe(undefined); expect(res.headers['x-parse-push-status-id'].length).toBe(10); - expect(res.headers['']) - expect(body.result).toBe(true); + expect(res.data.result).toBe(true); done(); }); }); diff --git a/spec/PushWorker.spec.js b/spec/PushWorker.spec.js index 96317efcc6..8dc1068e5e 100644 --- a/spec/PushWorker.spec.js +++ b/spec/PushWorker.spec.js @@ -1,61 +1,75 @@ -var PushWorker = require('../src').PushWorker; -var PushUtils = require('../src/Push/utils'); -var Config = require('../src/Config'); -var { pushStatusHandler } = require('../src/StatusHandler'); -var rest = require('../src/rest'); +const PushWorker = require('../lib').PushWorker; +const PushUtils = require('../lib/Push/utils'); +const Config = require('../lib/Config'); +const { pushStatusHandler } = require('../lib/StatusHandler'); +const rest = require('../lib/rest'); describe('PushWorker', () => { - it('should run with small batch', (done) => { + it('should run with small batch', done => { const batchSize = 3; - var sendCount = 0; + let sendCount = 0; reconfigureServer({ push: { queueOptions: { disablePushWorker: true, - batchSize - } - } - }).then(() => { - expect(Config.get('test').pushWorker).toBeUndefined(); - new PushWorker({ - send: (body, installations) => { - expect(installations.length <= batchSize).toBe(true); - sendCount += installations.length; - return Promise.resolve(); + batchSize, }, - getValidPushTypes: function() { - return ['ios', 'android'] - } - }); - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_" + installations.length); - installation.set("deviceToken","device_token_" + installations.length) - installation.set("badge", 1); - installation.set("deviceType", "ios"); - installations.push(installation); - } - return Parse.Object.saveAll(installations); - }).then(() => { - return Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - alert: 'Hello world!' + }, + }) + .then(() => { + expect(Config.get('test').pushWorker).toBeUndefined(); + new PushWorker({ + send: (body, installations) => { + expect(installations.length <= batchSize).toBe(true); + sendCount += installations.length; + return Promise.resolve(); + }, + getValidPushTypes: function() { + return ['ios', 'android']; + }, + }); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set( + 'installationId', + 'installation_' + installations.length + ); + installation.set( + 'deviceToken', + 'device_token_' + installations.length + ); + installation.set('badge', 1); + installation.set('deviceType', 'ios'); + installations.push(installation); } - }, {useMasterKey: true}) - }).then(() => { - return new Promise((resolve) => { - setTimeout(resolve, 500); + return Parse.Object.saveAll(installations); + }) + .then(() => { + return Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + return new Promise(resolve => { + setTimeout(resolve, 500); + }); + }) + .then(() => { + expect(sendCount).toBe(10); + done(); + }) + .catch(err => { + jfail(err); }); - }).then(() => { - expect(sendCount).toBe(10); - done(); - }).catch(err => { - jfail(err); - }) }); describe('localized push', () => { @@ -63,9 +77,9 @@ describe('PushWorker', () => { const locales = PushUtils.getLocalesFromPush({ data: { 'alert-fr': 'french', - 'alert': 'Yo!', + alert: 'Yo!', 'alert-en-US': 'English', - } + }, }); expect(locales).toEqual(['fr', 'en-US']); }); @@ -73,8 +87,8 @@ describe('PushWorker', () => { it('should return and empty array if no locale is set', () => { const locales = PushUtils.getLocalesFromPush({ data: { - 'alert': 'Yo!', - } + alert: 'Yo!', + }, }); expect(locales).toEqual([]); }); @@ -82,282 +96,328 @@ describe('PushWorker', () => { it('should deduplicate locales', () => { const locales = PushUtils.getLocalesFromPush({ data: { - 'alert': 'Yo!', + alert: 'Yo!', 'alert-fr': 'french', - 'title-fr': 'french' - } + 'title-fr': 'french', + }, }); expect(locales).toEqual(['fr']); }); + it('should handle empty body data', () => { + expect(PushUtils.getLocalesFromPush({})).toEqual([]); + }); + it('transforms body appropriately', () => { - const cleanBody = PushUtils.transformPushBodyForLocale({ - data: { - alert: 'Yo!', - 'alert-fr': 'frenchy!', - 'alert-en': 'english', - } - }, 'fr'); + const cleanBody = PushUtils.transformPushBodyForLocale( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + }, + }, + 'fr' + ); expect(cleanBody).toEqual({ data: { - alert: 'frenchy!' - } + alert: 'frenchy!', + }, }); }); it('transforms body appropriately', () => { - const cleanBody = PushUtils.transformPushBodyForLocale({ - data: { - alert: 'Yo!', - 'alert-fr': 'frenchy!', - 'alert-en': 'english', - 'title-fr': 'french title' - } - }, 'fr'); + const cleanBody = PushUtils.transformPushBodyForLocale( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title', + }, + }, + 'fr' + ); expect(cleanBody).toEqual({ data: { alert: 'frenchy!', - title: 'french title' - } + title: 'french title', + }, }); }); it('maps body on all provided locales', () => { - const bodies = PushUtils.bodiesPerLocales({ - data: { - alert: 'Yo!', - 'alert-fr': 'frenchy!', - 'alert-en': 'english', - 'title-fr': 'french title' - } - }, ['fr', 'en']); + const bodies = PushUtils.bodiesPerLocales( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title', + }, + }, + ['fr', 'en'] + ); expect(bodies).toEqual({ fr: { data: { alert: 'frenchy!', - title: 'french title' - } + title: 'french title', + }, }, en: { data: { alert: 'english', - } + }, }, default: { data: { - alert: 'Yo!' - } - } + alert: 'Yo!', + }, + }, }); }); it('should properly handle default cases', () => { expect(PushUtils.transformPushBodyForLocale({})).toEqual({}); expect(PushUtils.stripLocalesFromBody({})).toEqual({}); - expect(PushUtils.bodiesPerLocales({where: {}})).toEqual({default: {where: {}}}); - expect(PushUtils.groupByLocaleIdentifier([])).toEqual({default: []}); + expect(PushUtils.bodiesPerLocales({ where: {} })).toEqual({ + default: { where: {} }, + }); + expect(PushUtils.groupByLocaleIdentifier([])).toEqual({ default: [] }); }); }); describe('pushStatus', () => { - it('should remove invalid installations', (done) => { + it('should remove invalid installations', done => { const config = Config.get('test'); const handler = pushStatusHandler(config); - const spy = spyOn(config.database, "update").and.callFake(() => { - return Promise.resolve(); + const spy = spyOn(config.database, 'update').and.callFake(() => { + return Promise.resolve({}); }); - const toAwait = handler.trackSent([ - { - transmitted: false, - device: { - deviceToken: 1, - deviceType: 'ios', - }, - response: { error: 'Unregistered' } - }, - { - transmitted: true, - device: { - deviceToken: 10, - deviceType: 'ios', - }, - }, - { - transmitted: false, - device: { - deviceToken: 2, - deviceType: 'ios', - }, - response: { error: 'NotRegistered' } - }, - { - transmitted: false, - device: { - deviceToken: 3, - deviceType: 'ios', - }, - response: { error: 'InvalidRegistration' } - }, - { - transmitted: true, - device: { - deviceToken: 11, - deviceType: 'ios', - }, - }, - { - transmitted: false, - device: { - deviceToken: 4, - deviceType: 'ios', - }, - response: { error: 'InvalidRegistration' } - }, - { - transmitted: false, - device: { - deviceToken: 5, - deviceType: 'ios', - }, - response: { error: 'InvalidRegistration' } - }, - { // should not be deleted - transmitted: false, - device: { - deviceToken: 101, - deviceType: 'ios', - }, - response: { error: 'invalid error...' } - } - ], undefined, true); - expect(spy).toHaveBeenCalled(); - expect(spy.calls.count()).toBe(1); - const lastCall = spy.calls.mostRecent(); - expect(lastCall.args[0]).toBe('_Installation'); - expect(lastCall.args[1]).toEqual({ - deviceToken: { '$in': [1,2,3,4,5] } - }); - expect(lastCall.args[2]).toEqual({ - deviceToken: { '__op': "Delete" } - }); - toAwait.then(done).catch(done); - }); - - it('tracks push status per UTC offsets', (done) => { - const config = Config.get('test'); - const handler = pushStatusHandler(config); - const spy = spyOn(rest, "update").and.callThrough(); - const UTCOffset = 1; - handler.setInitial().then(() => { - return handler.trackSent([ + const toAwait = handler.trackSent( + [ { transmitted: false, device: { deviceToken: 1, deviceType: 'ios', }, + response: { error: 'Unregistered' }, }, { transmitted: true, device: { - deviceToken: 1, + deviceToken: 10, deviceType: 'ios', - } + }, }, - ], UTCOffset) - }).then(() => { - expect(spy).toHaveBeenCalled(); - const lastCall = spy.calls.mostRecent(); - expect(lastCall.args[2]).toBe(`_PushStatus`); - expect(lastCall.args[4]).toEqual({ - numSent: { __op: 'Increment', amount: 1 }, - numFailed: { __op: 'Increment', amount: 1 }, - 'sentPerType.ios': { __op: 'Increment', amount: 1 }, - 'failedPerType.ios': { __op: 'Increment', amount: 1 }, - [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, - [`failedPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, - count: { __op: 'Increment', amount: -2 }, - }); - const query = new Parse.Query('_PushStatus'); - return query.get(handler.objectId, { useMasterKey: true }); - }).then((pushStatus) => { - const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); - expect(sentPerUTCOffset['1']).toBe(1); - const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); - expect(failedPerUTCOffset['1']).toBe(1); - return handler.trackSent([ { transmitted: false, device: { - deviceToken: 1, + deviceToken: 2, deviceType: 'ios', }, + response: { error: 'NotRegistered' }, }, { - transmitted: true, + transmitted: false, device: { - deviceToken: 1, + deviceToken: 3, deviceType: 'ios', - } + }, + response: { error: 'InvalidRegistration' }, }, { transmitted: true, device: { - deviceToken: 1, + deviceToken: 11, deviceType: 'ios', - } + }, }, - ], UTCOffset) - }).then(() => { - const query = new Parse.Query('_PushStatus'); - return query.get(handler.objectId, { useMasterKey: true }); - }).then((pushStatus) => { - const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); - expect(sentPerUTCOffset['1']).toBe(3); - const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); - expect(failedPerUTCOffset['1']).toBe(2); - }).then(done).catch(done.fail); - }); - - it('tracks push status per UTC offsets with negative offsets', (done) => { - const config = Config.get('test'); - const handler = pushStatusHandler(config); - const spy = spyOn(rest, "update").and.callThrough(); - const UTCOffset = -6; - handler.setInitial().then(() => { - return handler.trackSent([ { transmitted: false, device: { - deviceToken: 1, + deviceToken: 4, deviceType: 'ios', }, - response: { error: 'Unregistered' } + response: { error: 'InvalidRegistration' }, }, { - transmitted: true, + transmitted: false, device: { - deviceToken: 1, + deviceToken: 5, deviceType: 'ios', }, - response: { error: 'Unregistered' } + response: { error: 'InvalidRegistration' }, }, - ], UTCOffset); - }).then(() => { - expect(spy).toHaveBeenCalled(); - const lastCall = spy.calls.mostRecent(); - expect(lastCall.args[2]).toBe('_PushStatus'); - expect(lastCall.args[4]).toEqual({ - numSent: { __op: 'Increment', amount: 1 }, - numFailed: { __op: 'Increment', amount: 1 }, - 'sentPerType.ios': { __op: 'Increment', amount: 1 }, - 'failedPerType.ios': { __op: 'Increment', amount: 1 }, - [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, - [`failedPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, - count: { __op: 'Increment', amount: -2 }, - }); - done(); + { + // should not be deleted + transmitted: false, + device: { + deviceToken: 101, + deviceType: 'ios', + }, + response: { error: 'invalid error...' }, + }, + ], + undefined, + true + ); + expect(spy).toHaveBeenCalled(); + expect(spy.calls.count()).toBe(1); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[0]).toBe('_Installation'); + expect(lastCall.args[1]).toEqual({ + deviceToken: { $in: [1, 2, 3, 4, 5] }, }); + expect(lastCall.args[2]).toEqual({ + deviceToken: { __op: 'Delete' }, + }); + toAwait.then(done).catch(done); + }); + + it('tracks push status per UTC offsets', done => { + const config = Config.get('test'); + const handler = pushStatusHandler(config); + const spy = spyOn(rest, 'update').and.callThrough(); + const UTCOffset = 1; + handler + .setInitial() + .then(() => { + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + ], + UTCOffset + ); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[2]).toBe(`_PushStatus`); + expect(lastCall.args[4]).toEqual({ + numSent: { __op: 'Increment', amount: 1 }, + numFailed: { __op: 'Increment', amount: 1 }, + 'sentPerType.ios': { __op: 'Increment', amount: 1 }, + 'failedPerType.ios': { __op: 'Increment', amount: 1 }, + [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, + [`failedPerUTCOffset.${UTCOffset}`]: { + __op: 'Increment', + amount: 1, + }, + count: { __op: 'Increment', amount: -1 }, + }); + const query = new Parse.Query('_PushStatus'); + return query.get(handler.objectId, { useMasterKey: true }); + }) + .then(pushStatus => { + const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); + expect(sentPerUTCOffset['1']).toBe(1); + const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); + expect(failedPerUTCOffset['1']).toBe(1); + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + ], + UTCOffset + ); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.get(handler.objectId, { useMasterKey: true }); + }) + .then(pushStatus => { + const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); + expect(sentPerUTCOffset['1']).toBe(3); + const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); + expect(failedPerUTCOffset['1']).toBe(2); + }) + .then(done) + .catch(done.fail); + }); + + it('tracks push status per UTC offsets with negative offsets', done => { + const config = Config.get('test'); + const handler = pushStatusHandler(config); + const spy = spyOn(rest, 'update').and.callThrough(); + const UTCOffset = -6; + handler + .setInitial() + .then(() => { + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + response: { error: 'Unregistered' }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + response: { error: 'Unregistered' }, + }, + ], + UTCOffset + ); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[2]).toBe('_PushStatus'); + expect(lastCall.args[4]).toEqual({ + numSent: { __op: 'Increment', amount: 1 }, + numFailed: { __op: 'Increment', amount: 1 }, + 'sentPerType.ios': { __op: 'Increment', amount: 1 }, + 'failedPerType.ios': { __op: 'Increment', amount: 1 }, + [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, + [`failedPerUTCOffset.${UTCOffset}`]: { + __op: 'Increment', + amount: 1, + }, + count: { __op: 'Increment', amount: -1 }, + }); + done(); + }); }); }); }); diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index b43ccb5e86..8f6f6c71da 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -1,27 +1,26 @@ -var Parse = require('parse/node'); +const Parse = require('parse/node'); -var Id = require('../src/LiveQuery/Id'); -var QueryTools = require('../src/LiveQuery/QueryTools'); -var queryHash = QueryTools.queryHash; -var matchesQuery = QueryTools.matchesQuery; +const Id = require('../lib/LiveQuery/Id'); +const QueryTools = require('../lib/LiveQuery/QueryTools'); +const queryHash = QueryTools.queryHash; +const matchesQuery = QueryTools.matchesQuery; -var Item = Parse.Object.extend('Item'); +const Item = Parse.Object.extend('Item'); describe('queryHash', function() { - it('should always hash a query to the same string', function() { - var q = new Parse.Query(Item); + const q = new Parse.Query(Item); q.equalTo('field', 'value'); q.exists('name'); q.ascending('createdAt'); q.limit(10); - var firstHash = queryHash(q); - var secondHash = queryHash(q); + const firstHash = queryHash(q); + const secondHash = queryHash(q); expect(firstHash).toBe(secondHash); }); it('should return equivalent hashes for equivalent queries', function() { - var q1 = new Parse.Query(Item); + let q1 = new Parse.Query(Item); q1.equalTo('field', 'value'); q1.exists('name'); q1.lessThan('age', 30); @@ -30,7 +29,7 @@ describe('queryHash', function() { q1.include(['name', 'age']); q1.limit(10); - var q2 = new Parse.Query(Item); + let q2 = new Parse.Query(Item); q2.limit(10); q2.greaterThan('age', 3); q2.lessThan('age', 30); @@ -39,8 +38,8 @@ describe('queryHash', function() { q2.exists('name'); q2.equalTo('field', 'value'); - var firstHash = queryHash(q1); - var secondHash = queryHash(q2); + let firstHash = queryHash(q1); + let secondHash = queryHash(q2); expect(firstHash).toBe(secondHash); q1.containedIn('fruit', ['apple', 'banana', 'cherry']); @@ -70,10 +69,10 @@ describe('queryHash', function() { }); it('should not let fields of different types appear similar', function() { - var q1 = new Parse.Query(Item); + let q1 = new Parse.Query(Item); q1.lessThan('age', 30); - var q2 = new Parse.Query(Item); + const q2 = new Parse.Query(Item); q2.equalTo('age', '{$lt:30}'); expect(queryHash(q1)).not.toBe(queryHash(q2)); @@ -89,11 +88,11 @@ describe('queryHash', function() { describe('matchesQuery', function() { it('matches blanket queries', function() { - var obj = { + const obj = { id: new Id('Klass', 'O1'), - value: 12 + value: 12, }; - var q = new Parse.Query('Klass'); + const q = new Parse.Query('Klass'); expect(matchesQuery(obj, q)).toBe(true); obj.id = new Id('Other', 'O1'); @@ -101,11 +100,11 @@ describe('matchesQuery', function() { }); it('matches existence queries', function() { - var obj = { + const obj = { id: new Id('Item', 'O1'), - count: 15 + count: 15, }; - var q = new Parse.Query('Item'); + const q = new Parse.Query('Item'); q.exists('count'); expect(matchesQuery(obj, q)).toBe(true); q.exists('name'); @@ -113,11 +112,11 @@ describe('matchesQuery', function() { }); it('matches queries with doesNotExist constraint', function() { - var obj = { + const obj = { id: new Id('Item', 'O1'), - count: 15 + count: 15, }; - var q = new Parse.Query('Item'); + let q = new Parse.Query('Item'); q.doesNotExist('name'); expect(matchesQuery(obj, q)).toBe(true); @@ -127,20 +126,20 @@ describe('matchesQuery', function() { }); it('matches on equality queries', function() { - var day = new Date(); - var location = new Parse.GeoPoint({ + const day = new Date(); + const location = new Parse.GeoPoint({ latitude: 37.484815, - longitude: -122.148377 + longitude: -122.148377, }); - var obj = { + const obj = { id: new Id('Person', 'O1'), score: 12, name: 'Bill', birthday: day, - lastLocation: location + lastLocation: location, }; - var q = new Parse.Query('Person'); + let q = new Parse.Query('Person'); q.equalTo('score', 12); expect(matchesQuery(obj, q)).toBe(true); @@ -169,21 +168,30 @@ describe('matchesQuery', function() { expect(matchesQuery(obj, q)).toBe(false); q = new Parse.Query('Person'); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.484815, - longitude: -122.148377 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377, + }) + ); expect(matchesQuery(obj, q)).toBe(true); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.4848, - longitude: -122.1483 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.4848, + longitude: -122.1483, + }) + ); expect(matchesQuery(obj, q)).toBe(false); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.484815, - longitude: -122.148377 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377, + }) + ); q.equalTo('score', 12); q.equalTo('name', 'Bill'); q.equalTo('birthday', day); @@ -192,9 +200,9 @@ describe('matchesQuery', function() { q.equalTo('name', 'bill'); expect(matchesQuery(obj, q)).toBe(false); - var img = { + let img = { id: new Id('Image', 'I1'), - tags: ['nofilter', 'latergram', 'tbt'] + tags: ['nofilter', 'latergram', 'tbt'], }; q = new Parse.Query('Image'); @@ -203,13 +211,13 @@ describe('matchesQuery', function() { q.equalTo('tags', 'tbt'); expect(matchesQuery(img, q)).toBe(true); - var q2 = new Parse.Query('Image'); + const q2 = new Parse.Query('Image'); q2.containsAll('tags', ['latergram', 'nofilter']); expect(matchesQuery(img, q2)).toBe(true); q2.containsAll('tags', ['latergram', 'selfie']); expect(matchesQuery(img, q2)).toBe(false); - var u = new Parse.User(); + const u = new Parse.User(); u.id = 'U2'; q = new Parse.Query('Image'); q.equalTo('owner', u); @@ -219,8 +227,8 @@ describe('matchesQuery', function() { objectId: 'I1', owner: { className: '_User', - objectId: 'U2' - } + objectId: 'U2', + }, }; expect(matchesQuery(img, q)).toBe(true); @@ -234,10 +242,12 @@ describe('matchesQuery', function() { img = { className: 'Image', objectId: 'I1', - owners: [{ - className: '_User', - objectId: 'U2' - }] + owners: [ + { + className: '_User', + objectId: 'U2', + }, + ], }; expect(matchesQuery(img, q)).toBe(true); @@ -246,13 +256,13 @@ describe('matchesQuery', function() { }); it('matches on inequalities', function() { - var player = { + const player = { id: new Id('Person', 'O1'), score: 12, name: 'Bill', birthday: new Date(1980, 2, 4), }; - var q = new Parse.Query('Person'); + let q = new Parse.Query('Person'); q.lessThan('score', 15); expect(matchesQuery(player, q)).toBe(true); q.lessThan('score', 10); @@ -288,29 +298,29 @@ describe('matchesQuery', function() { }); it('matches an $or query', function() { - var player = { + const player = { id: new Id('Player', 'P1'), name: 'Player 1', - score: 12 + score: 12, }; - var q = new Parse.Query('Player'); + const q = new Parse.Query('Player'); q.equalTo('name', 'Player 1'); - var q2 = new Parse.Query('Player'); + const q2 = new Parse.Query('Player'); q2.equalTo('name', 'Player 2'); - var orQuery = Parse.Query.or(q, q2); + const orQuery = Parse.Query.or(q, q2); expect(matchesQuery(player, q)).toBe(true); expect(matchesQuery(player, q2)).toBe(false); expect(matchesQuery(player, orQuery)).toBe(true); }); it('matches $regex queries', function() { - var player = { + const player = { id: new Id('Player', 'P1'), name: 'Player 1', - score: 12 + score: 12, }; - var q = new Parse.Query('Player'); + let q = new Parse.Query('Player'); q.startsWith('name', 'Play'); expect(matchesQuery(player, q)).toBe(true); q.startsWith('name', 'Ploy'); @@ -353,19 +363,19 @@ describe('matchesQuery', function() { }); it('matches $nearSphere queries', function() { - var q = new Parse.Query('Checkin'); + let q = new Parse.Query('Checkin'); q.near('location', new Parse.GeoPoint(20, 20)); // With no max distance, any GeoPoint is 'near' - var pt = { + const pt = { id: new Id('Checkin', 'C1'), - location: new Parse.GeoPoint(40, 40) + location: new Parse.GeoPoint(40, 40), }; - var ptUndefined = { - id: new Id('Checkin', 'C1') + const ptUndefined = { + id: new Id('Checkin', 'C1'), }; - var ptNull = { + const ptNull = { id: new Id('Checkin', 'C1'), - location: null + location: null, }; expect(matchesQuery(pt, q)).toBe(true); expect(matchesQuery(ptUndefined, q)).toBe(false); @@ -381,30 +391,30 @@ describe('matchesQuery', function() { }); it('matches $within queries', function() { - var caltrainStation = { + const caltrainStation = { id: new Id('Checkin', 'C1'), location: new Parse.GeoPoint(37.776346, -122.394218), - name: 'Caltrain' + name: 'Caltrain', }; - var santaClara = { + const santaClara = { id: new Id('Checkin', 'C2'), location: new Parse.GeoPoint(37.325635, -121.945753), - name: 'Santa Clara' + name: 'Santa Clara', }; - var noLocation = { + const noLocation = { id: new Id('Checkin', 'C2'), - name: 'Santa Clara' + name: 'Santa Clara', }; - var nullLocation = { + const nullLocation = { id: new Id('Checkin', 'C2'), location: null, - name: 'Santa Clara' + name: 'Santa Clara', }; - var q = new Parse.Query('Checkin').withinGeoBox( + let q = new Parse.Query('Checkin').withinGeoBox( 'location', new Parse.GeoPoint(37.708813, -122.526398), new Parse.GeoPoint(37.822802, -122.373962) @@ -435,65 +445,64 @@ describe('matchesQuery', function() { }); it('matches on subobjects with dot notation', function() { - var message = { + const message = { id: new Id('Message', 'O1'), - text: "content", - status: {x: "read", y: "delivered"} + text: 'content', + status: { x: 'read', y: 'delivered' }, }; - var q = new Parse.Query('Message'); - q.equalTo("status.x", "read"); + let q = new Parse.Query('Message'); + q.equalTo('status.x', 'read'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.equalTo("status.z", "read"); + q.equalTo('status.z', 'read'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.equalTo("status.x", "delivered"); + q.equalTo('status.x', 'delivered'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.notEqualTo("status.x", "read"); + q.notEqualTo('status.x', 'read'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.notEqualTo("status.z", "read"); + q.notEqualTo('status.z', 'read'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.notEqualTo("status.x", "delivered"); + q.notEqualTo('status.x', 'delivered'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.exists("status.x"); + q.exists('status.x'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.exists("status.z"); + q.exists('status.z'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.exists("nonexistent.x"); + q.exists('nonexistent.x'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.doesNotExist("status.x"); + q.doesNotExist('status.x'); expect(matchesQuery(message, q)).toBe(false); q = new Parse.Query('Message'); - q.doesNotExist("status.z"); + q.doesNotExist('status.z'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.doesNotExist("nonexistent.z"); + q.doesNotExist('nonexistent.z'); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.equalTo("status.x", "read"); - q.doesNotExist("status.y"); + q.equalTo('status.x', 'read'); + q.doesNotExist('status.y'); expect(matchesQuery(message, q)).toBe(false); - }); function pointer(className, objectId) { @@ -501,53 +510,61 @@ describe('matchesQuery', function() { } it('should support containedIn with pointers', () => { - var message = { + const message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'abc') + profile: pointer('Profile', 'abc'), }; - var q = new Parse.Query('Message'); - q.containedIn('profile', [Parse.Object.fromJSON({ className: 'Profile', objectId: 'abc' }), - Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' })]); + let q = new Parse.Query('Message'); + q.containedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'abc' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); expect(matchesQuery(message, q)).toBe(true); q = new Parse.Query('Message'); - q.containedIn('profile', [Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), - Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' })]); + q.containedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); expect(matchesQuery(message, q)).toBe(false); }); it('should support notContainedIn with pointers', () => { - var message = { + let message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'abc') + profile: pointer('Profile', 'abc'), }; - var q = new Parse.Query('Message'); - q.notContainedIn('profile', [Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), - Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' })]); + let q = new Parse.Query('Message'); + q.notContainedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + ]); expect(matchesQuery(message, q)).toBe(true); message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'def') + profile: pointer('Profile', 'def'), }; q = new Parse.Query('Message'); - q.notContainedIn('profile', [Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), - Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' })]); + q.notContainedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); expect(matchesQuery(message, q)).toBe(false); }); it('should support containedIn queries with [objectId]', () => { - var message = { + let message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'abc') + profile: pointer('Profile', 'abc'), }; - var q = new Parse.Query('Message'); + let q = new Parse.Query('Message'); q.containedIn('profile', ['abc', 'def']); expect(matchesQuery(message, q)).toBe(true); message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'ghi') + profile: pointer('Profile', 'ghi'), }; q = new Parse.Query('Message'); q.containedIn('profile', ['abc', 'def']); @@ -555,19 +572,64 @@ describe('matchesQuery', function() { }); it('should support notContainedIn queries with [objectId]', () => { - var message = { + let message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'ghi') + profile: pointer('Profile', 'ghi'), }; - var q = new Parse.Query('Message'); + let q = new Parse.Query('Message'); q.notContainedIn('profile', ['abc', 'def']); expect(matchesQuery(message, q)).toBe(true); message = { id: new Id('Message', 'O1'), - profile: pointer('Profile', 'ghi') + profile: pointer('Profile', 'ghi'), }; q = new Parse.Query('Message'); q.notContainedIn('profile', ['abc', 'def', 'ghi']); expect(matchesQuery(message, q)).toBe(false); }); + + it('matches on Date', () => { + // given + const now = new Date(); + const obj = { + id: new Id('Person', '01'), + dateObject: now, + dateJSON: { + __type: 'Date', + iso: now.toISOString(), + }, + }; + + // when, then: Equal + let q = new Parse.Query('Person'); + q.equalTo('dateObject', now); + q.equalTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: lessThan + const future = Date(now.getTime() + 1000); + q = new Parse.Query('Person'); + q.lessThan('dateObject', future); + q.lessThan('dateJSON', future); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: lessThanOrEqualTo + q = new Parse.Query('Person'); + q.lessThanOrEqualTo('dateObject', now); + q.lessThanOrEqualTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: greaterThan + const past = Date(now.getTime() - 1000); + q = new Parse.Query('Person'); + q.greaterThan('dateObject', past); + q.greaterThan('dateJSON', past); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: greaterThanOrEqualTo + q = new Parse.Query('Person'); + q.greaterThanOrEqualTo('dateObject', now); + q.greaterThanOrEqualTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + }); }); diff --git a/spec/ReadPreferenceOption.spec.js b/spec/ReadPreferenceOption.spec.js index c25dec0f01..e52f657466 100644 --- a/spec/ReadPreferenceOption.spec.js +++ b/spec/ReadPreferenceOption.spec.js @@ -1,45 +1,98 @@ -'use strict' +'use strict'; const Parse = require('parse/node'); const ReadPreference = require('mongodb').ReadPreference; -const rp = require('request-promise'); -const Config = require("../src/Config"); +const request = require('../lib/request'); +const Config = require('../lib/Config'); describe_only_db('mongo')('Read preference option', () => { - it('should find in primary by default', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should find in primary by default', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); const obj1 = new Parse.Object('MyObject'); obj1.set('boolKey', true); - Parse.Object.saveAll([obj0, obj1]).then(() => { - spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + Parse.Object.saveAll([obj0, obj1]) + .then(() => { + spyOn( + databaseAdapter.database.serverConfig, + 'cursor' + ).and.callThrough(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + return query.find().then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.PRIMARY + ); + } + }); + + expect(myObjectReadPreference).toBe(true); + + done(); + }); + }) + .catch(done.fail); + }); - const query = new Parse.Query('MyObject'); - query.equalTo('boolKey', false); + it('should preserve the read preference set (#4831)', async () => { + const { + MongoStorageAdapter, + } = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter'); + const adapterOptions = { + uri: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', + mongoOptions: { + readPreference: ReadPreference.NEAREST, + }, + }; + await reconfigureServer({ + databaseAdapter: new MongoStorageAdapter(adapterOptions), + }); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); - expect(myObjectReadPreference).toEqual(ReadPreference.PRIMARY); + await Parse.Object.saveAll([obj0, obj1]); + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - done(); - }); + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls.all().forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = true; + expect(call.args[0].options.readPreference.mode).toBe( + ReadPreference.NEAREST + ); + } }); + + expect(myObjectReadPreference).toBe(true); }); - it('should change read preference in the beforeFind trigger', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference in the beforeFind trigger', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -49,33 +102,81 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY'; }); const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); + }); + }); - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); + it('should check read preference as case insensitive', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - done(); + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'sEcOnDarY'; }); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference in the beforeFind trigger even changing query', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference in the beforeFind trigger even changing query', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -85,7 +186,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.query.equalTo('boolKey', true); req.readPreference = 'SECONDARY'; }); @@ -93,26 +194,32 @@ describe_only_db('mongo')('Read preference option', () => { const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(true); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference in the beforeFind trigger even returning query', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference in the beforeFind trigger even returning query', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -122,7 +229,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY'; const otherQuery = new Parse.Query('MyObject'); @@ -133,26 +240,32 @@ describe_only_db('mongo')('Read preference option', () => { const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(true); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference in the beforeFind trigger even returning promise', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference in the beforeFind trigger even returning promise', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -162,7 +275,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY'; const otherQuery = new Parse.Query('MyObject'); @@ -173,26 +286,32 @@ describe_only_db('mongo')('Read preference option', () => { const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(true); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference to PRIMARY_PREFERRED', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference to PRIMARY_PREFERRED', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -202,33 +321,41 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'PRIMARY_PREFERRED'; }); const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference).toEqual(ReadPreference.PRIMARY_PREFERRED); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual( + ReadPreference.PRIMARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference to SECONDARY_PREFERRED', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference to SECONDARY_PREFERRED', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -238,33 +365,41 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY_PREFERRED'; }); const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY_PREFERRED); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference to NEAREST', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference to NEAREST', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -274,33 +409,79 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'NEAREST'; }); const query = new Parse.Query('MyObject'); query.equalTo('boolKey', false); - query.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); + query + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.NEAREST); + + done(); + }) + .catch(done.fail); + }); + }); - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); + it('should change read preference for GET', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - expect(myObjectReadPreference).toEqual(ReadPreference.NEAREST); + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); - done(); + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; }); + + const query = new Parse.Query('MyObject'); + + query + .get(obj0.id) + .then(result => { + expect(result.get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference for GET', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference for GET using API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -310,31 +491,137 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY'; }); - const query = new Parse.Query('MyObject'); + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/' + obj0.id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + const body = response.data; + expect(body.boolKey).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); + }); + }); - query.get(obj0.id).then((result) => { - expect(result.get('boolKey')).toBe(false); + it('should change read preference for GET directly from API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - done(); + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject/' + + obj0.id + + '?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.boolKey).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); + }); + }); + + it('should change read preference for GET using API through the beforeFind overriding API option', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY_PREFERRED'; }); + + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject/' + + obj0.id + + '?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.boolKey).toBe(false); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference for GET using API', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference for FIND using API through beforeFind trigger', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -344,37 +631,84 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { + Parse.Cloud.beforeFind('MyObject', req => { req.readPreference = 'SECONDARY'; }); - rp({ + request({ method: 'GET', - uri: 'http://localhost:8378/1/classes/MyObject/' + obj0.id, + url: 'http://localhost:8378/1/classes/MyObject/', headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, json: true, - }).then(body => { - expect(body.boolKey).toBe(false); - - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject') >= 0) { - myObjectReadPreference = call.args[2].readPreference.preference; - } - }); + }) + .then(response => { + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); + }); + }); - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + it('should change read preference for FIND directly from API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - done(); - }); + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change read preference for count', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change read preference for FIND using API through the beforeFind overriding API option', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject'); obj0.set('boolKey', false); @@ -382,32 +716,88 @@ describe_only_db('mongo')('Read preference option', () => { obj1.set('boolKey', true); Parse.Object.saveAll([obj0, obj1]).then(() => { - spyOn(databaseAdapter.database.serverConfig, 'command').and.callThrough(); + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject', (req) => { - req.readPreference = 'SECONDARY'; + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY_PREFERRED'; }); - const query = new Parse.Query('MyObject'); - query.equalTo('boolKey', false); + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject/?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); + }); + }); - query.count().then((result) => { - expect(result).toBe(1); + xit('should change read preference for count', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - let myObjectReadPreference = null; - databaseAdapter.database.serverConfig.command.calls.all().forEach((call) => { - myObjectReadPreference = call.args[2].readPreference.preference; - }); + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); - expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - done(); + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; }); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + query + .count() + .then(result => { + expect(result).toBe(1); + + let myObjectReadPreference = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should find includes in primary by default', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should find includes in same replica of readPreference by default', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -421,7 +811,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY'; }); @@ -430,39 +820,52 @@ describe_only_db('mongo')('Read preference option', () => { query.include('myObject1'); query.include('myObject1.myObject0'); - query.find().then((results) => { - expect(results.length).toBe(1); - const firstResult = results[0]; - expect(firstResult.get('boolKey')).toBe(false); - expect(firstResult.get('myObject1').get('boolKey')).toBe(true); - expect(firstResult.get('myObject1').get('myObject0').get('boolKey')).toBe(false); - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference0).toEqual(ReadPreference.PRIMARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.PRIMARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); - - done(); - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + const firstResult = results[0]; + expect(firstResult.get('boolKey')).toBe(false); + expect(firstResult.get('myObject1').get('boolKey')).toBe(true); + expect( + firstResult + .get('myObject1') + .get('myObject0') + .get('boolKey') + ).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change includes read preference', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change includes read preference', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -476,7 +879,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY_PREFERRED'; req.includeReadPreference = 'SECONDARY'; }); @@ -486,40 +889,121 @@ describe_only_db('mongo')('Read preference option', () => { query.include('myObject1'); query.include('myObject1.myObject0'); - query.find().then((results) => { - expect(results.length).toBe(1); - const firstResult = results[0]; - expect(firstResult.get('boolKey')).toBe(false); - expect(firstResult.get('myObject1').get('boolKey')).toBe(true); - expect(firstResult.get('myObject1').get('myObject0').get('boolKey')).toBe(false); - - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); + query + .find() + .then(results => { + expect(results.length).toBe(1); + const firstResult = results[0]; + expect(firstResult.get('boolKey')).toBe(false); + expect(firstResult.get('myObject1').get('boolKey')).toBe(true); + expect( + firstResult + .get('myObject1') + .get('myObject0') + .get('boolKey') + ).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); + }); + }); - expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + it('should change includes read preference when finding through API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; - done(); - }); + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2/' + + obj2.id + + '?include=' + + JSON.stringify(['myObject1', 'myObject1.myObject0']) + + '&readPreference=SECONDARY_PREFERRED&includeReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + const firstResult = response.data; + expect(firstResult.boolKey).toBe(false); + expect(firstResult.myObject1.boolKey).toBe(true); + expect(firstResult.myObject1.myObject0.boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should find subqueries in primary by default', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change includes read preference when getting through API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -533,7 +1017,75 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2?where=' + + JSON.stringify({ boolKey: false }) + + '&include=' + + JSON.stringify(['myObject1', 'myObject1.myObject0']) + + '&readPreference=SECONDARY_PREFERRED&includeReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.results.length).toBe(1); + const firstResult = response.data.results[0]; + expect(firstResult.boolKey).toBe(false); + expect(firstResult.myObject1.boolKey).toBe(true); + expect(firstResult.myObject1.myObject0.boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); + }); + }); + + it('should find subqueries in same replica of readPreference by default', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY'; }); @@ -546,36 +1098,44 @@ describe_only_db('mongo')('Read preference option', () => { const query2 = new Parse.Query('MyObject2'); query2.matchesQuery('myObject1', query1); - query2.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference0).toEqual(ReadPreference.PRIMARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.PRIMARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); - - done(); - }); + query2 + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); }); }); - it('should change subqueries read preference when using matchesQuery', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change subqueries read preference when using matchesQuery', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -589,7 +1149,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY_PREFERRED'; req.subqueryReadPreference = 'SECONDARY'; }); @@ -603,36 +1163,46 @@ describe_only_db('mongo')('Read preference option', () => { const query2 = new Parse.Query('MyObject2'); query2.matchesQuery('myObject1', query1); - query2.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); - - done(); - }); + query2 + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should change subqueries read preference when using doesNotMatchQuery', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change subqueries read preference when using doesNotMatchQuery', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -646,7 +1216,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY_PREFERRED'; req.subqueryReadPreference = 'SECONDARY'; }); @@ -660,36 +1230,46 @@ describe_only_db('mongo')('Read preference option', () => { const query2 = new Parse.Query('MyObject2'); query2.doesNotMatchQuery('myObject1', query1); - query2.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); - - expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); - - done(); - }); + query2 + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); - it('should change subqueries read preference when using matchesKeyInQuery and doesNotMatchKeyInQuery', (done) => { - const databaseAdapter = (Config.get(Parse.applicationId)).database.adapter; + it('should change subqueries read preference when using matchesKeyInQuery and doesNotMatchKeyInQuery', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; const obj0 = new Parse.Object('MyObject0'); obj0.set('boolKey', false); @@ -703,7 +1283,7 @@ describe_only_db('mongo')('Read preference option', () => { Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - Parse.Cloud.beforeFind('MyObject2', (req) => { + Parse.Cloud.beforeFind('MyObject2', req => { req.readPreference = 'SECONDARY_PREFERRED'; req.subqueryReadPreference = 'SECONDARY'; }); @@ -718,31 +1298,123 @@ describe_only_db('mongo')('Read preference option', () => { query2.matchesKeyInQuery('boolKey', 'boolKey', query0); query2.doesNotMatchKeyInQuery('boolKey', 'boolKey', query1); - query2.find().then((results) => { - expect(results.length).toBe(1); - expect(results[0].get('boolKey')).toBe(false); - - let myObjectReadPreference0 = null; - let myObjectReadPreference1 = null; - let myObjectReadPreference2 = null; - databaseAdapter.database.serverConfig.cursor.calls.all().forEach((call) => { - if (call.args[0].indexOf('MyObject0') >= 0) { - myObjectReadPreference0 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject1') >= 0) { - myObjectReadPreference1 = call.args[2].readPreference.preference; - } - if (call.args[0].indexOf('MyObject2') >= 0) { - myObjectReadPreference2 = call.args[2].readPreference.preference; - } - }); + query2 + .find() + .then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); + }); + }); + + it('should change subqueries read preference when using matchesKeyInQuery and doesNotMatchKeyInQuery to find through API', done => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); - expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); - expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + Parse.Object.saveAll([obj0, obj1, obj2]).then(() => { + spyOn(databaseAdapter.database.serverConfig, 'cursor').and.callThrough(); - done(); + const whereString = JSON.stringify({ + boolKey: { + $select: { + query: { + className: 'MyObject0', + where: { boolKey: false }, + }, + key: 'boolKey', + }, + $dontSelect: { + query: { + className: 'MyObject1', + where: { boolKey: true }, + }, + key: 'boolKey', + }, + }, }); + + request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2/?where=' + + whereString + + '&readPreference=SECONDARY_PREFERRED&subqueryReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }) + .then(response => { + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + databaseAdapter.database.serverConfig.cursor.calls + .all() + .forEach(call => { + if (call.args[0].ns.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = + call.args[0].options.readPreference.mode; + } + if (call.args[0].ns.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = + call.args[0].options.readPreference.mode; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual( + ReadPreference.SECONDARY_PREFERRED + ); + + done(); + }) + .catch(done.fail); }); }); }); diff --git a/spec/RedisCacheAdapter.spec.js b/spec/RedisCacheAdapter.spec.js index 8707da253f..9ae53cf688 100644 --- a/spec/RedisCacheAdapter.spec.js +++ b/spec/RedisCacheAdapter.spec.js @@ -1,4 +1,7 @@ -var RedisCacheAdapter = require('../src/Adapters/Cache/RedisCacheAdapter').default; +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter') + .default; +const Config = require('../lib/Config'); + /* To run this test part of the complete suite set PARSE_SERVER_TEST_CACHE='redis' @@ -7,50 +10,511 @@ and make sure a redis server is available on the default port describe_only(() => { return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; })('RedisCacheAdapter', function() { - var KEY = 'hello'; - var VALUE = 'world'; + const KEY = 'hello'; + const VALUE = 'world'; function wait(sleep) { return new Promise(function(resolve) { setTimeout(resolve, sleep); - }) + }); } - it('should get/set/clear', (done) => { - var cache = new RedisCacheAdapter({ - ttl: NaN + it('should get/set/clear', done => { + const cache = new RedisCacheAdapter({ + ttl: NaN, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) .then(() => cache.clear()) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); }); - it('should expire after ttl', (done) => { - var cache = new RedisCacheAdapter(null, 1); + it('should expire after ttl', done => { + const cache = new RedisCacheAdapter(null, 50); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) - .then(wait.bind(null, 2)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 52)) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + .then(done); + }); + + it('should not store value for ttl=0', done => { + const cache = new RedisCacheAdapter(null, 5); + + cache + .put(KEY, VALUE, 0) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); }); - it('should find un-expired records', (done) => { - var cache = new RedisCacheAdapter(null, 5); + it('should not expire when ttl=Infinity', done => { + const cache = new RedisCacheAdapter(null, 1); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE, Infinity) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 5)) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) + .then(done); + }); + + it('should fallback to default ttl', done => { + const cache = new RedisCacheAdapter(null, 1); + let promise = Promise.resolve(); + + [-100, null, undefined, 'not number', true].forEach(ttl => { + promise = promise.then(() => + cache + .put(KEY, VALUE, ttl) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 5)) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + ); + }); + + promise.then(done); + }); + + it('should find un-expired records', done => { + const cache = new RedisCacheAdapter(null, 5); + + cache + .put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) .then(wait.bind(null, 1)) .then(() => cache.get(KEY)) - .then((value) => expect(value).not.toEqual(null)) + .then(value => expect(value).not.toEqual(null)) + .then(done); + }); +}); + +describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; +})('RedisCacheAdapter/KeyPromiseQueue', function() { + const KEY1 = 'key1'; + const KEY2 = 'key2'; + const VALUE = 'hello'; + + // number of chained ops on a single key + function getQueueCountForKey(cache, key) { + return cache.queue.queue[key][0]; + } + + // total number of queued keys + function getQueueCount(cache) { + return Object.keys(cache.queue.queue).length; + } + + it('it should clear completed operations from queue', done => { + const cache = new RedisCacheAdapter({ ttl: NaN }); + + // execute a bunch of operations in sequence + let promise = Promise.resolve(); + for (let index = 1; index < 100; index++) { + promise = promise.then(() => { + const key = `${index}`; + return cache + .put(key, VALUE) + .then(() => expect(getQueueCount(cache)).toEqual(0)) + .then(() => cache.get(key)) + .then(() => expect(getQueueCount(cache)).toEqual(0)) + .then(() => cache.clear()) + .then(() => expect(getQueueCount(cache)).toEqual(0)); + }); + } + + // at the end the queue should be empty + promise.then(() => expect(getQueueCount(cache)).toEqual(0)).then(done); + }); + + it('it should count per key chained operations correctly', done => { + const cache = new RedisCacheAdapter({ ttl: NaN }); + + let key1Promise = Promise.resolve(); + let key2Promise = Promise.resolve(); + for (let index = 1; index < 100; index++) { + key1Promise = cache.put(KEY1, VALUE); + key2Promise = cache.put(KEY2, VALUE); + // per key chain should be equal to index, which is the + // total number of operations on that key + expect(getQueueCountForKey(cache, KEY1)).toEqual(index); + expect(getQueueCountForKey(cache, KEY2)).toEqual(index); + // the total keys counts should be equal to the different keys + // we have currently being processed. + expect(getQueueCount(cache)).toEqual(2); + } + + // at the end the queue should be empty + Promise.all([key1Promise, key2Promise]) + .then(() => expect(getQueueCount(cache)).toEqual(0)) .then(done); }); }); + +describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; +})('Redis Performance', function() { + let cacheAdapter; + let getSpy; + let putSpy; + let delSpy; + + beforeEach(async () => { + cacheAdapter = new RedisCacheAdapter(); + await reconfigureServer({ + cacheAdapter, + }); + await cacheAdapter.clear(); + + getSpy = spyOn(cacheAdapter, 'get').and.callThrough(); + putSpy = spyOn(cacheAdapter, 'put').and.callThrough(); + delSpy = spyOn(cacheAdapter, 'del').and.callThrough(); + }); + + it('test new object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(3); + expect(delSpy.calls.count()).toBe(1); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test new object multiple fields', async () => { + const container = new Container({ + dateField: new Date(), + arrayField: [], + numberField: 1, + stringField: 'hello', + booleanField: true, + }); + await container.save(); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(3); + expect(delSpy.calls.count()).toBe(1); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test update existing fields', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + object.set('foo', 'barz'); + await object.save(); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(2); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test saveAll / destroyAll', async () => { + const object = new TestObject(); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + const objects = []; + for (let i = 0; i < 10; i++) { + const object = new TestObject(); + object.set('number', i); + objects.push(object); + } + await Parse.Object.saveAll(objects); + expect(getSpy.calls.count()).toBe(21); + expect(putSpy.calls.count()).toBe(11); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + await Parse.Object.destroyAll(objects); + expect(getSpy.calls.count()).toBe(11); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(3); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test saveAll / destroyAll batch', async () => { + const object = new TestObject(); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + const objects = []; + for (let i = 0; i < 10; i++) { + const object = new TestObject(); + object.set('number', i); + objects.push(object); + } + await Parse.Object.saveAll(objects, { batchSize: 5 }); + expect(getSpy.calls.count()).toBe(22); + expect(putSpy.calls.count()).toBe(7); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + await Parse.Object.destroyAll(objects, { batchSize: 5 }); + expect(getSpy.calls.count()).toBe(12); + expect(putSpy.calls.count()).toBe(2); + expect(delSpy.calls.count()).toBe(5); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test add new field to existing object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + object.set('new', 'barz'); + await object.save(); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(2); + expect(delSpy.calls.count()).toBe(2); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test add multiple fields to existing object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + object.set({ + dateField: new Date(), + arrayField: [], + numberField: 1, + stringField: 'hello', + booleanField: true, + }); + await object.save(); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(2); + expect(delSpy.calls.count()).toBe(2); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test user', async () => { + const user = new Parse.User(); + user.setUsername('testing'); + user.setPassword('testing'); + await user.signUp(); + + expect(getSpy.calls.count()).toBe(8); + expect(putSpy.calls.count()).toBe(2); + expect(delSpy.calls.count()).toBe(1); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test allowClientCreation false', async () => { + const object = new TestObject(); + await object.save(); + await reconfigureServer({ + cacheAdapter, + allowClientClassCreation: false, + }); + await cacheAdapter.clear(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + delSpy.calls.reset(); + + object.set('foo', 'bar'); + await object.save(); + expect(getSpy.calls.count()).toBe(4); + expect(putSpy.calls.count()).toBe(2); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + const query = new Parse.Query(TestObject); + await query.get(object.id); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(2); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test query', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + delSpy.calls.reset(); + + const query = new Parse.Query(TestObject); + await query.get(object.id); + expect(getSpy.calls.count()).toBe(2); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(1); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test query include', async () => { + const child = new TestObject(); + await child.save(); + + const object = new TestObject(); + object.set('child', child); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + const query = new Parse.Query(TestObject); + query.include('child'); + await query.get(object.id); + + expect(getSpy.calls.count()).toBe(4); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(3); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('query relation without schema', async () => { + const child = new Parse.Object('ChildObject'); + await child.save(); + + const parent = new Parse.Object('ParentObject'); + const relation = parent.relation('child'); + relation.add(child); + await parent.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + + const objects = await relation.query().find(); + expect(objects.length).toBe(1); + expect(objects[0].id).toBe(child.id); + + expect(getSpy.calls.count()).toBe(2); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(3); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test delete object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + delSpy.calls.reset(); + + await object.destroy(); + expect(getSpy.calls.count()).toBe(2); + expect(putSpy.calls.count()).toBe(1); + expect(delSpy.calls.count()).toBe(1); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(0); + }); + + it('test schema update class', async () => { + const container = new Container(); + await container.save(); + + getSpy.calls.reset(); + putSpy.calls.reset(); + delSpy.calls.reset(); + + const config = Config.get('test'); + const schema = await config.database.loadSchema(); + await schema.reloadData(); + + const levelPermissions = { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }; + + await schema.updateClass( + 'Container', + { + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + levelPermissions, + {}, + config.database + ); + expect(getSpy.calls.count()).toBe(3); + expect(putSpy.calls.count()).toBe(3); + expect(delSpy.calls.count()).toBe(0); + + const keys = await cacheAdapter.getAllKeys(); + expect(keys.length).toBe(1); + }); +}); diff --git a/spec/RedisPubSub.spec.js b/spec/RedisPubSub.spec.js index 263bc209b4..ffb189aa41 100644 --- a/spec/RedisPubSub.spec.js +++ b/spec/RedisPubSub.spec.js @@ -1,26 +1,37 @@ -var RedisPubSub = require('../src/Adapters/PubSub/RedisPubSub').RedisPubSub; +const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; describe('RedisPubSub', function() { - beforeEach(function(done) { // Mock redis - var createClient = jasmine.createSpy('createClient'); + const createClient = jasmine.createSpy('createClient'); jasmine.mockLibrary('redis', 'createClient', createClient); done(); }); it('can create publisher', function() { - RedisPubSub.createPublisher({redisURL: 'redisAddress'}); + RedisPubSub.createPublisher({ + redisURL: 'redisAddress', + redisOptions: { socket_keepalive: true }, + }); - var redis = require('redis'); - expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + const redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { + socket_keepalive: true, + no_ready_check: true, + }); }); it('can create subscriber', function() { - RedisPubSub.createSubscriber({redisURL: 'redisAddress'}); + RedisPubSub.createSubscriber({ + redisURL: 'redisAddress', + redisOptions: { socket_keepalive: true }, + }); - var redis = require('redis'); - expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + const redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { + socket_keepalive: true, + no_ready_check: true, + }); }); afterEach(function() { diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js new file mode 100644 index 0000000000..58f5afac92 --- /dev/null +++ b/spec/RegexVulnerabilities.spec.js @@ -0,0 +1,198 @@ +const request = require('../lib/request'); + +const serverURL = 'http://localhost:8378/1'; +const headers = { + 'Content-Type': 'application/json', +}; +const keys = { + _ApplicationId: 'test', + _JavaScriptKey: 'test', +}; +const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, +}; +const appName = 'test'; +const publicServerURL = 'http://localhost:8378/1'; + +describe('Regex Vulnerabilities', function() { + beforeEach(async function() { + await reconfigureServer({ + verifyUserEmails: true, + emailAdapter, + appName, + publicServerURL, + }); + + const signUpResponse = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + username: 'someemail@somedomain.com', + password: 'somepassword', + email: 'someemail@somedomain.com', + }), + }); + this.objectId = signUpResponse.data.objectId; + this.sessionToken = signUpResponse.data.sessionToken; + this.partialSessionToken = this.sessionToken.slice(0, 3); + }); + + describe('on session token', function() { + it('should not work with regex', async function() { + try { + await request({ + url: `${serverURL}/users/me`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _SessionToken: { + $regex: this.partialSessionToken, + }, + _method: 'GET', + }), + }); + fail('should not work'); + } catch (e) { + expect(e.data.code).toEqual(209); + expect(e.data.error).toEqual('Invalid session token'); + } + }); + + it('should work with plain token', async function() { + const meResponse = await request({ + url: `${serverURL}/users/me`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _SessionToken: this.sessionToken, + _method: 'GET', + }), + }); + expect(meResponse.data.objectId).toEqual(this.objectId); + expect(meResponse.data.sessionToken).toEqual(this.sessionToken); + }); + }); + + describe('on verify e-mail', function() { + beforeEach(async function() { + const userQuery = new Parse.Query(Parse.User); + this.user = await userQuery.get(this.objectId, { useMasterKey: true }); + }); + + it('should not work with regex', async function() { + expect(this.user.get('emailVerified')).toEqual(false); + await request({ + url: `${serverURL}/apps/test/verify_email?username=someemail@somedomain.com&token[$regex]=`, + method: 'GET', + }); + await this.user.fetch({ useMasterKey: true }); + expect(this.user.get('emailVerified')).toEqual(false); + }); + + it('should work with plain token', async function() { + expect(this.user.get('emailVerified')).toEqual(false); + // It should work + await request({ + url: `${serverURL}/apps/test/verify_email?username=someemail@somedomain.com&token=${this.user.get( + '_email_verify_token' + )}`, + method: 'GET', + }); + await this.user.fetch({ useMasterKey: true }); + expect(this.user.get('emailVerified')).toEqual(true); + }); + }); + + describe('on password reset', function() { + beforeEach(async function() { + this.user = await Parse.User.logIn( + 'someemail@somedomain.com', + 'somepassword' + ); + }); + + it('should not work with regex', async function() { + expect(this.user.id).toEqual(this.objectId); + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + await this.user.fetch({ useMasterKey: true }); + const passwordResetResponse = await request({ + url: `${serverURL}/apps/test/request_password_reset?username=someemail@somedomain.com&token[$regex]=`, + method: 'GET', + }); + expect(passwordResetResponse.status).toEqual(302); + expect(passwordResetResponse.headers.location).toMatch( + `\\/invalid\\_link\\.html` + ); + await request({ + url: `${serverURL}/apps/test/request_password_reset`, + method: 'POST', + body: { + token: { $regex: '' }, + username: 'someemail@somedomain.com', + new_password: 'newpassword', + }, + }); + try { + await Parse.User.logIn('someemail@somedomain.com', 'newpassword'); + fail('should not work'); + } catch (e) { + expect(e.code).toEqual(101); + expect(e.message).toEqual('Invalid username/password.'); + } + }); + + it('should work with plain token', async function() { + expect(this.user.id).toEqual(this.objectId); + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + await this.user.fetch({ useMasterKey: true }); + const token = this.user.get('_perishable_token'); + const passwordResetResponse = await request({ + url: `${serverURL}/apps/test/request_password_reset?username=someemail@somedomain.com&token=${token}`, + method: 'GET', + }); + expect(passwordResetResponse.status).toEqual(302); + expect(passwordResetResponse.headers.location).toMatch( + `\\/choose\\_password\\?token\\=${token}\\&` + ); + await request({ + url: `${serverURL}/apps/test/request_password_reset`, + method: 'POST', + body: { + token, + username: 'someemail@somedomain.com', + new_password: 'newpassword', + }, + }); + const userAgain = await Parse.User.logIn( + 'someemail@somedomain.com', + 'newpassword' + ); + expect(userAgain.id).toEqual(this.objectId); + }); + }); +}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 5c6cc4e26f..412394df21 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -1,268 +1,449 @@ -'use strict' +'use strict'; // These tests check the "find" functionality of the REST API. -var auth = require('../src/Auth'); -var Config = require('../src/Config'); -var rest = require('../src/rest'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const RestQuery = require('../lib/RestQuery'); +const request = require('../lib/request'); -var querystring = require('querystring'); -var rp = require('request-promise'); +const querystring = require('querystring'); -var config; +let config; let database; -var nobody = auth.nobody(config); +const nobody = auth.nobody(config); describe('rest query', () => { - beforeEach(() => { config = Config.get('test'); database = config.database; }); - it('basic query', (done) => { - rest.create(config, nobody, 'TestObject', {}).then(() => { - return rest.find(config, nobody, 'TestObject', {}); - }).then((response) => { - expect(response.results.length).toEqual(1); - done(); - }); + it('basic query', done => { + rest + .create(config, nobody, 'TestObject', {}) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}); + }) + .then(response => { + expect(response.results.length).toEqual(1); + done(); + }); }); - it('query with limit', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 1}); - }).then((response) => { - expect(response.results.length).toEqual(1); - expect(response.results[0].foo).toBeTruthy(); - done(); - }); + it('query with limit', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); + }) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}, { limit: 1 }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + expect(response.results[0].foo).toBeTruthy(); + done(); + }); }); - var data = { + const data = { username: 'blah', password: 'pass', sessionToken: 'abc123', - } + }; - it_exclude_dbs(['postgres'])('query for user w/ legacy credentials without masterKey has them stripped from results', done => { - database.create('_User', data).then(() => { - return rest.find(config, nobody, '_User') - }).then((result) => { - var user = result.results[0]; - expect(user.username).toEqual('blah'); - expect(user.sessionToken).toBeUndefined(); - expect(user.password).toBeUndefined(); - done(); - }); - }); + it_exclude_dbs(['postgres'])( + 'query for user w/ legacy credentials without masterKey has them stripped from results', + done => { + database + .create('_User', data) + .then(() => { + return rest.find(config, nobody, '_User'); + }) + .then(result => { + const user = result.results[0]; + expect(user.username).toEqual('blah'); + expect(user.sessionToken).toBeUndefined(); + expect(user.password).toBeUndefined(); + done(); + }); + } + ); - it_exclude_dbs(['postgres'])('query for user w/ legacy credentials with masterKey has them stripped from results', done => { - database.create('_User', data).then(() => { - return rest.find(config, {isMaster: true}, '_User') - }).then((result) => { - var user = result.results[0]; - expect(user.username).toEqual('blah'); - expect(user.sessionToken).toBeUndefined(); - expect(user.password).toBeUndefined(); - done(); - }); - }); + it_exclude_dbs(['postgres'])( + 'query for user w/ legacy credentials with masterKey has them stripped from results', + done => { + database + .create('_User', data) + .then(() => { + return rest.find(config, { isMaster: true }, '_User'); + }) + .then(result => { + const user = result.results[0]; + expect(user.username).toEqual('blah'); + expect(user.sessionToken).toBeUndefined(); + expect(user.password).toBeUndefined(); + done(); + }); + } + ); // Created to test a scenario in AnyPic - it_exclude_dbs(['postgres'])('query with include', (done) => { - var photo = { - foo: 'bar' + it_exclude_dbs(['postgres'])('query with include', done => { + let photo = { + foo: 'bar', }; - var user = { + let user = { username: 'aUsername', - password: 'aPassword' + password: 'aPassword', }; - var activity = { + const activity = { type: 'comment', photo: { __type: 'Pointer', className: 'TestPhoto', - objectId: '' + objectId: '', }, fromUser: { __type: 'Pointer', className: '_User', - objectId: '' - } + objectId: '', + }, }; - var queryWhere = { + const queryWhere = { photo: { __type: 'Pointer', className: 'TestPhoto', - objectId: '' + objectId: '', }, - type: 'comment' + type: 'comment', }; - var queryOptions = { + const queryOptions = { include: 'fromUser', order: 'createdAt', - limit: 30 + limit: 30, }; - rest.create(config, nobody, 'TestPhoto', photo - ).then((p) => { - photo = p; - return rest.create(config, nobody, '_User', user); - }).then((u) => { - user = u.response; - activity.photo.objectId = photo.objectId; - activity.fromUser.objectId = user.objectId; - return rest.create(config, nobody, - 'TestActivity', activity); - }).then(() => { - queryWhere.photo.objectId = photo.objectId; - return rest.find(config, nobody, - 'TestActivity', queryWhere, queryOptions); - }).then((response) => { - var results = response.results; - expect(results.length).toEqual(1); - expect(typeof results[0].objectId).toEqual('string'); - expect(typeof results[0].photo).toEqual('object'); - expect(typeof results[0].fromUser).toEqual('object'); - expect(typeof results[0].fromUser.username).toEqual('string'); - done(); - }).catch((error) => { console.log(error); }); + rest + .create(config, nobody, 'TestPhoto', photo) + .then(p => { + photo = p; + return rest.create(config, nobody, '_User', user); + }) + .then(u => { + user = u.response; + activity.photo.objectId = photo.objectId; + activity.fromUser.objectId = user.objectId; + return rest.create(config, nobody, 'TestActivity', activity); + }) + .then(() => { + queryWhere.photo.objectId = photo.objectId; + return rest.find( + config, + nobody, + 'TestActivity', + queryWhere, + queryOptions + ); + }) + .then(response => { + const results = response.results; + expect(results.length).toEqual(1); + expect(typeof results[0].objectId).toEqual('string'); + expect(typeof results[0].photo).toEqual('object'); + expect(typeof results[0].fromUser).toEqual('object'); + expect(typeof results[0].fromUser.username).toEqual('string'); + done(); + }) + .catch(error => { + console.log(error); + }); + }); + + it('query non-existent class when disabled client class creation', done => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + rest + .find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}) + .then( + () => { + fail('Should throw an error'); + done(); + }, + err => { + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(err.message).toEqual( + 'This user is not allowed to access ' + + 'non-existent class: ClientClassCreation' + ); + done(); + } + ); }); - it('query non-existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - rest.find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}) + it('query existent class when disabled client class creation', async () => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + const schema = await config.database.loadSchema(); + const actualSchema = await schema.addClassIfNotExists( + 'ClientClassCreation', + {} + ); + expect(actualSchema.className).toEqual('ClientClassCreation'); + + await schema.reloadData({ clearCache: true }); + // Should not throw + const result = await rest.find( + customConfig, + auth.nobody(customConfig), + 'ClientClassCreation', + {} + ); + expect(result.results.length).toEqual(0); + }); + + it('query with wrongly encoded parameter', done => { + rest + .create(config, nobody, 'TestParameterEncode', { foo: 'bar' }) .then(() => { - fail('Should throw an error'); - done(); - }, (err) => { - expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); - expect(err.message).toEqual('This user is not allowed to access ' + - 'non-existent class: ClientClassCreation'); + return rest.create(config, nobody, 'TestParameterEncode', { + foo: 'baz', + }); + }) + .then(() => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + const p0 = request({ + 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.data; + expect(error.code).toEqual(Parse.Error.INVALID_QUERY); + }); + + const p1 = request({ + headers: headers, + url: + 'http://localhost:8378/1/classes/TestParameterEncode?' + + querystring + .stringify({ + limit: 1, + }) + .replace('=', '%3D'), + }).then(fail, response => { + const error = response.data; + expect(error.code).toEqual(Parse.Error.INVALID_QUERY); + }); + return Promise.all([p0, p1]); + }) + .then(done) + .catch(err => { + jfail(err); + fail('should not fail'); done(); }); }); - it('query existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('ClientClassCreation', {})) - .then(actualSchema => { - expect(actualSchema.className).toEqual('ClientClassCreation'); - return rest.find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}); + it('query with limit = 0', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); + }) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}, { limit: 0 }); }) - .then((result) => { - expect(result.results.length).toEqual(0); + .then(response => { + expect(response.results.length).toEqual(0); done(); - }, () => { - fail('Should not throw error') }); }); - it('query with wrongly encoded parameter', (done) => { - rest.create(config, nobody, 'TestParameterEncode', {foo: 'bar'} - ).then(() => { - return rest.create(config, nobody, - 'TestParameterEncode', {foo: 'baz'}); - }).then(() => { - var headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - - const p0 = rp.get({ - headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' + querystring.stringify({ - where: '{"foo":{"$ne": "baz"}}', - limit: 1 - }).replace('=', '%3D'), + it('query with limit = 0 and count = 1', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); }) - .then(fail, (response) => { - const error = response.error; - var b = JSON.parse(error); - expect(b.code).toEqual(Parse.Error.INVALID_QUERY); - }); + .then(() => { + return rest.find( + config, + nobody, + 'TestObject', + {}, + { limit: 0, count: 1 } + ); + }) + .then(response => { + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }); + }); - const p1 = rp.get({ - headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' + querystring.stringify({ - limit: 1 - }).replace('=', '%3D'), + it('makes sure null pointers are handed correctly #2189', done => { + const object = new Parse.Object('AnObject'); + const anotherObject = new Parse.Object('AnotherObject'); + anotherObject + .save() + .then(() => { + object.set('values', [null, null, anotherObject]); + return object.save(); }) - .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); - fail('should not fail'); - done(); - }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('values'); + return query.first(); + }) + .then( + result => { + const values = result.get('values'); + expect(values.length).toBe(3); + let anotherObjectFound = false; + let nullCounts = 0; + for (const value of values) { + if (value === null) { + nullCounts++; + } else if (value instanceof Parse.Object) { + anotherObjectFound = true; + } + } + expect(nullCounts).toBe(2); + expect(anotherObjectFound).toBeTruthy(); + done(); + }, + err => { + console.error(err); + fail(err); + done(); + } + ); }); +}); - it('query with limit = 0', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 0}); - }).then((response) => { - expect(response.results.length).toEqual(0); - done(); +describe('RestQuery.each', () => { + it('should run each', async () => { + const objects = []; + while (objects.length != 10) { + objects.push(new Parse.Object('Object', { value: objects.length })); + } + const config = Config.get('test'); + await Parse.Object.saveAll(objects); + const query = new RestQuery( + config, + auth.master(config), + 'Object', + { value: { $gt: 2 } }, + { limit: 2 } + ); + const spy = spyOn(query, 'execute').and.callThrough(); + const classSpy = spyOn(RestQuery.prototype, 'execute').and.callThrough(); + const results = []; + await query.each(result => { + expect(result.value).toBeGreaterThan(2); + results.push(result); }); + expect(spy.calls.count()).toBe(0); + expect(classSpy.calls.count()).toBe(4); + expect(results.length).toBe(7); }); - it('query with limit = 0 and count = 1', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 0, count: 1}); - }).then((response) => { - expect(response.results.length).toEqual(0); - expect(response.count).toEqual(2); - done(); + it('should work with query on relations', async () => { + const objectA = new Parse.Object('Letter', { value: 'A' }); + const objectB = new Parse.Object('Letter', { value: 'B' }); + + const object1 = new Parse.Object('Number', { value: '1' }); + const object2 = new Parse.Object('Number', { value: '2' }); + const object3 = new Parse.Object('Number', { value: '3' }); + const object4 = new Parse.Object('Number', { value: '4' }); + await Parse.Object.saveAll([object1, object2, object3, object4]); + + objectA.relation('numbers').add(object1); + objectB.relation('numbers').add(object2); + await Parse.Object.saveAll([objectA, objectB]); + + const config = Config.get('test'); + + /** + * Two queries needed since objectId are sorted and we can't know which one + * going to be the first and then skip by the $gt added by each + */ + const queryOne = new RestQuery( + config, + auth.master(config), + 'Letter', + { + numbers: { + __type: 'Pointer', + className: 'Number', + objectId: object1.id, + }, + }, + { limit: 1 } + ); + const queryTwo = new RestQuery( + config, + auth.master(config), + 'Letter', + { + numbers: { + __type: 'Pointer', + className: 'Number', + objectId: object2.id, + }, + }, + { limit: 1 } + ); + + const classSpy = spyOn(RestQuery.prototype, 'execute').and.callThrough(); + const resultsOne = []; + const resultsTwo = []; + await queryOne.each(result => { + resultsOne.push(result); + }); + await queryTwo.each(result => { + resultsTwo.push(result); }); + expect(classSpy.calls.count()).toBe(4); + expect(resultsOne.length).toBe(1); + expect(resultsTwo.length).toBe(1); }); - it('makes sure null pointers are handed correctly #2189', done => { - const object = new Parse.Object('AnObject'); - const anotherObject = new Parse.Object('AnotherObject'); - anotherObject.save().then(() => { - object.set('values', [null, null, anotherObject]); - return object.save(); - }).then(() => { - const query = new Parse.Query('AnObject'); - query.include('values'); - return query.first(); - }).then((result) => { - const values = result.get('values'); - expect(values.length).toBe(3); - let anotherObjectFound = false; - let nullCounts = 0; - for(const value of values) { - if (value === null) { - nullCounts++; - } else if (value instanceof Parse.Object) { - anotherObjectFound = true; - } - } - expect(nullCounts).toBe(2); - expect(anotherObjectFound).toBeTruthy(); - done(); - }, (err) => { - console.error(err); - fail(err); - done(); + it('test afterSave response object is return', done => { + Parse.Cloud.beforeSave('TestObject2', function(req) { + req.object.set('tobeaddbefore', true); + req.object.set('tobeaddbeforeandremoveafter', true); + }); + + Parse.Cloud.afterSave('TestObject2', function(req) { + const jsonObject = req.object.toJSON(); + delete jsonObject.todelete; + delete jsonObject.tobeaddbeforeandremoveafter; + jsonObject.toadd = true; + + return jsonObject; }); + + rest + .create(config, nobody, 'TestObject2', { todelete: true, tokeep: true }) + .then(response => { + expect(response.response.toadd).toBeTruthy(); + expect(response.response.tokeep).toBeTruthy(); + expect(response.response.tobeaddbefore).toBeTruthy(); + expect(response.response.tobeaddbeforeandremoveafter).toBeUndefined(); + expect(response.response.todelete).toBeUndefined(); + done(); + }); }); }); diff --git a/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js index f9d87536bd..4587d99fdb 100644 --- a/spec/RevocableSessionsUpgrade.spec.js +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -1,6 +1,6 @@ -const Config = require('../src/Config'); +const Config = require('../lib/Config'); const sessionToken = 'legacySessionToken'; -const rp = require('request-promise'); +const request = require('../lib/request'); const Parse = require('parse/node'); function createUser() { @@ -9,14 +9,13 @@ function createUser() { objectId: '1234567890', username: 'hello', password: 'pass', - _session_token: sessionToken - } + _session_token: sessionToken, + }; return config.database.create('_User', user); } describe_only_db('mongo')('revocable sessions', () => { - - beforeEach((done) => { + beforeEach(done => { // Create 1 user with the legacy createUser().then(done); }); @@ -25,88 +24,121 @@ describe_only_db('mongo')('revocable sessions', () => { const user = Parse.Object.fromJSON({ className: '_User', objectId: '1234567890', - sessionToken: sessionToken - }); - user._upgradeToRevocableSession().then((res) => { - expect(res.getSessionToken().indexOf('r:')).toBe(0); - const config = Config.get(Parse.applicationId); - // use direct access to the DB to make sure we're not - // getting the session token stripped - return config.database.loadSchema().then(schemaController => { - return schemaController.getOneSchema('_User', true) - }).then((schema) => { - return config.database.adapter.find('_User', schema, {objectId: '1234567890'}, {}) - }).then((results) => { - expect(results.length).toBe(1); - expect(results[0].sessionToken).toBeUndefined(); - }); - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); + sessionToken: sessionToken, }); + user + ._upgradeToRevocableSession() + .then(res => { + expect(res.getSessionToken().indexOf('r:')).toBe(0); + const config = Config.get(Parse.applicationId); + // use direct access to the DB to make sure we're not + // getting the session token stripped + return config.database + .loadSchema() + .then(schemaController => { + return schemaController.getOneSchema('_User', true); + }) + .then(schema => { + return config.database.adapter.find( + '_User', + schema, + { objectId: '1234567890' }, + {} + ); + }) + .then(results => { + expect(results.length).toBe(1); + expect(results[0].sessionToken).toBeUndefined(); + }); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); it('should be able to become with revocable session token', done => { const user = Parse.Object.fromJSON({ className: '_User', objectId: '1234567890', - sessionToken: sessionToken - }); - user._upgradeToRevocableSession().then((res) => { - expect(res.getSessionToken().indexOf('r:')).toBe(0); - return Parse.User.logOut().then(() => { - return Parse.User.become(res.getSessionToken()) - }).then((user) => { - expect(user.id).toEqual('1234567890'); - }); - }).then(() => { - done(); - }, (err) => { - jfail(err); - done(); + sessionToken: sessionToken, }); + user + ._upgradeToRevocableSession() + .then(res => { + expect(res.getSessionToken().indexOf('r:')).toBe(0); + return Parse.User.logOut() + .then(() => { + return Parse.User.become(res.getSessionToken()); + }) + .then(user => { + expect(user.id).toEqual('1234567890'); + }); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); }); it('should not upgrade bad legacy session token', done => { - rp.post({ + request({ + method: 'POST', url: Parse.serverURL + '/upgradeToRevocableSession', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Rest-API-Key': 'rest', - 'X-Parse-Session-Token': 'badSessionToken' + 'X-Parse-Session-Token': 'badSessionToken', }, - json: true - }).then(() => { - fail('should not be able to upgrade a bad token'); - }, (response) => { - expect(response.statusCode).toBe(400); - expect(response.error).not.toBeUndefined(); - expect(response.error.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); - expect(response.error.error).toEqual('invalid legacy session token'); - }).then(() => { - done(); - }); + }) + .then( + () => { + fail('should not be able to upgrade a bad token'); + }, + response => { + expect(response.status).toBe(400); + expect(response.data).not.toBeUndefined(); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(response.data.error).toEqual('invalid legacy session token'); + } + ) + .then(() => { + done(); + }); }); it('should not crash without session token #2720', done => { - rp.post({ + request({ + method: 'POST', url: Parse.serverURL + '/upgradeToRevocableSession', headers: { 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Rest-API-Key': 'rest' + 'X-Parse-Rest-API-Key': 'rest', }, - json: true - }).then(() => { - fail('should not be able to upgrade a bad token'); - }, (response) => { - expect(response.statusCode).toBe(404); - expect(response.error).not.toBeUndefined(); - expect(response.error.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - expect(response.error.error).toEqual('invalid session'); - }).then(() => { - done(); - }); + }) + .then( + () => { + fail('should not be able to upgrade a bad token'); + }, + response => { + expect(response.status).toBe(404); + expect(response.data).not.toBeUndefined(); + expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(response.data.error).toEqual('invalid session'); + } + ) + .then(() => { + done(); + }); }); -}) +}); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index eef17e9751..7cdc88a632 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,21 +1,25 @@ 'use strict'; -var Config = require('../src/Config'); -var SchemaController = require('../src/Controllers/SchemaController'); -var dd = require('deep-diff'); +const Config = require('../lib/Config'); +const SchemaController = require('../lib/Controllers/SchemaController'); +const dd = require('deep-diff'); +const TestUtils = require('../lib/TestUtils'); -var config; +let config; -var hasAllPODobject = () => { - var obj = new Parse.Object('HasAllPOD'); +const hasAllPODobject = () => { + const obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); obj.set('aString', 'string'); obj.set('aBool', true); obj.set('aDate', new Date()); - obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aObject', { k1: 'value', k2: true, k3: 5 }); obj.set('aArray', ['contents', true, 5]); - obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); - obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); + obj.set('aGeoPoint', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); + obj.set( + 'aFile', + new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' }) + ); return obj; }; @@ -24,197 +28,287 @@ describe('SchemaController', () => { config = Config.get('test'); }); - it('can validate one object', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('TestObject', {a: 1, b: 'yo', c: false}); - }).then(() => { - done(); - }, (error) => { - jfail(error); - done(); - }); + afterEach(async () => { + await config.database.schemaCache.clear(); + await TestUtils.destroyAllDataPermanently(false); }); - it('can validate one object with dot notation', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('TestObjectWithSubDoc', {x: false, y: 'YY', z: 1, 'aObject.k1': 'newValue'}); - }).then(() => { - done(); - }, (error) => { - jfail(error); - done(); - }); + it('can validate one object', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('TestObject', { a: 1, b: 'yo', c: false }); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it('can validate two objects in a row', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Foo', {x: true, y: 'yyy', z: 0}); - }).then((schema) => { - return schema.validateObject('Foo', {x: false, y: 'YY', z: 1}); - }).then(() => { - done(); - }); + it('can validate one object with dot notation', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('TestObjectWithSubDoc', { + x: false, + y: 'YY', + z: 1, + 'aObject.k1': 'newValue', + }); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it('can validate Relation object', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Stuff', {aRelation: {__type:'Relation',className:'Stuff'}}); - }).then((schema) => { - return schema.validateObject('Stuff', {aRelation: {__type:'Pointer',className:'Stuff'}}) - .then(() => { + it('can validate two objects in a row', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Foo', { x: true, y: 'yyy', z: 0 }); + }) + .then(schema => { + return schema.validateObject('Foo', { x: false, y: 'YY', z: 1 }); + }) + .then(() => { + done(); + }); + }); + + it('can validate Relation object', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { + aRelation: { __type: 'Relation', className: 'Stuff' }, + }); + }) + .then(schema => { + return schema + .validateObject('Stuff', { + aRelation: { __type: 'Pointer', className: 'Stuff' }, + }) + .then( + () => { + done.fail('expected invalidity'); + }, + () => done() + ); + }, done.fail); + }); + + it('rejects inconsistent types', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { bacon: 7 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { bacon: 'z' }); + }) + .then( + () => { fail('expected invalidity'); done(); - }, done); - }, (err) => { - fail(err); - done(); - }); + }, + () => done() + ); }); - it('rejects inconsistent types', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Stuff', {bacon: 7}); - }).then((schema) => { - return schema.validateObject('Stuff', {bacon: 'z'}); - }).then(() => { - fail('expected invalidity'); - done(); - }, done); - }); - - it('updates when new fields are added', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Stuff', {bacon: 7}); - }).then((schema) => { - return schema.validateObject('Stuff', {sausage: 8}); - }).then((schema) => { - return schema.validateObject('Stuff', {sausage: 'ate'}); - }).then(() => { - fail('expected invalidity'); - done(); - }, done); - }); - - it('class-level permissions test find', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': {} - }); - }).then(() => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then(() => { - fail('Class permissions should have rejected this query.'); - done(); - }, () => { - done(); - }); + it('updates when new fields are added', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { bacon: 7 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { sausage: 8 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { sausage: 'ate' }); + }) + .then( + () => { + fail('expected invalidity'); + done(); + }, + () => done() + ); }); - it('class-level permissions test user', (done) => { - var user; - createTestUser().then((u) => { - user = u; - return config.database.loadSchema(); - }).then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - var find = {}; - find[user.id] = true; - return schema.setPermissions('Stuff', { - 'find': find - }); - }).then(() => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then(() => { - done(); - }, () => { - fail('Class permissions should have allowed this query.'); - done(); - }); + it('class-level permissions test find', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: {}, + }); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + () => { + done(); + } + ); }); - it('class-level permissions test get', (done) => { - var obj; + it('class-level permissions test user', done => { + let user; createTestUser() - .then(user => { - return config.database.loadSchema() - // Create a valid class - .then(schema => schema.validateObject('Stuff', {foo: 'bar'})) + .then(u => { + user = u; + return config.database.loadSchema(); + }) + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + const find = {}; + find[user.id] = true; + return schema.setPermissions('Stuff', { + find: find, + }); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + done(); + }, + () => { + fail('Class permissions should have allowed this query.'); + done(); + } + ); + }); + + it('class-level permissions test get', done => { + let obj; + createTestUser().then(user => { + return ( + config.database + .loadSchema() + // Create a valid class + .then(schema => schema.validateObject('Stuff', { foo: 'bar' })) .then(schema => { - var find = {}; - var get = {}; + const find = {}; + const get = {}; get[user.id] = true; return schema.setPermissions('Stuff', { - 'create': {'*': true}, - 'find': find, - 'get': get + create: { '*': true }, + find: find, + get: get, }); - }).then(() => { + }) + .then(() => { obj = new Parse.Object('Stuff'); obj.set('foo', 'bar'); return obj.save(); - }).then((o) => { + }) + .then(o => { obj = o; - var query = new Parse.Query('Stuff'); + const query = new Parse.Query('Stuff'); return query.find(); - }).then(() => { - fail('Class permissions should have rejected this query.'); - done(); - }, () => { - var query = new Parse.Query('Stuff'); - return query.get(obj.id).then(() => { - done(); - }, () => { - fail('Class permissions should have allowed this get query'); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); done(); - }); - }); - }); + }, + () => { + const query = new Parse.Query('Stuff'); + return query.get(obj.id).then( + () => { + done(); + }, + () => { + fail('Class permissions should have allowed this get query'); + done(); + } + ); + } + ) + ); + }); }); - it('class-level permissions test count', (done) => { - var obj; - return config.database.loadSchema() - // Create a valid class - .then(schema => schema.validateObject('Stuff', {foo: 'bar'})) - .then(schema => { - var count = {}; - return schema.setPermissions('Stuff', { - 'create': {'*': true}, - 'find': {'*': true}, - 'count': count + it('class-level permissions test count', done => { + let obj; + return ( + config.database + .loadSchema() + // Create a valid class + .then(schema => schema.validateObject('Stuff', { foo: 'bar' })) + .then(schema => { + const count = {}; + return schema.setPermissions('Stuff', { + create: { '*': true }, + find: { '*': true }, + count: count, + }); }) - }).then(() => { - obj = new Parse.Object('Stuff'); - obj.set('foo', 'bar'); - return obj.save(); - }).then((o) => { - obj = o; - var query = new Parse.Query('Stuff'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - var query = new Parse.Query('Stuff'); - return query.count(); - }).then(() => { - fail('Class permissions should have rejected this query.'); - }, (err) => { - expect(err.message).toEqual('Permission denied for action count on class Stuff.'); - done(); - }); + .then(() => { + obj = new Parse.Object('Stuff'); + obj.set('foo', 'bar'); + return obj.save(); + }) + .then(o => { + obj = o; + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + const query = new Parse.Query('Stuff'); + return query.count(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action count on class Stuff.' + ); + done(); + } + ) + ); }); it('can add classes without needing an object', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'String'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + ) .then(actualSchema => { const expectedSchema = { className: 'NewClass', @@ -228,12 +322,14 @@ describe('SchemaController', () => { classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, - } + }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); done(); }) @@ -246,286 +342,465 @@ describe('SchemaController', () => { const levelPermissions = { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }; - config.database.loadSchema() - .then(schema => { - schema.validateObject('NewClass', { foo: 2 }) - .then(() => schema.reloadData()) - .then(() => schema.updateClass('NewClass', { - fooOne: {type: 'Number'}, - fooTwo: {type: 'Array'}, - fooThree: {type: 'Date'}, - fooFour: {type: 'Object'}, - fooFive: {type: 'Relation', targetClass: '_User' }, - fooSix: {type: 'String'}, - fooSeven: {type: 'Object' }, - fooEight: {type: 'String'}, - fooNine: {type: 'String'}, - fooTeen: {type: 'Number' }, - fooEleven: {type: 'String'}, - fooTwelve: {type: 'String'}, - fooThirteen: {type: 'String'}, - fooFourteen: {type: 'String'}, - fooFifteen: {type: 'String'}, - fooSixteen: {type: 'String'}, - fooEighteen: {type: 'String'}, - fooNineteen: {type: 'String'}, - }, levelPermissions, config.database)) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - foo: { type: 'Number' }, - fooOne: {type: 'Number'}, - fooTwo: {type: 'Array'}, - fooThree: {type: 'Date'}, - fooFour: {type: 'Object'}, - fooFive: {type: 'Relation', targetClass: '_User' }, - fooSix: {type: 'String'}, - fooSeven: {type: 'Object' }, - fooEight: {type: 'String'}, - fooNine: {type: 'String'}, - fooTeen: {type: 'Number' }, - fooEleven: {type: 'String'}, - fooTwelve: {type: 'String'}, - fooThirteen: {type: 'String'}, - fooFourteen: {type: 'String'}, - fooFifteen: {type: 'String'}, - fooSixteen: {type: 'String'}, - fooEighteen: {type: 'String'}, - fooNineteen: {type: 'String'}, - }, - classLevelPermissions: { ...levelPermissions }, - }; - - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }) - .catch(error => { - console.trace(error); - done(); - fail('Error creating class: ' + JSON.stringify(error)); - }); - }); + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', { foo: 2 }) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + { + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + levelPermissions, + {}, + config.database + ) + ) + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + foo: { type: 'Number' }, + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + classLevelPermissions: { ...levelPermissions }, + indexes: { + _id_: { _id: 1 }, + }, + }; + + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }) + .catch(error => { + console.trace(error); + done(); + fail('Error creating class: ' + JSON.stringify(error)); + }); + }); + }); + + it('can update class level permission', done => { + const newLevelPermissions = { + find: {}, + get: { '*': true }, + count: {}, + create: { '*': true }, + update: {}, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': [] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', { foo: 2 }) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + {}, + newLevelPermissions, + {}, + config.database + ) + ) + .then(actualSchema => { + expect( + dd(actualSchema.classLevelPermissions, newLevelPermissions) + ).toEqual(undefined); + done(); + }) + .catch(error => { + console.trace(error); + done(); + fail('Error creating class: ' + JSON.stringify(error)); + }); + }); }); 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.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(); - }); - }); + config.database.loadSchema().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 => { // If two callers race to create the same schema, the response to the // race loser should be the same as if they hadn't been racing. - config.database.loadSchema() - .then(schema => { - var p1 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); - var p2 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); - Promise.race([p1, p2]) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - foo: { type: 'String' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - } - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - }); - Promise.all([p1,p2]) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.message).toEqual('Class NewClass already exists.'); - done(); - }); + config.database.loadSchema().then(schema => { + const p1 = schema + .addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + .then(validateSchema) + .catch(validateError); + const p2 = schema + .addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + .then(validateSchema) + .catch(validateError); + let schemaValidated = false; + function validateSchema(actualSchema) { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + foo: { type: 'String' }, + }, + classLevelPermissions: { + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + schemaValidated = true; + } + let errorValidated = false; + function validateError(error) { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NewClass already exists.'); + errorValidated = true; + } + Promise.all([p1, p2]).then(() => { + expect(schemaValidated).toEqual(true); + expect(errorValidated).toEqual(true); + done(); }); + }); }); it('refuses to create classes with invalid names', done => { - config.database.loadSchema() - .then(schema => { - schema.addClassIfNotExists('_InvalidName', {foo: {type: 'String'}}) - .catch(error => { - expect(error.error).toEqual( - 'Invalid classname: _InvalidName, classnames can only have alphanumeric characters and _, and must start with an alpha character ' - ); - done(); - }); - }); + config.database.loadSchema().then(schema => { + schema + .addClassIfNotExists('_InvalidName', { foo: { type: 'String' } }) + .catch(error => { + expect(error.message).toEqual( + 'Invalid classname: _InvalidName, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); + done(); + }); + }); }); it('refuses to add fields with invalid names', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', {'0InvalidName': {type: 'String'}})) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + '0InvalidName': { type: 'String' }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); - expect(error.error).toEqual('invalid field name: 0InvalidName'); + expect(error.message).toEqual('invalid field name: 0InvalidName'); done(); }); }); it('refuses to explicitly create the default fields for custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', {objectId: {type: 'String'}})) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { objectId: { type: 'String' } }) + ) .catch(error => { expect(error.code).toEqual(136); - expect(error.error).toEqual('field objectId cannot be added'); + expect(error.message).toEqual('field objectId cannot be added'); done(); }); }); it('refuses to explicitly create the default fields for non-custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Installation', {localeIdentifier: {type: 'String'}})) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('_Installation', { + localeIdentifier: { type: 'String' }, + }) + ) .catch(error => { expect(error.code).toEqual(136); - expect(error.error).toEqual('field localeIdentifier cannot be added'); + expect(error.message).toEqual('field localeIdentifier cannot be added'); done(); }); }); it('refuses to add fields with invalid types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 7} - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 7 }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); + expect(error.message).toEqual('invalid JSON'); done(); }); }); it('refuses to add fields with invalid pointer types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer'} - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer' }, + }) + ) .catch(error => { expect(error.code).toEqual(135); - expect(error.error).toEqual('type Pointer needs a class name'); + expect(error.message).toEqual('type Pointer needs a class name'); done(); }); }); it('refuses to add fields with invalid pointer target', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer', targetClass: 7}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer', targetClass: 7 }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); + expect(error.message).toEqual('invalid JSON'); done(); }); }); it('refuses to add fields with invalid Relation type', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', uselessKey: 7}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', uselessKey: 7 }, + }) + ) .catch(error => { expect(error.code).toEqual(135); - expect(error.error).toEqual('type Relation needs a class name'); + expect(error.message).toEqual('type Relation needs a class name'); done(); }); }); it('refuses to add fields with invalid relation target', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', targetClass: 7}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', targetClass: 7 }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); + expect(error.message).toEqual('invalid JSON'); done(); }); }); it('refuses to add fields with uncreatable pointer target class', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer', targetClass: 'not a valid class name'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer', targetClass: 'not a valid class name' }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + expect(error.message).toEqual( + 'Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); done(); }); }); it('refuses to add fields with uncreatable relation target class', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', targetClass: 'not a valid class name'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', targetClass: 'not a valid class name' }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + expect(error.message).toEqual( + 'Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); done(); }); }); it('refuses to add fields with unknown types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Unknown'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Unknown' }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(error.error).toEqual('invalid field type: Unknown'); + expect(error.message).toEqual('invalid field type: Unknown'); done(); }); }); + it('refuses to add CLP with incorrect find', done => { + const levelPermissions = { + find: { '*': false }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': ['email'] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + {}, + levelPermissions, + {}, + config.database + ) + ) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + }); + + it('refuses to add CLP when incorrectly sending a string to protectedFields object value instead of an array', done => { + const levelPermissions = { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': 'email' }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + {}, + levelPermissions, + {}, + config.database + ) + ) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + }); + it('will create classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - aNumber: {type: 'Number'}, - aString: {type: 'String'}, - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'}, - aPointer: {type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet'}, - aRelation: {type: 'Relation', targetClass: 'NewClass'}, - aBytes: {type: 'Bytes'}, - aPolygon: {type: 'Polygon'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + aNumber: { type: 'Number' }, + aString: { type: 'String' }, + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, + aPointer: { + type: 'Pointer', + targetClass: 'ThisClassDoesNotExistYet', + }, + aRelation: { type: 'Relation', targetClass: 'NewClass' }, + aBytes: { type: 'Bytes' }, + aPolygon: { type: 'Polygon' }, + }) + ) .then(actualSchema => { const expectedSchema = { className: 'NewClass', @@ -542,30 +817,38 @@ describe('SchemaController', () => { aArray: { type: 'Array' }, aGeoPoint: { type: 'GeoPoint' }, aFile: { type: 'File' }, - aPointer: { type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet' }, + aPointer: { + type: 'Pointer', + targetClass: 'ThisClassDoesNotExistYet', + }, aRelation: { type: 'Relation', targetClass: 'NewClass' }, - aBytes: {type: 'Bytes'}, - aPolygon: {type: 'Polygon'}, + aBytes: { type: 'Bytes' }, + aPolygon: { type: 'Polygon' }, }, classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, - } + }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); done(); }); }); it('creates the default fields for non-custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Installation', { - foo: {type: 'Number'}, - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('_Installation', { + foo: { type: 'Number' }, + }) + ) .then(actualSchema => { const expectedSchema = { className: '_Installation', @@ -592,20 +875,23 @@ describe('SchemaController', () => { classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, - } + }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); done(); }); }); it('creates non-custom classes which include relation field', done => { - config.database.loadSchema() - //as `_Role` is always created by default, we only get it here + config.database + .loadSchema() + //as `_Role` is always created by default, we only get it here .then(schema => schema.getOneSchema('_Role')) .then(actualSchema => { const expectedSchema = { @@ -622,10 +908,12 @@ describe('SchemaController', () => { classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -634,7 +922,8 @@ describe('SchemaController', () => { }); it('creates non-custom classes which include pointer field', done => { - config.database.loadSchema() + config.database + .loadSchema() .then(schema => schema.addClassIfNotExists('_Session', {})) .then(actualSchema => { const expectedSchema = { @@ -654,10 +943,12 @@ describe('SchemaController', () => { classLevelPermissions: { find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -666,31 +957,41 @@ describe('SchemaController', () => { }); it('refuses to create two geopoints', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - geo1: {type: 'GeoPoint'}, - geo2: {type: 'GeoPoint'} - })) + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + geo1: { type: 'GeoPoint' }, + geo2: { type: 'GeoPoint' }, + }) + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(error.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding geo2 when geo1 already exists.'); + expect(error.message).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding geo2 when geo1 already exists.' + ); done(); }); }); it('can check if a class exists', done => { - config.database.loadSchema() + config.database + .loadSchema() .then(schema => { - return schema.addClassIfNotExists('NewClass', {}) + return schema + .addClassIfNotExists('NewClass', {}) + .then(() => schema.reloadData({ clearCache: true })) .then(() => { - schema.hasClass('NewClass') + schema + .hasClass('NewClass') .then(hasClass => { expect(hasClass).toEqual(true); done(); }) .catch(fail); - schema.hasClass('NonexistantClass') + schema + .hasClass('NonexistantClass') .then(hasClass => { expect(hasClass).toEqual(false); done(); @@ -698,15 +999,16 @@ describe('SchemaController', () => { .catch(fail); }) .catch(error => { - fail('Couldn\'t create class'); + fail("Couldn't create class"); jfail(error); }); }) - .catch(() => fail('Couldn\'t load schema')); + .catch(() => fail("Couldn't load schema")); }); it('refuses to delete fields from invalid class names', done => { - config.database.loadSchema() + config.database + .loadSchema() .then(schema => schema.deleteField('fieldName', 'invalid class name')) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); @@ -715,8 +1017,11 @@ describe('SchemaController', () => { }); it('refuses to delete invalid fields', done => { - config.database.loadSchema() - .then(schema => schema.deleteField('invalid field name', 'ValidClassName')) + config.database + .loadSchema() + .then(schema => + schema.deleteField('invalid field name', 'ValidClassName') + ) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); done(); @@ -724,7 +1029,8 @@ describe('SchemaController', () => { }); it('refuses to delete the default fields', done => { - config.database.loadSchema() + config.database + .loadSchema() .then(schema => schema.deleteField('installationId', '_Installation')) .catch(error => { expect(error.code).toEqual(136); @@ -734,7 +1040,8 @@ describe('SchemaController', () => { }); it('refuses to delete fields from nonexistant classes', done => { - config.database.loadSchema() + config.database + .loadSchema() .then(schema => schema.deleteField('field', 'NoClass')) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); @@ -744,119 +1051,158 @@ describe('SchemaController', () => { }); it('refuses to delete fields that dont exist', done => { - hasAllPODobject().save() + hasAllPODobject() + .save() .then(() => config.database.loadSchema()) .then(schema => schema.deleteField('missingField', 'HasAllPOD')) - .fail(error => { + .catch(error => { expect(error.code).toEqual(255); - expect(error.message).toEqual('Field missingField does not exist, cannot delete.'); + expect(error.message).toEqual( + 'Field missingField does not exist, cannot delete.' + ); done(); }); }); it('drops related collection when deleting relation field', done => { - var obj1 = hasAllPODobject(); - obj1.save() + const obj1 = hasAllPODobject(); + obj1 + .save() .then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); + const obj2 = new Parse.Object('HasPointersAndRelations'); obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); + const relation = obj2.relation('aRelation'); relation.add(obj1); return obj2.save(); }) - .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) + .then(() => + config.database.collectionExists( + '_Join:aRelation:HasPointersAndRelations' + ) + ) .then(exists => { if (!exists) { - fail('Relation collection ' + - 'should exist after save.'); + fail('Relation collection ' + 'should exist after save.'); } }) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) - .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) - .then(exists => { - if (exists) { - fail('Relation collection should not exist after deleting relation field.'); + .then(schema => + schema.deleteField( + 'aRelation', + 'HasPointersAndRelations', + config.database + ) + ) + .then(() => + config.database.collectionExists( + '_Join:aRelation:HasPointersAndRelations' + ) + ) + .then( + exists => { + if (exists) { + fail( + 'Relation collection should not exist after deleting relation field.' + ); + } + done(); + }, + error => { + jfail(error); + done(); } - done(); - }, error => { - jfail(error); - done(); - }); + ); }); 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'} + config.database.loadSchema().then(schema => { + schema + .addClassIfNotExists('NewClass', { + relationField: { type: 'Relation', targetClass: '_User' }, }) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - relationField: { type: 'Relation', targetClass: '_User' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - }; - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - }) - .then(() => config.database.collectionExists('_Join:relationField:NewClass')) - .then(exist => { - on_db('postgres', () => { - // We create the table when creating the column - expect(exist).toEqual(true); - }, () => { - expect(exist).toEqual(false); - }); - - }) - .then(() => schema.deleteField('relationField', 'NewClass', config.database)) - .then(() => schema.reloadData()) - .then(() => { - const expectedSchema = { + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { objectId: { type: 'String' }, updatedAt: { type: 'Date' }, createdAt: { type: 'Date' }, ACL: { type: 'ACL' }, - }; - expect(dd(schema.data.NewClass, expectedSchema)).toEqual(undefined); - done(); - }); - }); + relationField: { type: 'Relation', targetClass: '_User' }, + }, + classLevelPermissions: { + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + }) + .then(() => + config.database.collectionExists('_Join:relationField:NewClass') + ) + .then(exist => { + on_db( + 'postgres', + () => { + // We create the table when creating the column + expect(exist).toEqual(true); + }, + () => { + expect(exist).toEqual(false); + } + ); + }) + .then(() => + schema.deleteField('relationField', 'NewClass', config.database) + ) + .then(() => schema.reloadData()) + .then(() => { + const expectedSchema = { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }; + expect(dd(schema.schemaData.NewClass.fields, expectedSchema)).toEqual( + undefined + ); + }) + .then(done) + .catch(done.fail); + }); }); it('can delete string fields and resave as number field', done => { Parse.Object.disableSingleInstance(); - var obj1 = hasAllPODobject(); - var obj2 = hasAllPODobject(); + const obj1 = hasAllPODobject(); + const obj2 = hasAllPODobject(); Parse.Object.saveAll([obj1, obj2]) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database)) + .then(schema => + schema.deleteField('aString', 'HasAllPOD', config.database) + ) .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) .then(obj1Reloaded => { expect(obj1Reloaded.get('aString')).toEqual(undefined); obj1Reloaded.set('aString', ['not a string', 'this time']); - obj1Reloaded.save() + obj1Reloaded + .save() .then(obj1reloadedAgain => { - expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']); + expect(obj1reloadedAgain.get('aString')).toEqual([ + 'not a string', + 'this time', + ]); return new Parse.Query('HasAllPOD').get(obj2.id); }) .then(obj2reloaded => { expect(obj2reloaded.get('aString')).toEqual(undefined); done(); - Parse.Object.enableSingleInstance(); }); }) .catch(error => { @@ -867,8 +1213,9 @@ describe('SchemaController', () => { it('can delete pointer fields and resave as string', done => { Parse.Object.disableSingleInstance(); - var obj1 = new Parse.Object('NewClass'); - obj1.save() + const obj1 = new Parse.Object('NewClass'); + obj1 + .save() .then(() => { obj1.set('aPointer', obj1); return obj1.save(); @@ -877,7 +1224,9 @@ describe('SchemaController', () => { expect(obj1.get('aPointer').id).toEqual(obj1.id); }) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aPointer', 'NewClass', config.database)) + .then(schema => + schema.deleteField('aPointer', 'NewClass', config.database) + ) .then(() => new Parse.Query('NewClass').get(obj1.id)) .then(obj1 => { expect(obj1.get('aPointer')).toEqual(undefined); @@ -887,50 +1236,64 @@ describe('SchemaController', () => { .then(obj1 => { expect(obj1.get('aPointer')).toEqual('Now a string'); done(); - Parse.Object.enableSingleInstance(); }); }); it('can merge schemas', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: 'SomeClass', - someType: { type: 'Number' } - }, { - newType: {type: 'Number'} - })).toEqual({ - someType: {type: 'Number'}, - newType: {type: 'Number'}, + expect( + SchemaController.buildMergedSchemaObject( + { + _id: 'SomeClass', + someType: { type: 'Number' }, + }, + { + newType: { type: 'Number' }, + } + ) + ).toEqual({ + someType: { type: 'Number' }, + newType: { type: 'Number' }, }); done(); }); it('can merge deletions', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: 'SomeClass', + expect( + SchemaController.buildMergedSchemaObject( + { + _id: 'SomeClass', + someType: { type: 'Number' }, + outDatedType: { type: 'String' }, + }, + { + newType: { type: 'GeoPoint' }, + outDatedType: { __op: 'Delete' }, + } + ) + ).toEqual({ someType: { type: 'Number' }, - outDatedType: { type: 'String' }, - },{ - newType: {type: 'GeoPoint'}, - outDatedType: {__op: 'Delete'}, - })).toEqual({ - someType: {type: 'Number'}, - newType: {type: 'GeoPoint'}, + newType: { type: 'GeoPoint' }, }); done(); }); it('ignore default field when merge with system class', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: '_User', - username: { type: 'String' }, - password: { type: 'String' }, - email: { type: 'String' }, - emailVerified: { type: 'Boolean' }, - },{ - emailVerified: { type: 'String' }, + expect( + SchemaController.buildMergedSchemaObject( + { + _id: '_User', + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + }, + { + emailVerified: { type: 'String' }, + customField: { type: 'String' }, + } + ) + ).toEqual({ customField: { type: 'String' }, - })).toEqual({ - customField: { type: 'String' } }); done(); }); @@ -939,369 +1302,528 @@ describe('SchemaController', () => { const anObject = new Parse.Object('AnObject'); const anotherObject = new Parse.Object('AnotherObject'); const someObject = new Parse.Object('SomeObject'); - Parse.Object.saveAll([anObject, anotherObject, someObject]).then(() => { - anObject.set('pointer', anotherObject); - return anObject.save(); - }).then(() => { - anObject.set('pointer', someObject); - return anObject.save(); - }).then(() => { - fail('shoud not save correctly'); - done(); - }, (err) => { - expect(err instanceof Parse.Error).toBeTruthy(); - expect(err.message).toEqual('schema mismatch for AnObject.pointer; expected Pointer but got Pointer') - done(); - }); + Parse.Object.saveAll([anObject, anotherObject, someObject]) + .then(() => { + anObject.set('pointer', anotherObject); + return anObject.save(); + }) + .then(() => { + anObject.set('pointer', someObject); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.pointer; expected Pointer but got Pointer' + ); + done(); + } + ); }); it('yields a proper schema mismatch error bis (#2661)', done => { const anObject = new Parse.Object('AnObject'); const someObject = new Parse.Object('SomeObject'); - Parse.Object.saveAll([anObject, someObject]).then(() => { - anObject.set('number', 1); - return anObject.save(); - }).then(() => { - anObject.set('number', someObject); - return anObject.save(); - }).then(() => { - fail('shoud not save correctly'); - done(); - }, (err) => { - expect(err instanceof Parse.Error).toBeTruthy(); - expect(err.message).toEqual('schema mismatch for AnObject.number; expected Number but got Pointer') - done(); - }); + Parse.Object.saveAll([anObject, someObject]) + .then(() => { + anObject.set('number', 1); + return anObject.save(); + }) + .then(() => { + anObject.set('number', someObject); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.number; expected Number but got Pointer' + ); + done(); + } + ); }); it('yields a proper schema mismatch error ter (#2661)', done => { const anObject = new Parse.Object('AnObject'); const someObject = new Parse.Object('SomeObject'); - Parse.Object.saveAll([anObject, someObject]).then(() => { - anObject.set('pointer', someObject); - return anObject.save(); - }).then(() => { - anObject.set('pointer', 1); - return anObject.save(); - }).then(() => { - fail('shoud not save correctly'); - done(); - }, (err) => { - expect(err instanceof Parse.Error).toBeTruthy(); - expect(err.message).toEqual('schema mismatch for AnObject.pointer; expected Pointer but got Number') - done(); - }); + Parse.Object.saveAll([anObject, someObject]) + .then(() => { + anObject.set('pointer', someObject); + return anObject.save(); + }) + .then(() => { + anObject.set('pointer', 1); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.pointer; expected Pointer but got Number' + ); + done(); + } + ); }); it('properly handles volatile _Schemas', done => { function validateSchemaStructure(schema) { - expect(schema.hasOwnProperty('className')).toBe(true); - expect(schema.hasOwnProperty('fields')).toBe(true); - expect(schema.hasOwnProperty('classLevelPermissions')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(schema, 'className')).toBe( + true + ); + expect(Object.prototype.hasOwnProperty.call(schema, 'fields')).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(schema, 'classLevelPermissions') + ).toBe(true); } function validateSchemaDataStructure(schemaData) { Object.keys(schemaData).forEach(className => { const schema = schemaData[className]; // Hooks has className... if (className != '_Hooks') { - expect(schema.hasOwnProperty('className')).toBe(false); + expect( + Object.prototype.hasOwnProperty.call(schema, 'className') + ).toBe(false); } - expect(schema.hasOwnProperty('fields')).toBe(false); - expect(schema.hasOwnProperty('classLevelPermissions')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(schema, 'fields')).toBe( + false + ); + expect( + Object.prototype.hasOwnProperty.call(schema, 'classLevelPermissions') + ).toBe(false); }); } let schema; - config.database.loadSchema().then(s => { - schema = s; - return schema.getOneSchema('_User', false); - }).then(userSchema => { - validateSchemaStructure(userSchema); - validateSchemaDataStructure(schema.data); - return schema.getOneSchema('_PushStatus', true); - }).then(pushStatusSchema => { - validateSchemaStructure(pushStatusSchema); - validateSchemaDataStructure(schema.data); - done(); - }); + config.database + .loadSchema() + .then(s => { + schema = s; + return schema.getOneSchema('_User', false); + }) + .then(userSchema => { + validateSchemaStructure(userSchema); + validateSchemaDataStructure(schema.schemaData); + return schema.getOneSchema('_PushStatus', true); + }) + .then(pushStatusSchema => { + validateSchemaStructure(pushStatusSchema); + validateSchemaDataStructure(schema.schemaData); + }) + .then(done) + .catch(done.fail); + }); + + it('setAllClasses return classes if cache fails', async () => { + const schema = await config.database.loadSchema(); + + spyOn(schema._cache, 'setAllClasses').and.callFake(() => + Promise.reject('Oops!') + ); + const errorSpy = spyOn(console, 'error').and.callFake(() => {}); + const allSchema = await schema.setAllClasses(); + + expect(allSchema).toBeDefined(); + expect(errorSpy).toHaveBeenCalledWith( + 'Error saving schema to cache:', + 'Oops!' + ); + }); + + it('should not throw on null field types', async () => { + const schema = await config.database.loadSchema(); + const result = await schema.enforceFieldExists( + 'NewClass', + 'fieldName', + null + ); + expect(result).toBeUndefined(); + }); + + it('ensureFields should throw when schema is not set', async () => { + const schema = await config.database.loadSchema(); + try { + schema.ensureFields([ + { + className: 'NewClass', + fieldName: 'fieldName', + type: 'String', + }, + ]); + } catch (e) { + expect(e.message).toBe('Could not add field fieldName'); + } }); }); describe('Class Level Permissions for requiredAuth', () => { - beforeEach(() => { config = Config.get('test'); }); function createUser() { - const user = new Parse.User(); - user.set("username", "hello"); - user.set("password", "world"); + const user = new Parse.User(); + user.set('username', 'hello'); + user.set('password', 'world'); return user.signUp(null); } - it('required auth test find', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': { - 'requiresAuthentication': true + it('required auth test find', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + e => { + expect(e.message).toEqual( + 'Permission denied, user needs to be authenticated.' + ); + done(); } - }); - }).then(() => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then(() => { - fail('Class permissions should have rejected this query.'); - done(); - }, (e) => { - expect(e.message).toEqual('Permission denied, user needs to be authenticated.'); - done(); - }); + ); }); - it('required auth test find authenticated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': { - 'requiresAuthentication': true + it('required auth test find authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(0); + done(); + }, + e => { + console.error(e); + fail('Should not have failed'); + done(); } - }); - }).then(() => { - return createUser(); - }).then(() => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(0); - done(); - }, (e) => { - console.error(e); - fail("Should not have failed"); - done(); - }); + ); }); - it('required auth should allow create authenticated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'create': { - 'requiresAuthentication': true + it('required auth should allow create authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save(); + }) + .then( + () => { + done(); + }, + e => { + console.error(e); + fail('Should not have failed'); + done(); } - }); - }).then(() => { - return createUser(); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save(); - }).then(() => { - done(); - }, (e) => { - console.error(e); - fail("Should not have failed"); - done(); - }); + ); }); - it('required auth should reject create when not authenticated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'create': { - 'requiresAuthentication': true + it('required auth should reject create when not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + e => { + expect(e.message).toEqual( + 'Permission denied, user needs to be authenticated.' + ); + done(); } - }); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save(); - }).then(() => { - fail('Class permissions should have rejected this query.'); - done(); - }, (e) => { - expect(e.message).toEqual('Permission denied, user needs to be authenticated.'); - done(); - }); + ); }); - it('required auth test create/get/update/delete authenticated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'create': { - 'requiresAuthentication': true - }, - 'get': { - 'requiresAuthentication': true - }, - 'delete': { - 'requiresAuthentication': true + it('required auth test create/get/update/delete authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + get: { + requiresAuthentication: true, + }, + delete: { + requiresAuthentication: true, + }, + update: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then(gotStuff => { + return gotStuff.save({ foo: 'baz' }).then(() => { + return gotStuff.destroy(); + }); + }) + .then( + () => { + done(); }, - 'update': { - 'requiresAuthentication': true + e => { + console.error(e); + fail('Should not have failed'); + done(); } - }); - }).then(() => { - return createUser(); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save().then(() => { - const query = new Parse.Query('Stuff'); - return query.get(stuff.id); - }); - }).then((gotStuff) => { - return gotStuff.save({'foo': 'baz'}).then(() => { - return gotStuff.destroy(); - }) - }).then(() => { - done(); - }, (e) => { - console.error(e); - fail("Should not have failed"); - done(); - }); + ); }); - it('required auth test create/get/update/delete not authenitcated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'get': { - 'requiresAuthentication': true - }, - 'delete': { - 'requiresAuthentication': true - }, - 'update': { - 'requiresAuthentication': true + it('required auth test get not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + get: { + requiresAuthentication: true, + }, + create: { + '*': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then( + () => { + fail('Should not succeed!'); + done(); }, - 'create': { - '*': true + e => { + expect(e.message).toEqual( + 'Permission denied, user needs to be authenticated.' + ); + done(); } - }); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save().then(() => { - const query = new Parse.Query('Stuff'); - return query.get(stuff.id); - }); - }).then(() => { - fail("Should not succeed!"); - done(); - }, (e) => { - expect(e.message).toEqual('Permission denied, user needs to be authenticated.'); - done(); - }); + ); }); - it('required auth test create/get/update/delete not authenitcated', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': { - 'requiresAuthentication': true - }, - 'delete': { - 'requiresAuthentication': true - }, - 'update': { - 'requiresAuthentication': true - }, - 'create': { - '*': true + it('required auth test find not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + create: { + '*': true, + }, + get: { + '*': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then(result => { + expect(result.get('foo')).toEqual('bar'); + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Should not succeed!'); + done(); }, - 'get': { - '*': true + e => { + expect(e.message).toEqual( + 'Permission denied, user needs to be authenticated.' + ); + done(); } - }); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save().then(() => { - const query = new Parse.Query('Stuff'); - return query.get(stuff.id); - }) - }).then((result) => { - expect(result.get('foo')).toEqual('bar'); - const query = new Parse.Query('Stuff'); - return query.find(); - }).then(() => { - fail("Should not succeed!"); - done(); - }, (e) => { - expect(e.message).toEqual('Permission denied, user needs to be authenticated.'); - done(); - }); + ); }); - it('required auth test create/get/update/delete with roles (#3753)', (done) => { + it('required auth test create/get/update/delete with roles (#3753)', done => { let user; - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': { - 'requiresAuthentication': true, - 'role:admin': true + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + 'role:admin': true, + }, + create: { 'role:admin': true }, + update: { 'role:admin': true }, + delete: { 'role:admin': true }, + get: { + requiresAuthentication: true, + 'role:admin': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff + .save(null, { useMasterKey: true }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query + .get(stuff.id) + .then( + () => { + done.fail('should not succeed'); + }, + () => { + return new Parse.Query('Stuff').find(); + } + ) + .then( + () => { + done.fail('should not succeed'); + }, + () => { + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.signUp('user', 'password').then(signedUpUser => { + user = signedUpUser; + const query = new Parse.Query('Stuff'); + return query.get(stuff.id, { + sessionToken: user.getSessionToken(), + }); + }); + }); + }) + .then(result => { + expect(result.get('foo')).toEqual('bar'); + const query = new Parse.Query('Stuff'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then( + results => { + expect(results.length).toBe(1); + done(); }, - 'create': { 'role:admin': true }, - 'update': { 'role:admin': true }, - 'delete': { 'role:admin': true }, - 'get': { - 'requiresAuthentication': true, - 'role:admin': true + e => { + console.error(e); + done.fail(e); } - }); - }).then(() => { - const stuff = new Parse.Object('Stuff'); - stuff.set('foo', 'bar'); - return stuff.save(null, {useMasterKey: true}).then(() => { - const query = new Parse.Query('Stuff'); - return query.get(stuff.id).then(() => { - done.fail('should not succeed'); - }, () => { - return new Parse.Query('Stuff').find(); - }).then(() => { - done.fail('should not succeed'); - }, () => { - return Promise.resolve(); - }); - }).then(() => { - return Parse.User.signUp('user', 'password').then((signedUpUser) => { - user = signedUpUser; - const query = new Parse.Query('Stuff'); - return query.get(stuff.id, {sessionToken: user.getSessionToken()}); - }); - }); - }).then((result) => { - expect(result.get('foo')).toEqual('bar'); - const query = new Parse.Query('Stuff'); - return query.find({sessionToken: user.getSessionToken()}); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }, (e) => { - console.error(e); - done.fail(e); - }); + ); }); -}) +}); diff --git a/spec/SchemaCache.spec.js b/spec/SchemaCache.spec.js index a27abd98ef..26897e03f7 100644 --- a/spec/SchemaCache.spec.js +++ b/spec/SchemaCache.spec.js @@ -1,6 +1,8 @@ -var CacheController = require('../src/Controllers/CacheController.js').default; -var InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').default; -var SchemaCache = require('../src/Controllers/SchemaCache').default; +const CacheController = require('../lib/Controllers/CacheController.js') + .default; +const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') + .default; +const SchemaCache = require('../lib/Controllers/SchemaCache').default; describe('SchemaCache', () => { let cacheController; @@ -10,59 +12,66 @@ describe('SchemaCache', () => { cacheController = new CacheController(cacheAdapter, 'appId'); }); - it('can retrieve a single schema after all schemas stored', (done) => { + it('can retrieve a single schema after all schemas stored', done => { const schemaCache = new SchemaCache(cacheController); - const allSchemas = [{ - className: 'Class1' - }, { - className: 'Class2' - }]; - schemaCache.setAllClasses(allSchemas).then(() => { - return schemaCache.getOneSchema('Class2'); - }).then((schema) => { - expect(schema).not.toBeNull(); - done(); - }); - }); - - it('does not return all schemas after a single schema is stored', (done) => { - const schemaCache = new SchemaCache(cacheController); - const schema = { - className: 'Class1' - }; - schemaCache.setOneSchema(schema.className, schema).then(() => { - return schemaCache.getAllClasses(); - }).then((allSchemas) => { - expect(allSchemas).toBeNull(); - done(); - }); + const allSchemas = [ + { + className: 'Class1', + }, + { + className: 'Class2', + }, + ]; + schemaCache + .setAllClasses(allSchemas) + .then(() => { + return schemaCache.getOneSchema('Class2'); + }) + .then(schema => { + expect(schema).not.toBeNull(); + done(); + }); }); - it('doesn\'t persist cached data by default', (done) => { + it("doesn't persist cached data by default", done => { const schemaCache = new SchemaCache(cacheController); const schema = { - className: 'Class1' + className: 'Class1', }; - schemaCache.setOneSchema(schema.className, schema).then(() => { + schemaCache.setAllClasses([schema]).then(() => { const anotherSchemaCache = new SchemaCache(cacheController); - return anotherSchemaCache.getOneSchema(schema.className).then((schema) => { + return anotherSchemaCache.getOneSchema(schema.className).then(schema => { expect(schema).toBeNull(); done(); }); }); }); - it('can persist cached data', (done) => { + it('can persist cached data', done => { const schemaCache = new SchemaCache(cacheController, 5000, true); const schema = { - className: 'Class1' + className: 'Class1', }; - schemaCache.setOneSchema(schema.className, schema).then(() => { + schemaCache.setAllClasses([schema]).then(() => { const anotherSchemaCache = new SchemaCache(cacheController, 5000, true); - return anotherSchemaCache.getOneSchema(schema.className).then((schema) => { + return anotherSchemaCache.getOneSchema(schema.className).then(schema => { expect(schema).not.toBeNull(); done(); }); }); }); + + it('should not store if ttl is null', async () => { + const ttl = null; + const schemaCache = new SchemaCache(cacheController, ttl); + expect(await schemaCache.getAllClasses()).toBeNull(); + expect(await schemaCache.setAllClasses()).toBeNull(); + expect(await schemaCache.getOneSchema()).toBeNull(); + }); + + it('should convert string ttl to number', async () => { + const ttl = '5000'; + const schemaCache = new SchemaCache(cacheController, ttl); + expect(schemaCache.ttl).toBe(5000); + }); }); diff --git a/spec/SessionTokenCache.spec.js b/spec/SessionTokenCache.spec.js index 57709e0cb0..990903d3ff 100644 --- a/spec/SessionTokenCache.spec.js +++ b/spec/SessionTokenCache.spec.js @@ -1,50 +1,55 @@ -var SessionTokenCache = require('../src/LiveQuery/SessionTokenCache').SessionTokenCache; +const SessionTokenCache = require('../lib/LiveQuery/SessionTokenCache') + .SessionTokenCache; describe('SessionTokenCache', function() { - beforeEach(function(done) { - var Parse = require('parse/node'); - - spyOn(Parse, "Query").and.returnValue({ - first: jasmine.createSpy("first").and.returnValue(Parse.Promise.as(new Parse.Object("_Session", { - user: new Parse.User({id:"userId"}) - }))), - equalTo: function(){} - }) + const Parse = require('parse/node'); + + spyOn(Parse, 'Query').and.returnValue({ + first: jasmine.createSpy('first').and.returnValue( + Promise.resolve( + new Parse.Object('_Session', { + user: new Parse.User({ id: 'userId' }), + }) + ) + ), + equalTo: function() {}, + }); done(); }); it('can get undefined userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); - - sessionTokenCache.getUserId(undefined).then(() => { - }, (error) => { - expect(error).not.toBeNull(); - done(); - }); + const sessionTokenCache = new SessionTokenCache(); + + sessionTokenCache.getUserId(undefined).then( + () => {}, + error => { + expect(error).not.toBeNull(); + done(); + } + ); }); it('can get existing userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); - var sessionToken = 'sessionToken'; - var userId = 'userId' + const sessionTokenCache = new SessionTokenCache(); + const sessionToken = 'sessionToken'; + const userId = 'userId'; sessionTokenCache.cache.set(sessionToken, userId); - sessionTokenCache.getUserId(sessionToken).then((userIdFromCache) => { + sessionTokenCache.getUserId(sessionToken).then(userIdFromCache => { expect(userIdFromCache).toBe(userId); done(); }); }); it('can get new userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); + const sessionTokenCache = new SessionTokenCache(); - sessionTokenCache.getUserId('sessionToken').then((userIdFromCache) => { + sessionTokenCache.getUserId('sessionToken').then(userIdFromCache => { expect(userIdFromCache).toBe('userId'); expect(sessionTokenCache.cache.length).toBe(1); done(); }); }); - }); diff --git a/spec/Subscription.spec.js b/spec/Subscription.spec.js index 20f1aa5bc1..368b493fa8 100644 --- a/spec/Subscription.spec.js +++ b/spec/Subscription.spec.js @@ -1,36 +1,51 @@ -var Subscription = require('../src/LiveQuery/Subscription').Subscription; +const Subscription = require('../lib/LiveQuery/Subscription').Subscription; let logger; describe('Subscription', function() { - beforeEach(function() { - logger = require('../src/logger').logger; + logger = require('../lib/logger').logger; spyOn(logger, 'error').and.callThrough(); }); it('can be initialized', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); expect(subscription.className).toBe('className'); - expect(subscription.query).toEqual({ key : 'value' }); + expect(subscription.query).toEqual({ key: 'value' }); expect(subscription.hash).toBe('hash'); expect(subscription.clientRequestIds.size).toBe(0); }); it('can check it has subscribing clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); expect(subscription.hasSubscribingClient()).toBe(false); }); it('can check it does not have subscribing clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); expect(subscription.hasSubscribingClient()).toBe(true); }); it('can add one request for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); expect(subscription.clientRequestIds.size).toBe(1); @@ -38,7 +53,11 @@ describe('Subscription', function() { }); it('can add requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); @@ -47,7 +66,11 @@ describe('Subscription', function() { }); it('can add requests for clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.addClientSubscription(2, 2); @@ -59,14 +82,22 @@ describe('Subscription', function() { }); it('can delete requests for nonexistent client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.deleteClientSubscription(1, 1); expect(logger.error).toHaveBeenCalled(); }); it('can delete nonexistent request for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.deleteClientSubscription(1, 2); @@ -76,7 +107,11 @@ describe('Subscription', function() { }); it('can delete some requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.deleteClientSubscription(1, 2); @@ -87,7 +122,11 @@ describe('Subscription', function() { }); it('can delete all requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.deleteClientSubscription(1, 1); @@ -98,7 +137,11 @@ describe('Subscription', function() { }); it('can delete requests for multiple clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + const subscription = new Subscription( + 'className', + { key: 'value' }, + 'hash' + ); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.addClientSubscription(2, 1); diff --git a/spec/TwitterAuth.spec.js b/spec/TwitterAuth.spec.js index d98297f745..ce55542a2b 100644 --- a/spec/TwitterAuth.spec.js +++ b/spec/TwitterAuth.spec.js @@ -1,61 +1,94 @@ -const twitter = require('../src/Adapters/Auth/twitter'); +const twitter = require('../lib/Adapters/Auth/twitter'); describe('Twitter Auth', () => { it('should use the proper configuration', () => { // Multiple options, consumer_key found - expect(twitter.handleMultipleConfigurations({ - consumer_key: 'hello', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]).consumer_key).toEqual('hello'); + expect( + twitter.handleMultipleConfigurations( + { + consumer_key: 'hello', + }, + [ + { + consumer_key: 'hello', + }, + { + consumer_key: 'world', + }, + ] + ).consumer_key + ).toEqual('hello'); // Multiple options, consumer_key not found - expect(function(){ - twitter.handleMultipleConfigurations({ - consumer_key: 'some', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]); + expect(function() { + twitter.handleMultipleConfigurations( + { + consumer_key: 'some', + }, + [ + { + consumer_key: 'hello', + }, + { + consumer_key: 'world', + }, + ] + ); }).toThrow(); // Multiple options, consumer_key not found - expect(function(){ - twitter.handleMultipleConfigurations({ - auth_token: 'token', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]); + expect(function() { + twitter.handleMultipleConfigurations( + { + auth_token: 'token', + }, + [ + { + consumer_key: 'hello', + }, + { + consumer_key: 'world', + }, + ] + ); }).toThrow(); // Single configuration and consumer_key set - expect(twitter.handleMultipleConfigurations({ - consumer_key: 'hello', - }, { - consumer_key: 'hello' - }).consumer_key).toEqual('hello'); + expect( + twitter.handleMultipleConfigurations( + { + consumer_key: 'hello', + }, + { + consumer_key: 'hello', + } + ).consumer_key + ).toEqual('hello'); // General case, only 1 config, no consumer_key set - expect(twitter.handleMultipleConfigurations({ - auth_token: 'token', - }, { - consumer_key: 'hello' - }).consumer_key).toEqual('hello'); + expect( + twitter.handleMultipleConfigurations( + { + auth_token: 'token', + }, + { + consumer_key: 'hello', + } + ).consumer_key + ).toEqual('hello'); }); - it("Should fail with missing options", (done) => { + it('Should fail with missing options', done => { try { - twitter.validateAuthData({ - consumer_key: 'key', - consumer_secret: 'secret', - auth_token: 'token', - auth_token_secret: 'secret' - }, undefined); + twitter.validateAuthData( + { + consumer_key: 'key', + consumer_secret: 'secret', + auth_token: 'token', + auth_token_secret: 'secret', + }, + undefined + ); } catch (error) { jequal(error.message, 'Twitter auth configuration missing'); done(); diff --git a/spec/Uniqueness.spec.js b/spec/Uniqueness.spec.js index 0789037953..73dfad34cb 100644 --- a/spec/Uniqueness.spec.js +++ b/spec/Uniqueness.spec.js @@ -1,45 +1,62 @@ 'use strict'; -const Parse = require("parse/node"); -const Config = require('../src/Config'); +const Parse = require('parse/node'); +const Config = require('../lib/Config'); describe('Uniqueness', function() { it('fail when create duplicate value in unique field', done => { const obj = new Parse.Object('UniqueField'); obj.set('unique', 'value'); - obj.save().then(() => { - expect(obj.id).not.toBeUndefined(); - const config = Config.get('test'); - return config.database.adapter.ensureUniqueness('UniqueField', { fields: { unique: { __type: 'String' } } }, ['unique']) - }) + obj + .save() + .then(() => { + expect(obj.id).not.toBeUndefined(); + const config = Config.get('test'); + return config.database.adapter.ensureUniqueness( + 'UniqueField', + { fields: { unique: { __type: 'String' } } }, + ['unique'] + ); + }) .then(() => { const obj = new Parse.Object('UniqueField'); obj.set('unique', 'value'); - return obj.save() - }).then(() => { - fail('Saving duplicate field should have failed'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + return obj.save(); + }) + .then( + () => { + fail('Saving duplicate field should have failed'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + } + ); }); it('unique indexing works on pointer fields', done => { const obj = new Parse.Object('UniquePointer'); - obj.save({ string: 'who cares' }) + obj + .save({ string: 'who cares' }) .then(() => obj.save({ ptr: obj })) .then(() => { const config = Config.get('test'); - return config.database.adapter.ensureUniqueness('UniquePointer', { fields: { - string: { __type: 'String' }, - ptr: { __type: 'Pointer', targetClass: 'UniquePointer' } - } }, ['ptr']); + return config.database.adapter.ensureUniqueness( + 'UniquePointer', + { + fields: { + string: { __type: 'String' }, + ptr: { __type: 'Pointer', targetClass: 'UniquePointer' }, + }, + }, + ['ptr'] + ); }) .then(() => { - const newObj = new Parse.Object('UniquePointer') - newObj.set('ptr', obj) - return newObj.save() + const newObj = new Parse.Object('UniquePointer'); + newObj.set('ptr', obj); + return newObj.save(); }) .then(() => { fail('save should have failed due to duplicate value'); @@ -59,7 +76,11 @@ describe('Uniqueness', function() { Parse.Object.saveAll([o1, o2]) .then(() => { const config = Config.get('test'); - return config.database.adapter.ensureUniqueness('UniqueFail', { fields: { key: { __type: 'String' } } }, ['key']); + return config.database.adapter.ensureUniqueness( + 'UniqueFail', + { fields: { key: { __type: 'String' } } }, + ['key'] + ); }) .catch(error => { expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); @@ -69,7 +90,12 @@ describe('Uniqueness', function() { it_exclude_dbs(['postgres'])('can do compound uniqueness', done => { const config = Config.get('test'); - config.database.adapter.ensureUniqueness('CompoundUnique', { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }, ['k1', 'k2']) + config.database.adapter + .ensureUniqueness( + 'CompoundUnique', + { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }, + ['k1', 'k2'] + ) .then(() => { const o1 = new Parse.Object('CompoundUnique'); o1.set('k1', 'v1'); diff --git a/spec/UserController.spec.js b/spec/UserController.spec.js index 498f70b655..1e4d5e48c3 100644 --- a/spec/UserController.spec.js +++ b/spec/UserController.spec.js @@ -1,59 +1,68 @@ -var UserController = require('../src/Controllers/UserController').UserController; -var emailAdapter = require('./MockEmailAdapter') -var AppCache = require('../src/cache').AppCache; +const UserController = require('../lib/Controllers/UserController') + .UserController; +const emailAdapter = require('./MockEmailAdapter'); +const AppCache = require('../lib/cache').AppCache; describe('UserController', () => { - var user = { + const user = { _email_verify_token: 'testToken', username: 'testUser', - email: 'test@example.com' - } + email: 'test@example.com', + }; describe('sendVerificationEmail', () => { describe('parseFrameURL not provided', () => { - it('uses publicServerURL', (done) => { + it('uses publicServerURL', done => { + AppCache.put( + defaultConfiguration.appId, + Object.assign({}, defaultConfiguration, { + publicServerURL: 'http://www.example.com', + customPages: { + parseFrameURL: undefined, + }, + }) + ); - AppCache.put(defaultConfiguration.appId, Object.assign({}, defaultConfiguration, { - publicServerURL: 'http://www.example.com', - customPages: { - parseFrameURL: undefined - } - })) + emailAdapter.sendVerificationEmail = options => { + expect(options.link).toEqual( + 'http://www.example.com/apps/test/verify_email?token=testToken&username=testUser' + ); + done(); + }; - emailAdapter.sendVerificationEmail = (options) => { - expect(options.link).toEqual('http://www.example.com/apps/test/verify_email?token=testToken&username=testUser') - done() - } + const userController = new UserController(emailAdapter, 'test', { + verifyUserEmails: true, + }); - var userController = new UserController(emailAdapter, 'test', { - verifyUserEmails: true - }) - - userController.sendVerificationEmail(user) - }) - }) + userController.sendVerificationEmail(user); + }); + }); describe('parseFrameURL provided', () => { - it('uses parseFrameURL and includes the destination in the link parameter', (done) => { - - AppCache.put(defaultConfiguration.appId, Object.assign({}, defaultConfiguration, { - publicServerURL: 'http://www.example.com', - customPages: { - parseFrameURL: 'http://someother.example.com/handle-parse-iframe' - } - })) - - emailAdapter.sendVerificationEmail = (options) => { - expect(options.link).toEqual('http://someother.example.com/handle-parse-iframe?link=%2Fapps%2Ftest%2Fverify_email&token=testToken&username=testUser') - done() - } - - var userController = new UserController(emailAdapter, 'test', { - verifyUserEmails: true - }) - - userController.sendVerificationEmail(user) - }) - }) - }) + it('uses parseFrameURL and includes the destination in the link parameter', done => { + AppCache.put( + defaultConfiguration.appId, + Object.assign({}, defaultConfiguration, { + publicServerURL: 'http://www.example.com', + customPages: { + parseFrameURL: 'http://someother.example.com/handle-parse-iframe', + }, + }) + ); + + emailAdapter.sendVerificationEmail = options => { + expect(options.link).toEqual( + 'http://someother.example.com/handle-parse-iframe?link=%2Fapps%2Ftest%2Fverify_email&token=testToken&username=testUser' + ); + done(); + }; + + const userController = new UserController(emailAdapter, 'test', { + verifyUserEmails: true, + }); + + userController.sendVerificationEmail(user); + }); + }); + }); }); diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index ef0ebe0946..4483f5d8f4 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -1,9 +1,9 @@ 'use strict'; const Parse = require('parse/node'); -const request = require('request-promise'); +const request = require('../lib/request'); -// const Config = require('../src/Config'); +// const Config = require('../lib/Config'); const EMAIL = 'foo@bar.com'; const ZIP = '10001'; @@ -12,85 +12,82 @@ const SSN = '999-99-9999'; describe('Personally Identifiable Information', () => { let user; - beforeEach(done => { - return Parse.User.signUp('tester', 'abc') - .then(loggedInUser => user = loggedInUser) - .then(() => Parse.User.logIn(user.get('username'), 'abc')) - .then(() => user - .set('email', EMAIL) - .set('zip', ZIP) - .set('ssn', SSN) - .save()) - .then(() => done()); + beforeEach(async done => { + user = await Parse.User.signUp('tester', 'abc'); + user = await Parse.User.logIn(user.get('username'), 'abc'); + await user + .set('email', EMAIL) + .set('zip', ZIP) + .set('ssn', SSN) + .save(); + done(); }); - it('should be able to get own PII via API with object', (done) => { - const userObj = new (Parse.Object.extend(Parse.User)); + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); userObj.id = user.id; - userObj.fetch().then( - fetchedUser => { + return userObj + .fetch() + .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); - }, e => console.error('error', e)) - .done(() => done()); + }) + .then(done) + .catch(done.fail); }); - it('should not be able to get PII via API with object', (done) => { - Parse.User.logOut() - .then(() => { - const userObj = new (Parse.Object.extend(Parse.User)); - userObj.id = user.id; - userObj.fetch().then( - fetchedUser => { - expect(fetchedUser.get('email')).toBe(undefined); - }) - .fail(e => { - done.fail(JSON.stringify(e)); - }) - .done(() => done()); - }); + it('should not be able to get PII via API with object', done => { + return Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + done(); + }) + .catch(e => { + done.fail(JSON.stringify(e)); + }) + .then(done) + .catch(done.fail); + }); }); - it('should be able to get PII via API with object using master key', (done) => { - Parse.User.logOut() - .then(() => { - const userObj = new (Parse.Object.extend(Parse.User)); - userObj.id = user.id; - userObj.fetch({ useMasterKey: true }).then( - fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); - }, e => console.error('error', e)) - .done(() => done()); - }); + it('should be able to get PII via API with object using master key', done => { + return Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => expect(fetchedUser.get('email')).toBe(EMAIL)) + .then(done) + .catch(done.fail); + }); }); + it('should be able to get own PII via API with Find', done => { + return new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); - it('should be able to get own PII via API with Find', (done) => { - new Parse.Query(Parse.User) - .first() - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); + it('should not get PII via API with Find', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); expect(fetchedUser.get('zip')).toBe(ZIP); expect(fetchedUser.get('ssn')).toBe(SSN); done(); - }); - }); - - it('should not get PII via API with Find', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) - .first() - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(undefined); - expect(fetchedUser.get('zip')).toBe(ZIP); - expect(fetchedUser.get('ssn')).toBe(SSN); - done(); - }) - ); + }) + ); }); - it('should get PII via API with Find using master key', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) + it('should get PII via API with Find using master key', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User) .first({ useMasterKey: true }) .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); @@ -98,37 +95,32 @@ describe('Personally Identifiable Information', () => { expect(fetchedUser.get('ssn')).toBe(SSN); done(); }) - ); + ); }); + it('should be able to get own PII via API with Get', done => { + return new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); - it('should be able to get own PII via API with Get', (done) => { - new Parse.Query(Parse.User) - .get(user.id) - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); + it('should not get PII via API with Get', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); expect(fetchedUser.get('zip')).toBe(ZIP); expect(fetchedUser.get('ssn')).toBe(SSN); done(); - }); - }); - - it('should not get PII via API with Get', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) - .get(user.id) - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(undefined); - expect(fetchedUser.get('zip')).toBe(ZIP); - expect(fetchedUser.get('ssn')).toBe(SSN); - done(); - }) - ); + }) + ); }); - it('should get PII via API with Get using master key', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) + it('should get PII via API with Get using master key', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User) .get(user.id, { useMasterKey: true }) .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); @@ -136,240 +128,694 @@ describe('Personally Identifiable Information', () => { expect(fetchedUser.get('ssn')).toBe(SSN); done(); }) - ); + ); }); - it('should not get PII via REST', (done) => { - request.get({ + it('should not get PII via REST', done => { + return request({ url: 'http://localhost:8378/1/classes/_User', - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' - } + 'X-Parse-Javascript-Key': 'test', + }, }) - .then( - result => { - const fetchedUser = result.results[0]; - expect(fetchedUser.zip).toBe(ZIP); - expect(fetchedUser.email).toBe(undefined); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(undefined); + }) + .then(done) + .catch(done.fail); }); - it('should get PII via REST with self credentials', (done) => { - request.get({ + it('should get PII via REST with self credentials', done => { + return request({ url: 'http://localhost:8378/1/classes/_User', json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', - 'X-Parse-Session-Token': user.getSessionToken() - } + 'X-Parse-Session-Token': user.getSessionToken(), + }, }) - .then( - result => { - const fetchedUser = result.results[0]; - expect(fetchedUser.zip).toBe(ZIP); - expect(fetchedUser.email).toBe(EMAIL); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); }); - it('should get PII via REST using master key', (done) => { - request.get({ + it('should get PII via REST using master key', done => { + request({ url: 'http://localhost:8378/1/classes/_User', json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' - } + 'X-Parse-Master-Key': 'test', + }, }) - .then( - result => { - const fetchedUser = result.results[0]; - expect(fetchedUser.zip).toBe(ZIP); - expect(fetchedUser.email).toBe(EMAIL); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); }); - it('should not get PII via REST by ID', (done) => { - request.get({ + it('should not get PII via REST by ID', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' - } + 'X-Parse-Javascript-Key': 'test', + }, }) .then( - result => { - const fetchedUser = result; + response => { + const fetchedUser = response.data; expect(fetchedUser.zip).toBe(ZIP); expect(fetchedUser.email).toBe(undefined); }, - e => console.error('error', e.message) - ).done(() => done()); + e => done.fail(e) + ) + .then(() => done()); }); - it('should get PII via REST by ID with self credentials', (done) => { - request.get({ + it('should get PII via REST by ID with self credentials', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', - 'X-Parse-Session-Token': user.getSessionToken() - } + 'X-Parse-Session-Token': user.getSessionToken(), + }, }) - .then( - result => { - const fetchedUser = result; - expect(fetchedUser.zip).toBe(ZIP); - expect(fetchedUser.email).toBe(EMAIL); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); }); - it('should get PII via REST by ID with master key', (done) => { - request.get({ + it('should get PII via REST by ID with master key', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', 'X-Parse-Master-Key': 'test', - } + }, }) - .then( - result => { - const fetchedUser = result; - expect(fetchedUser.zip).toBe(ZIP); - expect(fetchedUser.email).toBe(EMAIL); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); }); - describe('with configured sensitive fields', () => { - beforeEach((done) => { - reconfigureServer({ userSensitiveFields: ['ssn', 'zip'] }) - .then(() => done()); + describe('with deprecated configured sensitive fields', () => { + beforeEach(done => { + return reconfigureServer({ userSensitiveFields: ['ssn', 'zip'] }).then( + done + ); }); - it('should be able to get own PII via API with object', (done) => { - const userObj = new (Parse.Object.extend(Parse.User)); + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); userObj.id = user.id; - userObj.fetch().then( - fetchedUser => { + return userObj + .fetch() + .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); expect(fetchedUser.get('zip')).toBe(ZIP); expect(fetchedUser.get('ssn')).toBe(SSN); done(); - }, e => done.fail(e)); + }) + .catch(done.fail); + }); + + it('should not be able to get PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get PII via API with object using master key', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + }, done.fail) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get own PII via API with Find', done => { + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Find using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .first({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should be able to get own PII via API with Get', done => { + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Get using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id, { useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should not get PII via REST', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, done.fail) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST with self credentials', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + return expect(fetchedUser.ssn).toBe(SSN); + }) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST using master key', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should not get PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with self credentials', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with master key', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + // Explicit ACL should be able to read sensitive information + describe('with privileged user no CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role( + 'Administrator', + new Parse.ACL() + ).save(null, { useMasterKey: true }); + + const managementRole = new Parse.Role( + 'managementOf_user' + user.id, + new Parse.ACL(user) + ); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole + .getUsers() + .add(adminUser) + .save(null, { useMasterKey: true }); + + done(); + }); + + it('privileged user should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('privileged user should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); }); - it('should not be able to get PII via API with object', (done) => { - Parse.User.logOut() - .then(() => { - const userObj = new (Parse.Object.extend(Parse.User)); + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); userObj.id = user.id; - userObj.fetch().then( - fetchedUser => { + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; expect(fetchedUser.get('email')).toBe(undefined); expect(fetchedUser.get('zip')).toBe(undefined); expect(fetchedUser.get('ssn')).toBe(undefined); - }, e => console.error('error', e)) - .done(() => done()); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => done()); }); - }); - it('should be able to get PII via API with object using master key', (done) => { - Parse.User.logOut() - .then(() => { - const userObj = new (Parse.Object.extend(Parse.User)); + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); userObj.id = user.id; - userObj.fetch({ useMasterKey: true }).then( - fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); - expect(fetchedUser.get('zip')).toBe(ZIP); - expect(fetchedUser.get('ssn')).toBe(SSN); - }, e => console.error('error', e)) - .done(() => done()); + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); }); - }); + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); - it('should be able to get own PII via API with Find', (done) => { - new Parse.Query(Parse.User) - .first() - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); - expect(fetchedUser.get('zip')).toBe(ZIP); - expect(fetchedUser.get('ssn')).toBe(SSN); - done(); + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); }); + }); + }); + }); + + describe('with configured sensitive fields via CLP', () => { + beforeEach(done => { + reconfigureServer({ + protectedFields: { + _User: { '*': ['ssn', 'zip'], 'role:Administrator': [] }, + }, + }).then(done); }); - it('should not get PII via API with Find', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) - .first() + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj.fetch().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }, done.fail); + }); + + it('should not be able to get PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(undefined); expect(fetchedUser.get('zip')).toBe(undefined); expect(fetchedUser.get('ssn')).toBe(undefined); - done(); }) - ); + .then(done) + .catch(done.fail); + }); }); - it('should get PII via API with Find using master key', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) - .first({ useMasterKey: true }) + it('should be able to get PII via API with object using master key', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); expect(fetchedUser.get('zip')).toBe(ZIP); expect(fetchedUser.get('ssn')).toBe(SSN); - done(); - }) - ); + }, done.fail) + .then(done) + .catch(done.fail); + }); }); + it('should be able to get own PII via API with Find', done => { + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); - it('should be able to get own PII via API with Get', (done) => { - new Parse.Query(Parse.User) - .get(user.id) - .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); - expect(fetchedUser.get('zip')).toBe(ZIP); - expect(fetchedUser.get('ssn')).toBe(SSN); + it('should not get PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); done(); - }); + }) + ); }); - it('should not get PII via API with Get', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) - .get(user.id) + it('should get PII via API with Find using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .first({ useMasterKey: true }) .then(fetchedUser => { - expect(fetchedUser.get('email')).toBe(undefined); - expect(fetchedUser.get('zip')).toBe(undefined); - expect(fetchedUser.get('ssn')).toBe(undefined); + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); done(); }) - ); + ); + }); + + it('should be able to get own PII via API with Get', done => { + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); }); - it('should get PII via API with Get using master key', (done) => { - Parse.User.logOut() - .then(() => new Parse.Query(Parse.User) + it('should get PII via API with Get using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) .get(user.id, { useMasterKey: true }) .then(fetchedUser => { expect(fetchedUser.get('email')).toBe(EMAIL); @@ -377,127 +823,389 @@ describe('Personally Identifiable Information', () => { expect(fetchedUser.get('ssn')).toBe(SSN); done(); }) - ); + ); }); - it('should not get PII via REST', (done) => { - request.get({ + it('should not get PII via REST', done => { + request({ url: 'http://localhost:8378/1/classes/_User', - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' - } + 'X-Parse-Javascript-Key': 'test', + }, }) - .then( - result => { - const fetchedUser = result.results[0]; - expect(fetchedUser.zip).toBe(undefined); - expect(fetchedUser.ssn).toBe(undefined); - expect(fetchedUser.email).toBe(undefined); - }, - e => console.error('error', e.message) - ).done(() => done()); + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, done.fail) + .then(done) + .catch(done.fail); }); - it('should get PII via REST with self credentials', (done) => { - request.get({ + it('should get PII via REST with self credentials', done => { + request({ url: 'http://localhost:8378/1/classes/_User', json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', - 'X-Parse-Session-Token': user.getSessionToken() - } + 'X-Parse-Session-Token': user.getSessionToken(), + }, }) .then( - result => { + response => { + const result = response.data; const fetchedUser = result.results[0]; expect(fetchedUser.zip).toBe(ZIP); expect(fetchedUser.email).toBe(EMAIL); expect(fetchedUser.ssn).toBe(SSN); }, - e => console.error('error', e.message) - ).done(() => done()); + () => {} + ) + .then(done) + .catch(done.fail); }); - it('should get PII via REST using master key', (done) => { - request.get({ + it('should get PII via REST using master key', done => { + request({ url: 'http://localhost:8378/1/classes/_User', json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' - } + 'X-Parse-Master-Key': 'test', + }, }) .then( - result => { + response => { + const result = response.data; const fetchedUser = result.results[0]; expect(fetchedUser.zip).toBe(ZIP); expect(fetchedUser.email).toBe(EMAIL); expect(fetchedUser.ssn).toBe(SSN); }, - e => console.error('error', e.message) - ).done(() => done()); + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); }); - it('should not get PII via REST by ID', (done) => { - request.get({ + it('should not get PII via REST by ID', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' - } + 'X-Parse-Javascript-Key': 'test', + }, }) .then( - result => { - const fetchedUser = result; + response => { + const fetchedUser = response.data; expect(fetchedUser.zip).toBe(undefined); expect(fetchedUser.email).toBe(undefined); }, - e => console.error('error', e.message) - ).done(() => done()); + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); }); - it('should get PII via REST by ID with self credentials', (done) => { - request.get({ + it('should get PII via REST by ID with self credentials', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', - 'X-Parse-Session-Token': user.getSessionToken() - } + 'X-Parse-Session-Token': user.getSessionToken(), + }, }) .then( - result => { - const fetchedUser = result; + response => { + const fetchedUser = response.data; expect(fetchedUser.zip).toBe(ZIP); expect(fetchedUser.email).toBe(EMAIL); }, - e => console.error('error', e.message) - ).done(() => done()); + () => {} + ) + .then(done) + .catch(done.fail); }); - it('should get PII via REST by ID with master key', (done) => { - request.get({ + it('should get PII via REST by ID with master key', done => { + request({ url: `http://localhost:8378/1/classes/_User/${user.id}`, - json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Javascript-Key': 'test', 'X-Parse-Master-Key': 'test', - } + }, }) .then( - result => { + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + // Explicit ACL should be able to read sensitive information + describe('with privileged user CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role( + 'Administrator', + new Parse.ACL() + ).save(null, { useMasterKey: true }); + + const managementRole = new Parse.Role( + 'managementOf_user' + user.id, + new Parse.ACL(user) + ); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole + .getUsers() + .add(adminUser) + .save(null, { useMasterKey: true }); + + done(); + }); + + it('privileged user should be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('privileged user should be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; const fetchedUser = result; expect(fetchedUser.zip).toBe(ZIP); expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + }); + + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', }, - e => console.error('error', e.message) - ).done(() => done()); + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + const ANOTHER_EMAIL = 'another@bar.com'; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => + anotherUser + .set('email', ANOTHER_EMAIL) + .set('zip', ZIP) + .set('ssn', SSN) + .save() + ) + .then(() => done()); + }); + + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find without constraints', done => { + new Parse.Query(Parse.User) + .find() + .then(fetchedUsers => { + const notCurrentUser = fetchedUsers.find( + u => u.id !== anotherUser.id + ); + expect(notCurrentUser.get('email')).toBe(undefined); + expect(notCurrentUser.get('zip')).toBe(undefined); + expect(notCurrentUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should be able to get own PII via API with Find without constraints', done => { + new Parse.Query(Parse.User) + .find() + .then(fetchedUsers => { + const currentUser = fetchedUsers.find( + u => u.id === anotherUser.id + ); + expect(currentUser.get('email')).toBe(ANOTHER_EMAIL); + expect(currentUser.get('zip')).toBe(ZIP); + expect(currentUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + }); }); }); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 1080cf003f..8be07b3fa5 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -1,201 +1,172 @@ -"use strict"; +'use strict'; const MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); -const request = require('request'); -const Config = require("../src/Config"); +const request = require('../lib/request'); +const Config = require('../lib/Config'); -describe("Custom Pages, Email Verification, Password Reset", () => { - it("should set the custom pages", (done) => { +describe('Custom Pages, Email Verification, Password Reset', () => { + it('should set the custom pages', done => { reconfigureServer({ appName: 'unused', customPages: { - invalidLink: "myInvalidLink", - verifyEmailSuccess: "myVerifyEmailSuccess", - choosePassword: "myChoosePassword", - passwordResetSuccess: "myPasswordResetSuccess", - parseFrameURL: "http://example.com/handle-parse-iframe" + invalidLink: 'myInvalidLink', + verifyEmailSuccess: 'myVerifyEmailSuccess', + choosePassword: 'myChoosePassword', + passwordResetSuccess: 'myPasswordResetSuccess', + parseFrameURL: 'http://example.com/handle-parse-iframe', }, - publicServerURL: "https://my.public.server.com/1" - }) - .then(() => { - var config = Config.get("test"); - expect(config.invalidLinkURL).toEqual("myInvalidLink"); - expect(config.verifyEmailSuccessURL).toEqual("myVerifyEmailSuccess"); - expect(config.choosePasswordURL).toEqual("myChoosePassword"); - expect(config.passwordResetSuccessURL).toEqual("myPasswordResetSuccess"); - expect(config.parseFrameURL).toEqual("http://example.com/handle-parse-iframe"); - expect(config.verifyEmailURL).toEqual("https://my.public.server.com/1/apps/test/verify_email"); - expect(config.requestResetPasswordURL).toEqual("https://my.public.server.com/1/apps/test/request_password_reset"); - done(); - }); + publicServerURL: 'https://my.public.server.com/1', + }).then(() => { + const config = Config.get('test'); + expect(config.invalidLinkURL).toEqual('myInvalidLink'); + expect(config.verifyEmailSuccessURL).toEqual('myVerifyEmailSuccess'); + expect(config.choosePasswordURL).toEqual('myChoosePassword'); + expect(config.passwordResetSuccessURL).toEqual('myPasswordResetSuccess'); + expect(config.parseFrameURL).toEqual( + 'http://example.com/handle-parse-iframe' + ); + expect(config.verifyEmailURL).toEqual( + 'https://my.public.server.com/1/apps/test/verify_email' + ); + expect(config.requestResetPasswordURL).toEqual( + 'https://my.public.server.com/1/apps/test/request_password_reset' + ); + done(); + }); }); it('sends verification email if email verification is enabled', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.setEmail('testIfEnabled@parse.com'); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); - }, - error: function() { - fail('Failed to save user'); - done(); - } - }); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + spyOn(emailAdapter, 'sendVerificationEmail'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.setEmail('testIfEnabled@parse.com'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch().then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); }); + }); }); it('does not send verification email when verification is enabled and email is not set', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(undefined); - done(); - }); - }, - error: function() { - fail('Failed to save user'); - done(); - } - }); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + spyOn(emailAdapter, 'sendVerificationEmail'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch().then(() => { + expect(user.get('emailVerified')).toEqual(undefined); + done(); }); + }); }); it('does send a validation email when updating the email', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then((user) => { - user.set("email", "testWhenUpdating@parse.com"); - return user.save(); - }).then((user) => { - return user.fetch(); - }).then(() => { - expect(user.get('emailVerified')).toEqual(false); - // Wait as on update email, we need to fetch the username - setTimeout(function(){ - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - done(); - }, 200); - }); - }, - error: function() { - fail('Failed to save user'); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + spyOn(emailAdapter, 'sendVerificationEmail'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user + .fetch() + .then(user => { + user.set('email', 'testWhenUpdating@parse.com'); + return user.save(); + }) + .then(user => { + return user.fetch(); + }) + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + // Wait as on update email, we need to fetch the username + setTimeout(function() { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); done(); - } + }, 200); }); - }); + }); }); - it('does send a validation email with valid verification link when updating the email', done => { - var emailAdapter = { + it('does send a validation email with valid verification link when updating the email', async done => { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - reconfigureServer({ + sendMail: () => Promise.resolve(), + }; + await reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail').and.callFake((options) => { - expect(options.link).not.toBeNull(); - expect(options.link).not.toMatch(/token=undefined/); - Promise.resolve(); - }); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then((user) => { - user.set("email", "testValidLinkWhenUpdating@parse.com"); - return user.save(); - }).then((user) => { - return user.fetch(); - }).then(() => { - expect(user.get('emailVerified')).toEqual(false); - // Wait as on update email, we need to fetch the username - setTimeout(function(){ - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - done(); - }, 200); - }); - }, - error: function() { - fail('Failed to save user'); - done(); - } - }); - }); + publicServerURL: 'http://localhost:8378/1', + }); + spyOn(emailAdapter, 'sendVerificationEmail').and.callFake(options => { + expect(options.link).not.toBeNull(); + expect(options.link).not.toMatch(/token=undefined/); + Promise.resolve(); + }); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + await user.fetch(); + user.set('email', 'testValidLinkWhenUpdating@parse.com'); + await user.save(); + await user.fetch(); + expect(user.get('emailVerified')).toEqual(false); + // Wait as on update email, we need to fetch the username + setTimeout(function() { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); }); it('does send with a simple adapter', done => { - var calls = 0; - var emailAdapter = { - sendMail: function(options){ + let calls = 0; + const emailAdapter = { + sendMail: function(options) { expect(options.to).toBe('testSendSimpleAdapter@parse.com'); if (calls == 0) { - expect(options.subject).toEqual('Please verify your e-mail for My Cool App'); + expect(options.subject).toEqual( + 'Please verify your e-mail for My Cool App' + ); expect(options.text.match(/verify_email/)).not.toBe(null); } else if (calls == 1) { expect(options.subject).toEqual('Password Reset for My Cool App'); @@ -203,41 +174,38 @@ describe("Custom Pages, Email Verification, Password Reset", () => { } calls++; return Promise.resolve(); - } - } + }, + }; reconfigureServer({ appName: 'My Cool App', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testSendSimpleAdapter@parse.com"); - user.signUp(null, { - success: function(user) { - expect(calls).toBe(1); - user.fetch() - .then((user) => { - return user.save(); - }).then(() => { - return Parse.User.requestPasswordReset("testSendSimpleAdapter@parse.com").catch(() => { - fail('Should not fail requesting a password'); - done(); - }) - }).then(() => { - expect(calls).toBe(2); - done(); - }); - }, - error: function() { - fail('Failed to save user'); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testSendSimpleAdapter@parse.com'); + await user.signUp(); + expect(calls).toBe(1); + user + .fetch() + .then(user => { + return user.save(); + }) + .then(() => { + return Parse.User.requestPasswordReset( + 'testSendSimpleAdapter@parse.com' + ).catch(() => { + fail('Should not fail requesting a password'); done(); - } + }); + }) + .then(() => { + expect(calls).toBe(2); + done(); }); - }); + }); }); it('prevents user from login if email is not verified but preventLoginWithUnverifiedEmail is set to true', done => { @@ -254,21 +222,25 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then((user) => { + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(user => { expect(user.getSessionToken()).toBe(undefined); - return Parse.User.logIn("zxcv", "asdf"); + return Parse.User.logIn('zxcv', 'asdf'); }) - .then(() => { - fail('login should have failed'); - done(); - }, error => { - expect(error.message).toEqual('User email is not verified.') - done(); - }); + .then( + () => { + fail('login should have failed'); + done(); + }, + error => { + expect(error.message).toEqual('User email is not verified.'); + done(); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -277,55 +249,66 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }); it('allows user to login only after user clicks on the link to confirm email address if preventLoginWithUnverifiedEmail is set to true', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, preventLoginWithUnverifiedEmail: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setPassword("other-password"); - user.setUsername("user"); + user.setPassword('other-password'); + user.setUsername('user'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { + }) + .then(() => { expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - - Parse.User.logIn("user", "other-password") - .then(user => { - expect(typeof user).toBe('object'); - expect(user.get('emailVerified')).toBe(true); - done(); - }, () => { - fail('login should have succeeded'); - done(); - }); - }, (err) => { - jfail(err); - fail("this should not fail"); - done(); - }).catch((err) => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user' + ); + user + .fetch() + .then( + () => { + expect(user.get('emailVerified')).toEqual(true); + + Parse.User.logIn('user', 'other-password').then( + user => { + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(true); + done(); + }, + () => { + fail('login should have succeeded'); + done(); + } + ); + }, + err => { + jfail(err); + fail('this should not fail'); + done(); + } + ) + .catch(err => { jfail(err); done(); - }) + }); }); }); }); @@ -344,19 +327,23 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.logIn("zxcv", "asdf")) - .then(user => { - expect(typeof user).toBe('object'); - expect(user.get('emailVerified')).toBe(false); - done(); - }, () => { - fail('login should have succeeded'); - done(); - }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.logIn('zxcv', 'asdf')) + .then( + user => { + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(false); + done(); + }, + () => { + fail('login should have succeeded'); + done(); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -376,19 +363,26 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') - done(); - }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => + Parse.User.requestPasswordReset('testInvalidConfig@parse.com') + ) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -407,19 +401,26 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') - done(); - }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => + Parse.User.requestPasswordReset('testInvalidConfig@parse.com') + ) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -435,19 +436,26 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') - done(); - }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => + Parse.User.requestPasswordReset('testInvalidConfig@parse.com') + ) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -455,7 +463,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }); }); - it('succeeds sending a password reset email if appName, publicServerURL, and email adapter are prodvided', done => { + it('succeeds sending a password reset email if appName, publicServerURL, and email adapter are provided', done => { reconfigureServer({ appName: 'coolapp', publicServerURL: 'http://localhost:1337/1', @@ -467,16 +475,22 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(() => { - done(); - }, error => { - done(error); - }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => + Parse.User.requestPasswordReset('testInvalidConfig@parse.com') + ) + .then( + () => { + done(); + }, + error => { + done(error); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -484,7 +498,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }); }); - it('succeeds sending a password reset username if appName, publicServerURL, and email adapter are prodvided', done => { + it('succeeds sending a password reset username if appName, publicServerURL, and email adapter are provided', done => { const adapter = MockEmailAdapterWithOptions({ fromAddress: 'parse@example.com', apiKey: 'k', @@ -492,7 +506,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { sendMail: function(options) { expect(options.to).toEqual('testValidConfig@parse.com'); return Promise.resolve(); - } + }, }); // delete that handler to force using the default @@ -502,20 +516,26 @@ describe("Custom Pages, Email Verification, Password Reset", () => { reconfigureServer({ appName: 'coolapp', publicServerURL: 'http://localhost:1337/1', - emailAdapter: adapter + emailAdapter: adapter, }) .then(() => { const user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("testValidConfig@parse.com"); - user.signUp(null) - .then(() => Parse.User.requestPasswordReset("testValidConfig@parse.com")) - .then(() => { - expect(adapter.sendMail).toHaveBeenCalled(); - done(); - }, error => { - done(error); - }); + user.setPassword('asdf'); + user.setUsername('testValidConfig@parse.com'); + user + .signUp(null) + .then(() => + Parse.User.requestPasswordReset('testValidConfig@parse.com') + ) + .then( + () => { + expect(adapter.sendMail).toHaveBeenCalled(); + done(); + }, + error => { + done(error); + } + ); }) .catch(error => { fail(JSON.stringify(error)); @@ -524,138 +544,131 @@ describe("Custom Pages, Email Verification, Password Reset", () => { }); it('does not send verification email if email verification is disabled', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', publicServerURL: 'http://localhost:1337/1', verifyUserEmails: false, emailAdapter: emailAdapter, - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - user.fetch() - .then(() => { - expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); - expect(user.get('emailVerified')).toEqual(undefined); - done(); - }); - }, - error: function() { - fail('Failed to save user'); - done(); - } - }); - }); + }).then(async () => { + spyOn(emailAdapter, 'sendVerificationEmail'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + await user.fetch(); + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); }); it('receives the app name and user in the adapter', done => { - var emailSent = false; - var emailAdapter = { + let emailSent = false; + const emailAdapter = { sendVerificationEmail: options => { expect(options.appName).toEqual('emailing app'); expect(options.user.get('email')).toEqual('user@parse.com'); emailSent = true; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp(null, { - success: () => { - expect(emailSent).toBe(true); - done(); - }, - error: function() { - fail('Failed to save user'); - done(); - } - }); - }); - }) + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'user@parse.com'); + await user.signUp(); + expect(emailSent).toBe(true); + done(); + }); + }); it('when you click the link in the email it sets emailVerified to true and redirects you', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) .then(() => { - user.setPassword("other-password"); - user.setUsername("user"); + user.setPassword('other-password'); + user.setUsername('user'); user.set('email', 'user@parse.com'); return user.signUp(); - }).then(() => { + }) + .then(() => { expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - done(); - }, (err) => { - jfail(err); - fail("this should not fail"); - done(); - }).catch((err) => { + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user' + ); + user + .fetch() + .then( + () => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }, + err => { + jfail(err); + fail('this should not fail'); + done(); + } + ) + .catch(err => { jfail(err); done(); - }) + }); }); }); }); - it('redirects you to invalid link if you try to verify email incorrecly', done => { + it('redirects you to invalid link if you try to verify email incorrectly', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/verify_email', { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done() - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/verify_email', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html' + ); + done(); }); + }); }); it('redirects you to invalid verification link page if you try to validate a nonexistant users email', done => { @@ -665,19 +678,22 @@ describe("Custom Pages, Email Verification, Password Reset", () => { emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=sadfasga&appId=test'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: + 'http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=sadfasga&appId=test' + ); + done(); }); + }); }); it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => { @@ -687,104 +703,104 @@ describe("Custom Pages, Email Verification, Password Reset", () => { emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.post('http://localhost:8378/1/apps/test/resend_verification_email', { - followRedirect: false, - form: { - username: "sadfasga" - } - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/link_send_fail.html'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/link_send_fail.html' + ); + done(); }); + }); }); it('does not update email verified if you use an invalid token', done => { - var user = new Parse.User(); - var emailAdapter = { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=zxcv&appId=test'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); + request({ + url: + 'http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?username=zxcv&appId=test' + ); + user.fetch().then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); }); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp(null, { - success: () => {}, - error: function() { - fail('Failed to save user'); - done(); - } - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function() { + fail('Failed to save user'); + done(); + }, }); + }); }); it('should send a password reset link', done => { - var user = new Parse.User(); - var emailAdapter = { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - request.get(options.link, { - followRedirect: false, - }, (error, response) => { - if (error) { - jfail(error); - fail("Failed to get the reset link"); - return; - } - expect(response.statusCode).toEqual(302); - var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv%2Bzxcv/; - expect(response.body.match(re)).not.toBe(null); + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv%2Bzxcv/; + expect(response.text.match(re)).not.toBe(null); done(); }); }, - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv+zxcv"); - user.set('email', 'user@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user@parse.com', { - error: (err) => { - jfail(err); - fail("Should not fail requesting a password"); - done(); - } - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv+zxcv'); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: err => { + jfail(err); + fail('Should not fail requesting a password'); + done(); + }, }); }); + }); }); it('redirects you to invalid link if you try to request password for a nonexistant users email', done => { @@ -794,99 +810,299 @@ describe("Custom Pages, Email Verification, Password Reset", () => { emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { - followRedirect: false, - }, (error, response) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: + 'http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html' + ); + done(); }); + }); }); - it('should programatically reset password', done => { - var user = new Parse.User(); - var emailAdapter = { + it('should programmatically reset password', done => { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - request.get(options.link, { - followRedirect: false, - }, (error, response) => { - if (error) { - jfail(error); - fail("Failed to get the reset link"); + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); return; } - expect(response.statusCode).toEqual(302); - var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; - var match = response.body.match(re); + const token = match[1]; + + request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=zxcv' + ); + + Parse.User.logIn('zxcv', 'hello').then( + function() { + const config = Config.get('test'); + config.database.adapter + .find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ) + .then(results => { + // _perishable_token should be unset after reset password + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toEqual(undefined); + done(); + }); + }, + err => { + jfail(err); + fail('should login with new password'); + done(); + } + ); + }); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: err => { + jfail(err); + fail('Should not fail'); + done(); + }, + }); + }); + }); + }); + + it('should redirect with username encoded on success page', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv%2B1/; + const match = response.text.match(re); if (!match) { - fail("should have a token"); + fail('should have a token'); done(); return; } - var token = match[1]; + const token = match[1]; - request.post({ - url: "http://localhost:8378/1/apps/test/request_password_reset" , - body: `new_password=hello&token=${token}&username=zxcv`, + request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv+1' }, headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', }, - followRedirect: false, - }, (error, response) => { - if (error) { - jfail(error); - fail("Failed to POST request password reset"); - return; - } - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=zxcv'); - - Parse.User.logIn("zxcv", "hello").then(function(){ - const config = Config.get('test'); - config.database.adapter.find('_User', { fields: {} }, { 'username': 'zxcv' }, { limit: 1 }) - .then(results => { - // _perishable_token should be unset after reset password - expect(results.length).toEqual(1); - expect(results[0]['_perishable_token']).toEqual(undefined); - done(); - }); - }, (err) => { - jfail(err); - fail("should login with new password"); - done(); - }); - + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(302); + expect(response.text).toEqual( + 'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html?username=zxcv%2B1' + ); + done(); }); }); }, - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv+1'); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: err => { + jfail(err); + fail('Should not fail'); + done(); + }, + }); + }); + }); + }); + + it('should programmatically reset password on ajax request', async done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: async options => { + const response = await request({ + url: options.link, + followRedirects: false, + }); + expect(response.status).toEqual(302); + const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return; + } + const token = match[1]; + + const resetResponse = await request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + expect(resetResponse.status).toEqual(200); + expect(resetResponse.text).toEqual('"Password successfully reset"'); + + await Parse.User.logIn('zxcv', 'hello'); + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ); + // _perishable_token should be unset after reset password + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toEqual(undefined); + done(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@parse.com'); + }); + + it('should return ajax failure error on ajax request with wrong data provided', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/1', + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=12345&username=Johnny`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual( + '{"code":-1,"error":"Failed to reset password: username / email / token is invalid"}' + ); + } + }); + + it('deletes password reset token on email address change', done => { + reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), }) .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp().then(() => { - Parse.User.requestPasswordReset('user@parse.com', { - error: (err) => { - jfail(err); - fail("Should not fail"); - done(); - } + const config = Config.get('test'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'test@parse.com'); + return user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('test@parse.com')) + .then(() => + config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ) + ) + .then(results => { + // validate that there is a token + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).not.toBeNull(); + user.set('email', 'test2@parse.com'); + return user.save(); + }) + .then(() => + config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ) + ) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toBeUndefined(); + done(); }); - }); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); }); }); -}) +}); diff --git a/spec/VerifyUserPassword.spec.js b/spec/VerifyUserPassword.spec.js new file mode 100644 index 0000000000..c40985671b --- /dev/null +++ b/spec/VerifyUserPassword.spec.js @@ -0,0 +1,635 @@ +'use strict'; + +const request = require('../lib/request'); +const MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); + +const verifyPassword = function(login, password, isEmail = false) { + const body = !isEmail + ? { username: login, password } + : { email: login, password }; + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: body, + }) + .then(res => res) + .catch(err => err); +}; + +const isAccountLockoutError = function(username, password, duration, waitTime) { + return new Promise((resolve, reject) => { + setTimeout(() => { + Parse.User.logIn(username, password) + .then(() => reject('login should have failed')) + .catch(err => { + if ( + err.message === + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + duration + + ' minute(s)' + ) { + resolve(); + } else { + reject(err); + } + }); + }, waitTime); + }); +}; + +describe('Verify User Password', () => { + it('fails to verify password when masterKey has locked out user', done => { + const user = new Parse.User(); + const ACL = new Parse.ACL(); + ACL.setPublicReadAccess(false); + ACL.setPublicWriteAccess(false); + user.setUsername('testuser'); + user.setPassword('mypass'); + user.setACL(ACL); + user + .signUp() + .then(() => { + return Parse.User.logIn('testuser', 'mypass'); + }) + .then(user => { + equal(user.get('username'), 'testuser'); + // Lock the user down + const ACL = new Parse.ACL(); + user.setACL(ACL); + return user.save(null, { useMasterKey: true }); + }) + .then(() => { + expect(user.getACL().getPublicReadAccess()).toBe(false); + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }); + }); + it('fails to verify password when username is not provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: '', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(400); + expect(err.text).toMatch( + '{"code":200,"error":"username/email is required."}' + ); + done(); + }); + }); + it('fails to verify password when email is not provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + email: '', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(400); + expect(err.text).toMatch( + '{"code":200,"error":"username/email is required."}' + ); + done(); + }); + }); + it('fails to verify password when username is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('', 'mypass'); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch( + '{"code":200,"error":"username/email is required."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('', 'mypass', true); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch( + '{"code":200,"error":"username/email is required."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when password is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', ''); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch( + '{"code":201,"error":"password is required."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when username matches but password does not match hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email matches but password does not match hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 'wrong password', true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof username does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword(123, 'mypass'); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof email does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword(123, 'mypass', true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof password does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 123, true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when username cannot be found REST API', done => { + verifyPassword('mytestuser', 'mypass') + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email cannot be found REST API', done => { + verifyPassword('my@user.com', 'mypass', true) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + '{"code":101,"error":"Invalid username/password."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when preventLoginWithUnverifiedEmail is set to true REST API', done => { + reconfigureServer({ + publicServerURL: 'http://localhost:8378/', + appName: 'emailVerify', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }) + .then(() => { + const user = new Parse.User(); + return user.save({ + username: 'unverified-user', + password: 'mypass', + email: 'unverified-email@user.com', + }); + }) + .then(() => { + return verifyPassword('unverified-email@user.com', 'mypass', true); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch( + '{"code":205,"error":"User email is not verified."}' + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('verify password lock account if failed verify password attempts are above threshold', done => { + reconfigureServer({ + appName: 'lockout threshold', + accountLockout: { + duration: 1, + threshold: 2, + }, + publicServerURL: 'http://localhost:8378/', + }) + .then(() => { + const user = new Parse.User(); + return user.save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return isAccountLockoutError('testuser', 'wrong password', 1, 1); + }) + .then(() => { + done(); + }) + .catch(err => { + fail( + 'lock account after failed login attempts test failed: ' + + JSON.stringify(err) + ); + done(); + }); + }); + it('succeed in verifying password when username and email are provided and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + email: 'my@user.com', + password: 'mypass', + }, + json: true, + }) + .then(res => res) + .catch(err => err); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(res, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual( + false + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('succeed in verifying password when username and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', 'mypass'); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(res, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual( + false + ); + done(); + }); + }); + it('succeed in verifying password when email and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 'mypass', true); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(res, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual( + false + ); + done(); + }); + }); + it('succeed to verify password when username and password provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + password: 'mypass', + }, + }); + }) + .then(response => { + const res = response.text; + expect(typeof res).toBe('string'); + const body = JSON.parse(res); + expect(typeof body['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(body, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(body, 'password')).toEqual( + false + ); + done(); + }); + }); + it('succeed to verify password when email and password provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + email: 'my@user.com', + password: 'mypass', + }, + }); + }) + .then(response => { + const res = response.text; + expect(typeof res).toBe('string'); + const body = JSON.parse(res); + expect(typeof body['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(body, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(body, 'password')).toEqual( + false + ); + done(); + }); + }); + it('succeed to verify password with username when user1 has username === user2 email REST API', done => { + const user1 = new Parse.User(); + user1 + .save({ + username: 'email@user.com', + password: 'mypass1', + email: '1@user.com', + }) + .then(() => { + const user2 = new Parse.User(); + return user2.save({ + username: 'user2', + password: 'mypass2', + email: 'email@user.com', + }); + }) + .then(() => { + return verifyPassword('email@user.com', 'mypass1'); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect( + Object.prototype.hasOwnProperty.call(res, 'sessionToken') + ).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual( + false + ); + done(); + }); + }); +}); diff --git a/spec/WinstonLoggerAdapter.spec.js b/spec/WinstonLoggerAdapter.spec.js index e677d0b831..c0eddf3083 100644 --- a/spec/WinstonLoggerAdapter.spec.js +++ b/spec/WinstonLoggerAdapter.spec.js @@ -1,62 +1,186 @@ 'use strict'; -var WinstonLoggerAdapter = require('../src/Adapters/Logger/WinstonLoggerAdapter').WinstonLoggerAdapter; -var request = require('request'); +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; +const request = require('../lib/request'); describe('info logs', () => { - - it("Verify INFO logs", (done) => { - var winstonLoggerAdapter = new WinstonLoggerAdapter(); - winstonLoggerAdapter.log('info', 'testing info logs', () => { - winstonLoggerAdapter.query({ + it('Verify INFO logs', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with 1234'); + winstonLoggerAdapter.query( + { from: new Date(Date.now() - 500), size: 100, - level: 'info' - }, (results) => { + level: 'info', + order: 'desc', + }, + results => { if (results.length == 0) { fail('The adapter should return non-empty results'); } else { - expect(results[0].message).toEqual('testing info logs'); + const log = results.find( + x => x.message === 'testing info logs with 1234' + ); + expect(log.level).toEqual('info'); } // Check the error log // Regression #2639 - winstonLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'error' - }, (results) => { - expect(results.length).toEqual(0); - done(); - }); - }); + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 200), + size: 100, + level: 'error', + }, + errors => { + const log = errors.find( + x => x.message === 'testing info logs with 1234' + ); + expect(log).toBeUndefined(); + done(); + } + ); + } + ); + }); + + it('info logs should interpolate string', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %s', 'replace'); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing info logs with replace' + ); + expect(log); + }); + + it('info logs should interpolate json', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %j', { hello: 'world' }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing info logs with {"hello":"world"}' + ); + expect(log); + }); + + it('info logs should interpolate number', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing info logs with 123' + ); + expect(log); }); }); describe('error logs', () => { - it("Verify ERROR logs", (done) => { - var winstonLoggerAdapter = new WinstonLoggerAdapter(); - winstonLoggerAdapter.log('error', 'testing error logs', () => { - winstonLoggerAdapter.query({ + it('Verify ERROR logs', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs'); + winstonLoggerAdapter.query( + { from: new Date(Date.now() - 500), size: 100, - level: 'error' - }, (results) => { - if(results.length == 0) { + level: 'error', + }, + results => { + if (results.length == 0) { fail('The adapter should return non-empty results'); done(); - } - else { + } else { expect(results[0].message).toEqual('testing error logs'); done(); } - }); + } + ); + }); + + it('Should filter on query', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs'); + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + }, + results => { + expect(results.filter(e => e.level !== 'error').length).toBe(0); + done(); + } + ); + }); + + it('error logs should interpolate string', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %s', 'replace'); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing error logs with replace' + ); + expect(log); + }); + + it('error logs should interpolate json', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %j', { hello: 'world' }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + order: 'desc', }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing error logs with {"hello":"world"}' + ); + expect(log); + }); + + it('error logs should interpolate number', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing error logs with 123' + ); + expect(log); }); }); describe('verbose logs', () => { - it("mask sensitive information in _User class", (done) => { + it('mask sensitive information in _User class', done => { reconfigureServer({ verbose: true }) .then(() => createTestUser()) .then(() => { @@ -64,36 +188,94 @@ describe('verbose logs', () => { return winstonLoggerAdapter.query({ from: new Date(Date.now() - 500), size: 100, - level: 'verbose' + level: 'verbose', }); - }).then((results) => { + }) + .then(results => { const logString = JSON.stringify(results); expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); expect(logString.match(/moon-y/g)).toBe(null); - var headers = { + const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/login?username=test&password=moon-y' - }, () => { + url: 'http://localhost:8378/1/login?username=test&password=moon-y', + }).then(() => { const winstonLoggerAdapter = new WinstonLoggerAdapter(); - return winstonLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'verbose' - }).then((results) => { - const logString = JSON.stringify(results); - expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); - expect(logString.match(/moon-y/g)).toBe(null); - done(); - }); + return winstonLoggerAdapter + .query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + }) + .then(results => { + const logString = JSON.stringify(results); + expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); + expect(logString.match(/moon-y/g)).toBe(null); + done(); + }); }); - }).catch((err) => { + }) + .catch(err => { fail(JSON.stringify(err)); done(); - }) + }); + }); + + it('verbose logs should interpolate string', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log( + 'verbose', + 'testing verbose logs with %s', + 'replace' + ); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing verbose logs with replace' + ); + expect(log); + }); + + it('verbose logs should interpolate json', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %j', { hello: 'world' }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing verbose logs with {"hello":"world"}' + ); + expect(log); + }); + + it('verbose logs should interpolate number', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find( + x => x.message === 'testing verbose logs with 123' + ); + expect(log); }); }); diff --git a/spec/batch.spec.js b/spec/batch.spec.js index 5d30caab46..c225be320e 100644 --- a/spec/batch.spec.js +++ b/spec/batch.spec.js @@ -1,4 +1,6 @@ -var batch = require('../src/batch'); +const batch = require('../lib/batch'); +const request = require('../lib/request'); +const TestUtils = require('../lib/TestUtils'); const originalURL = '/parse/batch'; const serverURL = 'http://localhost:1234/parse'; @@ -7,38 +9,571 @@ const serverURLNaked = 'http://localhost:1234/'; const publicServerURL = 'http://domain.com/parse'; const publicServerURLNaked = 'http://domain.com/'; +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', +}; + describe('batch', () => { it('should return the proper url', () => { - const internalURL = batch.makeBatchRoutingPathFunction(originalURL)('/parse/classes/Object'); + const internalURL = batch.makeBatchRoutingPathFunction(originalURL)( + '/parse/classes/Object' + ); expect(internalURL).toEqual('/classes/Object'); }); it('should return the proper url same public/local endpoint', () => { const originalURL = '/parse/batch'; - const internalURL = batch.makeBatchRoutingPathFunction(originalURL, serverURL, publicServerURL)('/parse/classes/Object'); + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURL + )('/parse/classes/Object'); expect(internalURL).toEqual('/classes/Object'); }); it('should return the proper url with different public/local mount', () => { const originalURL = '/parse/batch'; - const internalURL = batch.makeBatchRoutingPathFunction(originalURL, serverURL1, publicServerURL)('/parse/classes/Object'); + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL1, + publicServerURL + )('/parse/classes/Object'); expect(internalURL).toEqual('/classes/Object'); }); it('should return the proper url with naked public', () => { const originalURL = '/batch'; - const internalURL = batch.makeBatchRoutingPathFunction(originalURL, serverURL, publicServerURLNaked)('/classes/Object'); + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURLNaked + )('/classes/Object'); expect(internalURL).toEqual('/classes/Object'); }); it('should return the proper url with naked local', () => { const originalURL = '/parse/batch'; - const internalURL = batch.makeBatchRoutingPathFunction(originalURL, serverURLNaked, publicServerURL)('/parse/classes/Object'); + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURLNaked, + publicServerURL + )('/parse/classes/Object'); expect(internalURL).toEqual('/classes/Object'); }); + + it('should handle a batch request without transaction', done => { + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + }), + }).then(response => { + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(databaseAdapter.createObject.calls.count()).toBe(2); + expect(databaseAdapter.createObject.calls.argsFor(0)[3]).toEqual(null); + expect(databaseAdapter.createObject.calls.argsFor(1)[3]).toEqual(null); + expect(results.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + done(); + }); + }); + }); + + it('should handle a batch request with transaction = false', done => { + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: false, + }), + }).then(response => { + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(databaseAdapter.createObject.calls.count()).toBe(2); + expect(databaseAdapter.createObject.calls.argsFor(0)[3]).toEqual(null); + expect(databaseAdapter.createObject.calls.argsFor(1)[3]).toEqual(null); + expect(results.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + done(); + }); + }); + }); + + if ( + (process.env.MONGODB_VERSION === '4.0.4' && + process.env.MONGODB_TOPOLOGY === 'replicaset' && + process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger') || + process.env.PARSE_SERVER_TEST_DB === 'postgres' + ) { + describe('transactions', () => { + beforeAll(async () => { + if ( + process.env.MONGODB_VERSION === '4.0.4' && + process.env.MONGODB_TOPOLOGY === 'replicaset' && + process.env.MONGODB_STORAGE_ENGINE === 'wiredTiger' + ) { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase?replicaSet=replicaset', + }); + } + }); + + beforeEach(async () => { + await TestUtils.destroyAllDataPermanently(true); + }); + + it('should handle a batch request with transaction = true', done => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + myObject + .save() + .then(() => { + return myObject.destroy(); + }) + .then(() => { + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }), + }).then(response => { + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(databaseAdapter.createObject.calls.count()).toBe(2); + expect(databaseAdapter.createObject.calls.argsFor(0)[3]).toBe( + databaseAdapter.createObject.calls.argsFor(1)[3] + ); + expect(results.map(result => result.get('key')).sort()).toEqual( + ['value1', 'value2'] + ); + done(); + }); + }); + }); + }); + + it('should not save anything when one operation fails in a transaction', done => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + myObject + .save() + .then(() => { + return myObject.destroy(); + }) + .then(() => { + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + ], + transaction: true, + }), + }).catch(error => { + expect(error.data).toBeDefined(); + const query = new Parse.Query('MyObject'); + query.find().then(results => { + expect(results.length).toBe(0); + done(); + }); + }); + }); + }); + + it('should generate separate session for each call', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save(); + await myObject.destroy(); + + const myObject2 = new Parse.Object('MyObject2'); // This is important because transaction only works on pre-existing collections + await myObject2.save(); + await myObject2.destroy(); + + spyOn(databaseAdapter, 'createObject').and.callThrough(); + + let myObjectCalls = 0; + Parse.Cloud.beforeSave('MyObject', async () => { + myObjectCalls++; + if (myObjectCalls === 2) { + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + ], + transaction: true, + }), + }); + fail('should fail'); + } catch (e) { + expect(e).toBeDefined(); + } + } + }); + + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }), + }); + + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value2' }, + }, + ], + }), + }); + + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + + const query2 = new Parse.Query('MyObject2'); + const results2 = await query2.find(); + expect(results2.length).toEqual(0); + + const query3 = new Parse.Query('MyObject3'); + const results3 = await query3.find(); + expect(results3.map(result => result.get('key')).sort()).toEqual([ + 'value1', + 'value2', + ]); + + expect(databaseAdapter.createObject.calls.count()).toBe(13); + let transactionalSession; + let transactionalSession2; + let myObjectDBCalls = 0; + let myObject2DBCalls = 0; + let myObject3DBCalls = 0; + for (let i = 0; i < 13; i++) { + const args = databaseAdapter.createObject.calls.argsFor(i); + switch (args[0]) { + case 'MyObject': + myObjectDBCalls++; + if (!transactionalSession) { + transactionalSession = args[3]; + } else { + expect(transactionalSession).toBe(args[3]); + } + if (transactionalSession2) { + expect(transactionalSession2).not.toBe(args[3]); + } + break; + case 'MyObject2': + myObject2DBCalls++; + if (!transactionalSession2) { + transactionalSession2 = args[3]; + } else { + expect(transactionalSession2).toBe(args[3]); + } + if (transactionalSession) { + expect(transactionalSession).not.toBe(args[3]); + } + break; + case 'MyObject3': + myObject3DBCalls++; + expect(args[3]).toEqual(null); + break; + } + } + expect(myObjectDBCalls).toEqual(2); + expect(myObject2DBCalls).toEqual(9); + expect(myObject3DBCalls).toEqual(2); + }); + }); + } }); diff --git a/spec/cloud/cloudCodeAbsoluteFile.js b/spec/cloud/cloudCodeAbsoluteFile.js index f5fcf2b856..a62b4fcc24 100644 --- a/spec/cloud/cloudCodeAbsoluteFile.js +++ b/spec/cloud/cloudCodeAbsoluteFile.js @@ -1,3 +1,3 @@ -Parse.Cloud.define('cloudCodeInFile', (req, res) => { - res.success('It is possible to define cloud code in a file.'); +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; }); diff --git a/spec/cloud/cloudCodeRelativeFile.js b/spec/cloud/cloudCodeRelativeFile.js index f5fcf2b856..a62b4fcc24 100644 --- a/spec/cloud/cloudCodeRelativeFile.js +++ b/spec/cloud/cloudCodeRelativeFile.js @@ -1,3 +1,3 @@ -Parse.Cloud.define('cloudCodeInFile', (req, res) => { - res.success('It is possible to define cloud code in a file.'); +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; }); diff --git a/spec/configs/CLIConfigApps.json b/spec/configs/CLIConfigApps.json index 65ca875603..dc4a7cee74 100644 --- a/spec/configs/CLIConfigApps.json +++ b/spec/configs/CLIConfigApps.json @@ -1,9 +1,10 @@ { "apps": [ - { - "arg1": "my_app", - "arg2": 8888, - "arg3": "hello", - "arg4": "/1" - }] + { + "arg1": "my_app", + "arg2": 8888, + "arg3": "hello", + "arg4": "/1" + } + ] } diff --git a/spec/configs/CLIConfigFailTooManyApps.json b/spec/configs/CLIConfigFailTooManyApps.json index 381517d38c..4367019581 100644 --- a/spec/configs/CLIConfigFailTooManyApps.json +++ b/spec/configs/CLIConfigFailTooManyApps.json @@ -1,16 +1,16 @@ { "apps": [ - { - "arg1": "my_app", - "arg2": "99999", - "arg3": "hello", - "arg4": "/1" - }, - { - "arg1": "my_app2", - "arg2": "9999", - "arg3": "hello", - "arg4": "/1" - } + { + "arg1": "my_app", + "arg2": "99999", + "arg3": "hello", + "arg4": "/1" + }, + { + "arg1": "my_app2", + "arg2": "9999", + "arg3": "hello", + "arg4": "/1" + } ] } diff --git a/spec/cryptoUtils.spec.js b/spec/cryptoUtils.spec.js index b38c929777..a3b1c69718 100644 --- a/spec/cryptoUtils.spec.js +++ b/spec/cryptoUtils.spec.js @@ -1,9 +1,9 @@ -var cryptoUtils = require('../src/cryptoUtils'); +const cryptoUtils = require('../lib/cryptoUtils'); function givesUniqueResults(fn, iterations) { - var results = {}; - for (var i = 0; i < iterations; i++) { - var s = fn(); + const results = {}; + for (let i = 0; i < iterations; i++) { + const s = fn(); if (results[s]) { return false; } @@ -27,7 +27,9 @@ describe('randomString', () => { }); it('returns unique results', () => { - expect(givesUniqueResults(() => cryptoUtils.randomString(10), 100)).toBe(true); + expect(givesUniqueResults(() => cryptoUtils.randomString(10), 100)).toBe( + true + ); }); }); @@ -50,7 +52,9 @@ describe('randomHexString', () => { }); it('returns unique results', () => { - expect(givesUniqueResults(() => cryptoUtils.randomHexString(20), 100)).toBe(true); + expect(givesUniqueResults(() => cryptoUtils.randomHexString(20), 100)).toBe( + true + ); }); }); diff --git a/spec/defaultGraphQLTypes.spec.js b/spec/defaultGraphQLTypes.spec.js new file mode 100644 index 0000000000..de968ccf34 --- /dev/null +++ b/spec/defaultGraphQLTypes.spec.js @@ -0,0 +1,715 @@ +const { Kind } = require('graphql'); +const { + TypeValidationError, + parseStringValue, + parseIntValue, + parseFloatValue, + parseBooleanValue, + parseDateIsoValue, + parseValue, + parseListValues, + parseObjectFields, + BYTES, + DATE, + FILE, +} = require('../lib/GraphQL/loaders/defaultGraphQLTypes'); + +function createValue(kind, value, values, fields) { + return { + kind, + value, + values, + fields, + }; +} + +function createObjectField(name, value) { + return { + name: { + value: name, + }, + value, + }; +} + +describe('defaultGraphQLTypes', () => { + describe('TypeValidationError', () => { + it('should be an error with specific message', () => { + const typeValidationError = new TypeValidationError( + 'somevalue', + 'sometype' + ); + expect(typeValidationError).toEqual(jasmine.any(Error)); + expect(typeValidationError.message).toEqual( + 'somevalue is not a valid sometype' + ); + }); + }); + + describe('parseStringValue', () => { + it('should return itself if a string', () => { + const myString = 'myString'; + expect(parseStringValue(myString)).toBe(myString); + }); + + it('should fail if not a string', () => { + expect(() => parseStringValue()).toThrow( + jasmine.stringMatching('is not a valid String') + ); + expect(() => parseStringValue({})).toThrow( + jasmine.stringMatching('is not a valid String') + ); + expect(() => parseStringValue([])).toThrow( + jasmine.stringMatching('is not a valid String') + ); + expect(() => parseStringValue(123)).toThrow( + jasmine.stringMatching('is not a valid String') + ); + }); + }); + + describe('parseIntValue', () => { + it('should parse to number if a string', () => { + const myString = '123'; + expect(parseIntValue(myString)).toBe(123); + }); + + it('should fail if not a string', () => { + expect(() => parseIntValue()).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + expect(() => parseIntValue({})).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + expect(() => parseIntValue([])).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + expect(() => parseIntValue(123)).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + }); + + it('should fail if not an integer string', () => { + expect(() => parseIntValue('a123')).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + expect(() => parseIntValue('123.4')).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + }); + }); + + describe('parseFloatValue', () => { + it('should parse to number if a string', () => { + expect(parseFloatValue('123')).toBe(123); + expect(parseFloatValue('123.4')).toBe(123.4); + }); + + it('should fail if not a string', () => { + expect(() => parseFloatValue()).toThrow( + jasmine.stringMatching('is not a valid Float') + ); + expect(() => parseFloatValue({})).toThrow( + jasmine.stringMatching('is not a valid Float') + ); + expect(() => parseFloatValue([])).toThrow( + jasmine.stringMatching('is not a valid Float') + ); + }); + + it('should fail if not a float string', () => { + expect(() => parseIntValue('a123')).toThrow( + jasmine.stringMatching('is not a valid Int') + ); + }); + }); + + describe('parseBooleanValue', () => { + it('should return itself if a boolean', () => { + let myBoolean = true; + expect(parseBooleanValue(myBoolean)).toBe(myBoolean); + myBoolean = false; + expect(parseBooleanValue(myBoolean)).toBe(myBoolean); + }); + + it('should fail if not a boolean', () => { + expect(() => parseBooleanValue()).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + expect(() => parseBooleanValue({})).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + expect(() => parseBooleanValue([])).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + expect(() => parseBooleanValue(123)).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + expect(() => parseBooleanValue('true')).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + }); + }); + + describe('parseDateValue', () => { + it('should parse to date if a string', () => { + const myDateString = '2019-05-09T23:12:00.000Z'; + const myDate = new Date(Date.UTC(2019, 4, 9, 23, 12, 0, 0)); + expect(parseDateIsoValue(myDateString)).toEqual(myDate); + }); + + it('should fail if not a string', () => { + expect(() => parseDateIsoValue()).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => parseDateIsoValue({})).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => parseDateIsoValue([])).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => parseDateIsoValue(123)).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + + it('should fail if not a date string', () => { + expect(() => parseDateIsoValue('not a date')).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + }); + + describe('parseValue', () => { + const someString = createValue(Kind.STRING, 'somestring'); + const someInt = createValue(Kind.INT, '123'); + const someFloat = createValue(Kind.FLOAT, '123.4'); + const someBoolean = createValue(Kind.BOOLEAN, true); + const someOther = createValue(undefined, new Object()); + const someObject = createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + createObjectField('someInt', someInt), + createObjectField('someFloat', someFloat), + createObjectField('someBoolean', someBoolean), + createObjectField('someOther', someOther), + createObjectField( + 'someList', + createValue(Kind.LIST, undefined, [ + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + ]), + ]) + ), + createObjectField( + 'someObject', + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + ]) + ), + ]); + const someList = createValue(Kind.LIST, undefined, [ + someString, + someInt, + someFloat, + someBoolean, + someObject, + someOther, + createValue(Kind.LIST, undefined, [ + someString, + someInt, + someFloat, + someBoolean, + someObject, + someOther, + ]), + ]); + + it('should parse string', () => { + expect(parseValue(someString)).toEqual('somestring'); + }); + + it('should parse int', () => { + expect(parseValue(someInt)).toEqual(123); + }); + + it('should parse float', () => { + expect(parseValue(someFloat)).toEqual(123.4); + }); + + it('should parse boolean', () => { + expect(parseValue(someBoolean)).toEqual(true); + }); + + it('should parse list', () => { + expect(parseValue(someList)).toEqual([ + 'somestring', + 123, + 123.4, + true, + { + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }, + {}, + [ + 'somestring', + 123, + 123.4, + true, + { + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }, + {}, + ], + ]); + }); + + it('should parse object', () => { + expect(parseValue(someObject)).toEqual({ + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }); + }); + + it('should return value otherwise', () => { + expect(parseValue(someOther)).toEqual(new Object()); + }); + }); + + describe('parseListValues', () => { + it('should parse to list if an array', () => { + expect( + parseListValues([ + { kind: Kind.STRING, value: 'someString' }, + { kind: Kind.INT, value: '123' }, + ]) + ).toEqual(['someString', 123]); + }); + + it('should fail if not an array', () => { + expect(() => parseListValues()).toThrow( + jasmine.stringMatching('is not a valid List') + ); + expect(() => parseListValues({})).toThrow( + jasmine.stringMatching('is not a valid List') + ); + expect(() => parseListValues('some string')).toThrow( + jasmine.stringMatching('is not a valid List') + ); + expect(() => parseListValues(123)).toThrow( + jasmine.stringMatching('is not a valid List') + ); + }); + }); + + describe('parseObjectFields', () => { + it('should parse to list if an array', () => { + expect( + parseObjectFields([ + { + name: { value: 'someString' }, + value: { kind: Kind.STRING, value: 'someString' }, + }, + { + name: { value: 'someInt' }, + value: { kind: Kind.INT, value: '123' }, + }, + ]) + ).toEqual({ + someString: 'someString', + someInt: 123, + }); + }); + + it('should fail if not an array', () => { + expect(() => parseObjectFields()).toThrow( + jasmine.stringMatching('is not a valid Object') + ); + expect(() => parseObjectFields({})).toThrow( + jasmine.stringMatching('is not a valid Object') + ); + expect(() => parseObjectFields('some string')).toThrow( + jasmine.stringMatching('is not a valid Object') + ); + expect(() => parseObjectFields(123)).toThrow( + jasmine.stringMatching('is not a valid Object') + ); + }); + }); + + describe('Date', () => { + describe('parse literal', () => { + const { parseLiteral } = DATE; + + it('should parse to date if string', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect(parseLiteral(createValue(Kind.STRING, date))).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should parse to date if object', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Date' }), + createObjectField('iso', { value: date, kind: Kind.STRING }), + ]) + ) + ).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('iso', { value: '2019-05-09T23:12:00.000Z' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseLiteral([])).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => parseLiteral(123)).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + }); + + describe('parse value', () => { + const { parseValue } = DATE; + + it('should parse string value', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect(parseValue(date)).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should parse object value', () => { + const input = { + __type: 'Date', + iso: new Date('2019-05-09T23:12:00.000Z'), + }; + expect(parseValue(input)).toEqual(input); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseValue({})).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => + parseValue({ + __type: 'Foo', + iso: '2019-05-09T23:12:00.000Z', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => + parseValue({ + __type: 'Date', + iso: 'foo', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseValue([])).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => parseValue(123)).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + }); + + describe('serialize date type', () => { + const { serialize } = DATE; + + it('should do nothing if string', () => { + const str = '2019-05-09T23:12:00.000Z'; + expect(serialize(str)).toBe(str); + }); + + it('should serialize date', () => { + const date = new Date(); + expect(serialize(date)).toBe(date.toUTCString()); + }); + + it('should return iso value if object', () => { + const iso = '2019-05-09T23:12:00.000Z'; + const date = { + __type: 'Date', + iso, + }; + expect(serialize(date)).toEqual(iso); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => + serialize({ + __type: 'Foo', + iso: '2019-05-09T23:12:00.000Z', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => serialize([])).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + expect(() => serialize(123)).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + }); + }); + + describe('Bytes', () => { + describe('parse literal', () => { + const { parseLiteral } = BYTES; + + it('should parse to bytes if string', () => { + expect(parseLiteral(createValue(Kind.STRING, 'bytesContent'))).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should parse to bytes if object', () => { + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Bytes' }), + createObjectField('base64', { value: 'bytesContent' }), + ]) + ) + ).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('base64', { value: 'bytesContent' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseLiteral([])).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => parseLiteral(123)).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + }); + }); + + describe('parse value', () => { + const { parseValue } = BYTES; + + it('should parse string value', () => { + expect(parseValue('bytesContent')).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should parse object value', () => { + const input = { + __type: 'Bytes', + base64: 'bytesContent', + }; + expect(parseValue(input)).toEqual(input); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseValue({})).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => + parseValue({ + __type: 'Foo', + base64: 'bytesContent', + }) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseValue([])).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => parseValue(123)).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + }); + }); + + describe('serialize bytes type', () => { + const { serialize } = BYTES; + + it('should do nothing if string', () => { + const str = 'foo'; + expect(serialize(str)).toBe(str); + }); + + it('should return base64 value if object', () => { + const base64Content = 'bytesContent'; + const bytes = { + __type: 'Bytes', + base64: base64Content, + }; + expect(serialize(bytes)).toEqual(base64Content); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => + serialize({ + __type: 'Foo', + base64: 'bytesContent', + }) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => serialize([])).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + expect(() => serialize(123)).toThrow( + jasmine.stringMatching('is not a valid Bytes') + ); + }); + }); + }); + + describe('File', () => { + describe('parse literal', () => { + const { parseLiteral } = FILE; + + it('should parse to file if string', () => { + expect(parseLiteral(createValue(Kind.STRING, 'parsefile'))).toEqual({ + __type: 'File', + name: 'parsefile', + }); + }); + + it('should parse to file if object', () => { + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'File' }), + createObjectField('name', { value: 'parsefile' }), + createObjectField('url', { value: 'myurl' }), + ]) + ) + ).toEqual({ + __type: 'File', + name: 'parsefile', + url: 'myurl', + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow( + jasmine.stringMatching('is not a valid File') + ); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('name', { value: 'parsefile' }), + createObjectField('url', { value: 'myurl' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => parseLiteral([])).toThrow( + jasmine.stringMatching('is not a valid File') + ); + expect(() => parseLiteral(123)).toThrow( + jasmine.stringMatching('is not a valid File') + ); + }); + }); + + describe('serialize file type', () => { + const { serialize } = FILE; + + it('should do nothing if string', () => { + const str = 'foo'; + expect(serialize(str)).toBe(str); + }); + + it('should return file name if object', () => { + const fileName = 'parsefile'; + const file = { + __type: 'File', + name: fileName, + url: 'myurl', + }; + expect(serialize(file)).toEqual(fileName); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow( + jasmine.stringMatching('is not a valid File') + ); + expect(() => + serialize({ + __type: 'Foo', + name: 'parsefile', + url: 'myurl', + }) + ).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => serialize([])).toThrow( + jasmine.stringMatching('is not a valid File') + ); + expect(() => serialize(123)).toThrow( + jasmine.stringMatching('is not a valid File') + ); + }); + }); + }); +}); diff --git a/spec/dev.js b/spec/dev.js new file mode 100644 index 0000000000..31425dec40 --- /dev/null +++ b/spec/dev.js @@ -0,0 +1,98 @@ +const Config = require('../lib/Config'); +const Parse = require('parse/node'); + +const className = 'AnObject'; +const defaultRoleName = 'tester'; + +let schemaCache; + +module.exports = { + /* AnObject */ + className, + schemaCache, + + /** + * Creates and returns new user. + * + * This method helps to avoid 'User already exists' when re-running/debugging a single test. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + createUser: async (username, password = 'password') => { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + await user.save(); + return user; + }, + + /** + * Logs the user in. + * + * If password not provided, default 'password' is used. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + logIn: async (userObject, password) => { + return await Parse.User.logIn( + userObject.getUsername(), + password || 'password' + ); + }, + + /** + * Sets up Class-Level Permissions for 'AnObject' class. + * @param clp {ClassLevelPermissions} + */ + updateCLP: async (clp, targetClass = className) => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(targetClass, {}, clp); + }, + + /** + * Creates and returns role. Adds user(s) if provided. + * + * This method helps to avoid errors when re-running/debugging a single test. + * + * @param {Parse.User|Parse.User[]} [users] - user or array of users to be related with this role; + * @param {string?} [roleName] - uses this name for role if provided. Generates from datetime if not set; + * @param {string?} [exactName] - sets exact name (no generated part added); + * @param {Parse.Role[]} [roles] - uses this name for role if provided. Generates from datetime if not set; + * @param {boolean} [read] - value for role's acl public read. Defaults to true; + * @param {boolean} [write] - value for role's acl public write. Defaults to true; + */ + createRole: async ({ + users = null, + exactName = defaultRoleName + Date.now(), + roleName = null, + roles = null, + read = true, + write = true, + }) => { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(read); + acl.setPublicWriteAccess(write); + + const role = new Parse.Object('_Role'); + role.setACL(acl); + + // generate name based on roleName or use exactName (if botth not provided name is generated) + const name = roleName ? roleName + Date.now() : exactName; + role.set('name', name); + + if (roles) { + role.relation('roles').add(roles); + } + + if (users) { + role.relation('users').add(users); + } + + await role.save({ useMasterKey: true }); + + return role; + }, +}; diff --git a/spec/features.spec.js b/spec/features.spec.js index c2a60ebd8e..23b43c0b2e 100644 --- a/spec/features.spec.js +++ b/spec/features.spec.js @@ -1,20 +1,41 @@ 'use strict'; -const request = require("request"); +const request = require('../lib/request'); describe('features', () => { - it('requires the master key to get features', done => { - request.get({ + it('should return the serverInfo', async () => { + const response = await request({ url: 'http://localhost:8378/1/serverInfo', json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); - done(); + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, }); + const data = response.data; + expect(data).toBeDefined(); + expect(data.features).toBeDefined(); + expect(data.parseServerVersion).toBeDefined(); + }); + + it('requires the master key to get features', async done => { + try { + await request({ + url: 'http://localhost:8378/1/serverInfo', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + done.fail( + 'The serverInfo request should be rejected without the master key' + ); + } catch (error) { + expect(error.status).toEqual(403); + expect(error.data.error).toEqual('unauthorized: master key is required'); + done(); + } }); }); diff --git a/spec/graphQLObjectsQueries.js b/spec/graphQLObjectsQueries.js new file mode 100644 index 0000000000..40bdca8b6d --- /dev/null +++ b/spec/graphQLObjectsQueries.js @@ -0,0 +1,158 @@ +const { offsetToCursor } = require('graphql-relay'); +const { + calculateSkipAndLimit, +} = require('../lib/GraphQL/helpers/objectsQueries'); + +describe('GraphQL objectsQueries', () => { + describe('calculateSkipAndLimit', () => { + it('should fail with invalid params', () => { + expect(() => calculateSkipAndLimit(-1)).toThrow( + jasmine.stringMatching('Skip should be a positive number') + ); + expect(() => calculateSkipAndLimit(1, -1)).toThrow( + jasmine.stringMatching('First should be a positive number') + ); + expect(() => calculateSkipAndLimit(1, 1, offsetToCursor(-1))).toThrow( + jasmine.stringMatching('After is not a valid curso') + ); + expect(() => calculateSkipAndLimit(1, 1, offsetToCursor(1), -1)).toThrow( + jasmine.stringMatching('Last should be a positive number') + ); + expect(() => + calculateSkipAndLimit(1, 1, offsetToCursor(1), 1, offsetToCursor(-1)) + ).toThrow(jasmine.stringMatching('Before is not a valid curso')); + }); + + it('should work only with skip', () => { + expect(calculateSkipAndLimit(10)).toEqual({ + skip: 10, + limit: undefined, + needToPreCount: false, + }); + }); + + it('should work only with after', () => { + expect( + calculateSkipAndLimit(undefined, undefined, offsetToCursor(9)) + ).toEqual({ + skip: 10, + limit: undefined, + needToPreCount: false, + }); + }); + + it('should work with limit and after', () => { + expect(calculateSkipAndLimit(10, undefined, offsetToCursor(9))).toEqual({ + skip: 20, + limit: undefined, + needToPreCount: false, + }); + }); + + it('first alone should set the limit', () => { + expect(calculateSkipAndLimit(10, 30, offsetToCursor(9))).toEqual({ + skip: 20, + limit: 30, + needToPreCount: false, + }); + }); + + it('if before cursor is less than skipped items, no objects will be returned', () => { + expect( + calculateSkipAndLimit( + 10, + 30, + offsetToCursor(9), + undefined, + offsetToCursor(5) + ) + ).toEqual({ + skip: 20, + limit: 0, + needToPreCount: false, + }); + }); + + it('if before cursor is greater than returned objects set by limit, nothing is changed', () => { + expect( + calculateSkipAndLimit( + 10, + 30, + offsetToCursor(9), + undefined, + offsetToCursor(100) + ) + ).toEqual({ + skip: 20, + limit: 30, + needToPreCount: false, + }); + }); + + it('if before cursor is less than returned objects set by limit, limit is adjusted', () => { + expect( + calculateSkipAndLimit( + 10, + 30, + offsetToCursor(9), + undefined, + offsetToCursor(40) + ) + ).toEqual({ + skip: 20, + limit: 20, + needToPreCount: false, + }); + }); + + it('last should work alone but requires pre count', () => { + expect( + calculateSkipAndLimit(undefined, undefined, undefined, 10) + ).toEqual({ + skip: undefined, + limit: 10, + needToPreCount: true, + }); + }); + + it('last should be adjusted to max limit', () => { + expect( + calculateSkipAndLimit(undefined, undefined, undefined, 10, undefined, 5) + ).toEqual({ + skip: undefined, + limit: 5, + needToPreCount: true, + }); + }); + + it('no objects will be returned if last is equal to 0', () => { + expect(calculateSkipAndLimit(undefined, undefined, undefined, 0)).toEqual( + { + skip: undefined, + limit: 0, + needToPreCount: false, + } + ); + }); + + it('nothing changes if last is bigger than the calculared limit', () => { + expect( + calculateSkipAndLimit(10, 30, offsetToCursor(9), 30, offsetToCursor(40)) + ).toEqual({ + skip: 20, + limit: 20, + needToPreCount: false, + }); + }); + + it('If last is small than limit, new limit is calculated', () => { + expect( + calculateSkipAndLimit(10, 30, offsetToCursor(9), 10, offsetToCursor(40)) + ).toEqual({ + skip: 30, + limit: 10, + needToPreCount: false, + }); + }); + }); +}); diff --git a/spec/helper.js b/spec/helper.js index 109f1b0716..16d25ba1b8 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -1,11 +1,7 @@ -"use strict" +'use strict'; // Sets up a Parse API server for testing. -const SpecReporter = require('jasmine-spec-reporter').SpecReporter; - -jasmine.DEFAULT_TIMEOUT_INTERVAL = process.env.PARSE_SERVER_TEST_TIMEOUT || 5000; - -jasmine.getEnv().clearReporters(); -jasmine.getEnv().addReporter(new SpecReporter()); +jasmine.DEFAULT_TIMEOUT_INTERVAL = + process.env.PARSE_SERVER_TEST_TIMEOUT || 5000; global.on_db = (db, callback, elseCallback) => { if (process.env.PARSE_SERVER_TEST_DB == db) { @@ -16,57 +12,60 @@ global.on_db = (db, callback, elseCallback) => { if (elseCallback) { return elseCallback(); } -} +}; if (global._babelPolyfill) { console.error('We should not use polyfilled tests'); process.exit(1); } - -var cache = require('../src/cache').default; -var ParseServer = require('../src/index').ParseServer; -var path = require('path'); -var TestUtils = require('../src/TestUtils'); -var MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const GridStoreAdapter = require('../src/Adapters/Files/GridStoreAdapter').GridStoreAdapter; -const FSAdapter = require('parse-server-fs-adapter'); -const PostgresStorageAdapter = require('../src/Adapters/Storage/Postgres/PostgresStorageAdapter'); -const RedisCacheAdapter = require('../src/Adapters/Cache/RedisCacheAdapter').default; - -const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; -const postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +process.noDeprecation = true; + +const cache = require('../lib/cache').default; +const ParseServer = require('../lib/index').ParseServer; +const path = require('path'); +const TestUtils = require('../lib/TestUtils'); +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const FSAdapter = require('@parse/fs-files-adapter'); +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter') + .default; + +const mongoURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const postgresURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; let databaseAdapter; // need to bind for mocking mocha -let startDB = () => {}; -let stopDB = () => {}; - if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { databaseAdapter = new PostgresStorageAdapter({ uri: process.env.PARSE_SERVER_TEST_DATABASE_URI || postgresURI, collectionPrefix: 'test_', }); } else { - startDB = require('mongodb-runner/mocha/before').bind({ - timeout: () => {}, - slow: () => {} - }) - stopDB = require('mongodb-runner/mocha/after'); databaseAdapter = new MongoStorageAdapter({ uri: mongoURI, collectionPrefix: 'test_', }); } -var port = 8378; +const port = 8378; let filesAdapter; -on_db('mongo', () => { - filesAdapter = new GridStoreAdapter(mongoURI); -}, () => { - filesAdapter = new FSAdapter(); -}); +on_db( + 'mongo', + () => { + filesAdapter = new GridFSBucketAdapter(mongoURI); + }, + () => { + filesAdapter = new FSAdapter(); + } +); let logLevel; let silent = true; @@ -79,7 +78,7 @@ if (process.env.PARSE_SERVER_LOG_LEVEL) { logLevel = process.env.PARSE_SERVER_LOG_LEVEL; } // Default server configuration for tests. -var defaultConfiguration = { +const defaultConfiguration = { filesAdapter, serverURL: 'http://localhost:' + port + '/1', databaseAdapter, @@ -98,15 +97,17 @@ var defaultConfiguration = { android: { senderId: 'yolo', apiKey: 'yolo', - } + }, }, - auth: { // Override the facebook provider + auth: { + // Override the facebook provider + custom: mockCustom(), facebook: mockFacebook(), myoauth: { - module: path.resolve(__dirname, "myoauth") // relative path as it's run from src + module: path.resolve(__dirname, 'myoauth'), // relative path as it's run from src }, - shortLivedAuth: mockShortLivedAuth() - } + shortLivedAuth: mockShortLivedAuth(), + }, }; if (process.env.PARSE_SERVER_TEST_CACHE === 'redis') { @@ -114,9 +115,8 @@ if (process.env.PARSE_SERVER_TEST_CACHE === 'redis') { } const openConnections = {}; - // Set up a default API server for testing with default configuration. -var server; +let server; // Allows testing specific configurations of Parse Server const reconfigureServer = changedConfiguration => { @@ -128,15 +128,27 @@ const reconfigureServer = changedConfiguration => { }); } try { - const newConfiguration = Object.assign({}, defaultConfiguration, changedConfiguration, { - __indexBuildCompletionCallbackForTests: indexBuildPromise => indexBuildPromise.then(resolve, reject), - mountPath: '/1', - port, - }); + let parseServer = undefined; + const newConfiguration = Object.assign( + {}, + defaultConfiguration, + changedConfiguration, + { + serverStartComplete: error => { + if (error) { + reject(error); + } else { + resolve(parseServer); + } + }, + mountPath: '/1', + port, + } + ); cache.clear(); - const parseServer = ParseServer.start(newConfiguration); + parseServer = ParseServer.start(newConfiguration); parseServer.app.use(require('./testing-routes').router); - parseServer.expressApp.use('/1', (err) => { + parseServer.expressApp.use('/1', err => { console.error(err); fail('should not call next'); }); @@ -144,27 +156,20 @@ const reconfigureServer = changedConfiguration => { server.on('connection', connection => { const key = `${connection.remoteAddress}:${connection.remotePort}`; openConnections[key] = connection; - connection.on('close', () => { delete openConnections[key] }); + connection.on('close', () => { + delete openConnections[key]; + }); }); - } catch(error) { + } catch (error) { reject(error); } }); -} +}; // Set up a Parse client to talk to our test API server -var Parse = require('parse/node'); +const Parse = require('parse/node'); Parse.serverURL = 'http://localhost:' + port + '/1'; -// This is needed because we ported a bunch of tests from the non-A+ way. -// TODO: update tests to work in an A+ way -Parse.Promise.disableAPlusCompliant(); - -// 10 minutes timeout -beforeAll(startDB, 10 * 60 * 1000); - -afterAll(stopDB); - beforeEach(done => { try { Parse.User.enableUnsafeCurrentUser(); @@ -173,10 +178,13 @@ beforeEach(done => { throw error; } } - TestUtils.destroyAllDataPermanently() + TestUtils.destroyAllDataPermanently(true) .catch(error => { - // For tests that connect to their own mongo, there won't be any data to delete. - if (error.message === 'ns not found' || error.message.startsWith('connect ECONNREFUSED')) { + // For tests that connect to their own mongo, there won't be any data to delete. + if ( + error.message === 'ns not found' || + error.message.startsWith('connect ECONNREFUSED') + ) { return; } else { fail(error); @@ -188,72 +196,82 @@ beforeEach(done => { Parse.initialize('test', 'test', 'test'); Parse.serverURL = 'http://localhost:' + port + '/1'; done(); - }).catch(done.fail); + }) + .catch(done.fail); }); afterEach(function(done) { const afterLogOut = () => { if (Object.keys(openConnections).length > 0) { - fail('There were open connections to the server left after the test finished'); + fail( + 'There were open connections to the server left after the test finished' + ); } - on_db('postgres', () => { - TestUtils.destroyAllDataPermanently().then(done, done); - }, done); + TestUtils.destroyAllDataPermanently(true).then(done, done); }; Parse.Cloud._removeAllHooks(); - databaseAdapter.getAllClasses() + databaseAdapter + .getAllClasses() .then(allSchemas => { - allSchemas.forEach((schema) => { - var className = schema.className; - expect(className).toEqual({ asymmetricMatch: className => { - if (!className.startsWith('_')) { - return true; - } else { - // Other system classes will break Parse.com, so make sure that we don't save anything to _SCHEMA that will - // break it. - return ['_User', '_Installation', '_Role', '_Session', '_Product', '_Audience'].indexOf(className) >= 0; - } - }}); + allSchemas.forEach(schema => { + const className = schema.className; + expect(className).toEqual({ + asymmetricMatch: className => { + if (!className.startsWith('_')) { + return true; + } else { + // Other system classes will break Parse.com, so make sure that we don't save anything to _SCHEMA that will + // break it. + return ( + [ + '_User', + '_Installation', + '_Role', + '_Session', + '_Product', + '_Audience', + ].indexOf(className) >= 0 + ); + } + }, + }); }); }) .then(() => Parse.User.logOut()) - .then(afterLogOut, afterLogOut) + .then( + () => {}, + () => {} + ) // swallow errors + .then(() => { + // Connection close events are not immediate on node 10+... wait a bit + return new Promise(resolve => { + setTimeout(resolve, 0); + }); + }) + .then(afterLogOut); }); -var TestObject = Parse.Object.extend({ - className: "TestObject" +const TestObject = Parse.Object.extend({ + className: 'TestObject', }); -var Item = Parse.Object.extend({ - className: "Item" +const Item = Parse.Object.extend({ + className: 'Item', }); -var Container = Parse.Object.extend({ - className: "Container" +const Container = Parse.Object.extend({ + className: 'Container', }); // Convenience method to create a new TestObject with a callback function create(options, callback) { - var t = new TestObject(options); - t.save(null, { success: callback }); + const t = new TestObject(options); + return t.save().then(callback); } -function createTestUser(success, error) { - var user = new Parse.User(); +function createTestUser() { + const user = new Parse.User(); user.set('username', 'test'); user.set('password', 'moon-y'); - var promise = user.signUp(); - if (success || error) { - promise.then(function(user) { - if (success) { - success(user); - } - }, function(err) { - if (error) { - error(err); - } - }); - } else { - return promise; - } + return user.signUp(); } // Shims for compatibility with the old qunit tests. @@ -269,35 +287,6 @@ function strictEqual(a, b, message) { function notEqual(a, b, message) { expect(a).not.toEqual(b, message); } -function expectSuccess(params, done) { - return { - success: params.success, - error: function() { - fail('failure happened in expectSuccess'); - done ? done() : null; - }, - } -} -function expectError(errorCode, callback) { - return { - success: function(result) { - console.log('got result', result); - fail('expected error but got success'); - }, - error: function(obj, e) { - // Some methods provide 2 parameters. - e = e || obj; - if (!e) { - fail('expected a specific error but got a blank error'); - return; - } - expect(e.code).toEqual(errorCode, e.message); - if (callback) { - callback(e); - } - }, - } -} // Because node doesn't have Parse._.contains function arrayContains(arr, item) { @@ -312,8 +301,8 @@ function normalize(obj) { if (obj instanceof Array) { return '[' + obj.map(normalize).join(', ') + ']'; } - var answer = '{'; - for (var key of Object.keys(obj).sort()) { + let answer = '{'; + for (const key of Object.keys(obj).sort()) { answer += key + ': '; answer += normalize(obj[key]); answer += ', '; @@ -328,15 +317,33 @@ function jequal(o1, o2) { } function range(n) { - var answer = []; - for (var i = 0; i < n; i++) { + const answer = []; + for (let i = 0; i < n; i++) { answer.push(i); } return answer; } +function mockCustomAuthenticator(id, password) { + const custom = {}; + custom.validateAuthData = function(authData) { + if (authData.id === id && authData.password.startsWith(password)) { + return Promise.resolve(); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'not validated'); + }; + custom.validateAppId = function() { + return Promise.resolve(); + }; + return custom; +} + +function mockCustom() { + return mockCustomAuthenticator('fastrde', 'password'); +} + function mockFacebookAuthenticator(id, token) { - var facebook = {}; + const facebook = {}; facebook.validateAuthData = function(authData) { if (authData.id === id && authData.access_token.startsWith(token)) { return Promise.resolve(); @@ -363,7 +370,7 @@ function mockShortLivedAuth() { let accessToken; auth.setValidAccessToken = function(validAccessToken) { accessToken = validAccessToken; - } + }; auth.validateAuthData = function(authData) { if (authData.access_token == accessToken) { return Promise.resolve(); @@ -377,7 +384,6 @@ function mockShortLivedAuth() { return auth; } - // This is polluting, but, it makes it way easier to directly port old tests. global.Parse = Parse; global.TestObject = TestObject; @@ -389,17 +395,17 @@ global.ok = ok; global.equal = equal; global.strictEqual = strictEqual; global.notEqual = notEqual; -global.expectSuccess = expectSuccess; -global.expectError = expectError; global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.reconfigureServer = reconfigureServer; global.defaultConfiguration = defaultConfiguration; +global.mockCustomAuthenticator = mockCustomAuthenticator; global.mockFacebookAuthenticator = mockFacebookAuthenticator; +global.databaseAdapter = databaseAdapter; global.jfail = function(err) { fail(JSON.stringify(err)); -} +}; global.it_exclude_dbs = excluded => { if (excluded.indexOf(process.env.PARSE_SERVER_TEST_DB) >= 0) { @@ -407,10 +413,13 @@ global.it_exclude_dbs = excluded => { } else { return it; } -} +}; global.it_only_db = db => { - if (process.env.PARSE_SERVER_TEST_DB === db) { + if ( + process.env.PARSE_SERVER_TEST_DB === db || + (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') + ) { return it; } else { return xit; @@ -423,7 +432,7 @@ global.fit_exclude_dbs = excluded => { } else { return fit; } -} +}; global.describe_only_db = db => { if (process.env.PARSE_SERVER_TEST_DB == db) { @@ -431,11 +440,11 @@ global.describe_only_db = db => { } else if (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') { return describe; } else { - return () => {}; + return xdescribe; } -} +}; -global.describe_only = (validator) =>{ +global.describe_only = validator => { if (validator()) { return describe; } else { @@ -443,20 +452,19 @@ global.describe_only = (validator) =>{ } }; - -var libraryCache = {}; +const libraryCache = {}; jasmine.mockLibrary = function(library, name, mock) { - var original = require(library)[name]; + const original = require(library)[name]; if (!libraryCache[library]) { libraryCache[library] = {}; } require(library)[name] = mock; libraryCache[library][name] = original; -} +}; jasmine.restoreLibrary = function(library, name) { if (!libraryCache[library] || !libraryCache[library][name]) { throw 'Can not find library ' + library + ' ' + name; } require(library)[name] = libraryCache[library][name]; -} +}; diff --git a/spec/index.spec.js b/spec/index.spec.js index c0fa872dd6..8d02e37213 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,12 +1,13 @@ -"use strict" -var request = require('request'); -var parseServerPackage = require('../package.json'); -var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); -var ParseServer = require("../src/index"); -var Config = require('../src/Config'); -var express = require('express'); +'use strict'; +const request = require('../lib/request'); +const parseServerPackage = require('../package.json'); +const MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); +const ParseServer = require('../lib/index'); +const Config = require('../lib/Config'); +const express = require('express'); -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; describe('server', () => { it('requires a master key and app id', done => { @@ -25,53 +26,71 @@ describe('server', () => { }); }); + it('show warning if any reserved characters in appId', done => { + spyOn(console, 'warn').and.callFake(() => {}); + reconfigureServer({ appId: 'test!-^' }).then(() => { + expect(console.warn).toHaveBeenCalled(); + return done(); + }); + }); + it('support http basic authentication with masterkey', done => { reconfigureServer({ appId: 'test' }).then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/classes/TestObject', headers: { - 'Authorization': 'Basic ' + new Buffer('test:' + 'test').toString('base64') - } - }, (error, response) => { - expect(response.statusCode).toEqual(200); + Authorization: + 'Basic ' + Buffer.from('test:' + 'test').toString('base64'), + }, + }).then(response => { + expect(response.status).toEqual(200); done(); }); - }) + }); }); it('support http basic authentication with javascriptKey', done => { reconfigureServer({ appId: 'test' }).then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/classes/TestObject', headers: { - 'Authorization': 'Basic ' + new Buffer('test:javascript-key=' + 'test').toString('base64') - } - }, (error, response) => { - expect(response.statusCode).toEqual(200); + Authorization: + 'Basic ' + + Buffer.from('test:javascript-key=' + 'test').toString('base64'), + }, + }).then(response => { + expect(response.status).toEqual(200); done(); }); - }) + }); }); it('fails if database is unreachable', done => { - reconfigureServer({ databaseAdapter: new MongoStorageAdapter({ uri: 'mongodb://fake:fake@localhost:43605/drew3' }) }) - .catch(() => { + reconfigureServer({ + databaseAdapter: new MongoStorageAdapter({ + uri: 'mongodb://fake:fake@localhost:43605/drew3', + mongoOptions: { + serverSelectionTimeoutMS: 2000, + }, + }), + }).catch(() => { //Need to use rest api because saving via JS SDK results in fail() not getting called - request.post({ - url: 'http://localhost:8378/1/classes/NewClass', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - body: {}, - json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(500); - expect(body.code).toEqual(1); - expect(body.message).toEqual('Internal server error.'); - reconfigureServer().then(done, done); - }); + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/NewClass', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: {}, + }).then(fail, response => { + expect(response.status).toEqual(500); + const body = response.data; + expect(body.code).toEqual(1); + expect(body.message).toEqual('Internal server error.'); + reconfigureServer().then(done, done); }); + }); }); it('can load email adapter via object', done => { @@ -83,7 +102,7 @@ describe('server', () => { apiKey: 'k', domain: 'd', }), - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }).then(done, fail); }); @@ -97,9 +116,9 @@ describe('server', () => { fromAddress: 'parse@example.com', apiKey: 'k', domain: 'd', - } + }, }, - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }).then(done, fail); }); @@ -108,14 +127,14 @@ describe('server', () => { appName: 'unused', verifyUserEmails: true, emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', + module: '@parse/simple-mailgun-adapter', options: { fromAddress: 'parse@example.com', apiKey: 'k', domain: 'd', - } + }, }, - publicServerURL: 'http://localhost:8378/1' + publicServerURL: 'http://localhost:8378/1', }).then(done, fail); }); @@ -123,13 +142,14 @@ describe('server', () => { reconfigureServer({ appName: 'unused', verifyUserEmails: true, - emailAdapter: 'parse-server-simple-mailgun-adapter', - publicServerURL: 'http://localhost:8378/1' - }) - .catch(error => { - expect(error).toEqual('SimpleMailgunAdapter requires an API Key, domain, and fromAddress.'); - done(); - }); + emailAdapter: '@parse/simple-mailgun-adapter', + publicServerURL: 'http://localhost:8378/1', + }).catch(error => { + expect(error).toEqual( + 'SimpleMailgunAdapter requires an API Key, domain, and fromAddress.' + ); + done(); + }); }); it('throws if you initialize email adapter incorrectly', done => { @@ -137,31 +157,32 @@ describe('server', () => { appName: 'unused', verifyUserEmails: true, emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', + module: '@parse/simple-mailgun-adapter', options: { domain: 'd', - } + }, }, - publicServerURL: 'http://localhost:8378/1' - }) - .catch(error => { - expect(error).toEqual('SimpleMailgunAdapter requires an API Key, domain, and fromAddress.'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }).catch(error => { + expect(error).toEqual( + 'SimpleMailgunAdapter requires an API Key, domain, and fromAddress.' + ); + done(); + }); }); it('can report the server version', done => { - request.get({ + request({ url: 'http://localhost:8378/1/serverInfo', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, - json: true, - }, (error, response, body) => { + }).then(response => { + const body = response.data; expect(body.parseServerVersion).toEqual(parseServerPackage.version); done(); - }) + }); }); it('can properly sets the push support', done => { @@ -169,40 +190,44 @@ describe('server', () => { const config = Config.get('test'); expect(config.hasPushSupport).toEqual(true); expect(config.hasPushScheduledSupport).toEqual(false); - request.get({ + request({ url: 'http://localhost:8378/1/serverInfo', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, json: true, - }, (error, response, body) => { + }).then(response => { + const body = response.data; expect(body.features.push.immediatePush).toEqual(true); expect(body.features.push.scheduledPush).toEqual(false); done(); - }) + }); }); it('can properly sets the push support when not configured', done => { reconfigureServer({ - push: undefined // force no config - }).then(() => { - const config = Config.get('test'); - expect(config.hasPushSupport).toEqual(false); - expect(config.hasPushScheduledSupport).toEqual(false); - request.get({ - url: 'http://localhost:8378/1/serverInfo', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - json: true, - }, (error, response, body) => { - expect(body.features.push.immediatePush).toEqual(false); - expect(body.features.push.scheduledPush).toEqual(false); - done(); + push: undefined, // force no config + }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(false); + expect(config.hasPushScheduledSupport).toEqual(false); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(false); + expect(body.features.push.scheduledPush).toEqual(false); + done(); + }); }) - }).catch(done.fail); + .catch(done.fail); }); it('can properly sets the push support ', done => { @@ -210,26 +235,29 @@ describe('server', () => { push: { adapter: { send() {}, - getValidPushTypes() {} - } - } - }).then(() => { - const config = Config.get('test'); - expect(config.hasPushSupport).toEqual(true); - expect(config.hasPushScheduledSupport).toEqual(false); - request.get({ - url: 'http://localhost:8378/1/serverInfo', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', + getValidPushTypes() {}, }, - json: true, - }, (error, response, body) => { - expect(body.features.push.immediatePush).toEqual(true); - expect(body.features.push.scheduledPush).toEqual(false); - done(); + }, + }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(true); + expect(config.hasPushScheduledSupport).toEqual(false); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(true); + expect(body.features.push.scheduledPush).toEqual(false); + done(); + }); }) - }).catch(done.fail); + .catch(done.fail); }); it('can properly sets the push schedule support', done => { @@ -237,91 +265,97 @@ describe('server', () => { push: { adapter: { send() {}, - getValidPushTypes() {} - } + getValidPushTypes() {}, + }, }, scheduledPush: true, - }).then(() => { - const config = Config.get('test'); - expect(config.hasPushSupport).toEqual(true); - expect(config.hasPushScheduledSupport).toEqual(true); - request.get({ - url: 'http://localhost:8378/1/serverInfo', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - json: true, - }, (error, response, body) => { - expect(body.features.push.immediatePush).toEqual(true); - expect(body.features.push.scheduledPush).toEqual(true); - done(); + }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(true); + expect(config.hasPushScheduledSupport).toEqual(true); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(true); + expect(body.features.push.scheduledPush).toEqual(true); + done(); + }); }) - }).catch(done.fail); + .catch(done.fail); }); it('can respond 200 on path health', done => { - request.get({ + request({ url: 'http://localhost:8378/1/health', - }, (error, response) => { - expect(response.statusCode).toBe(200); + }).then(response => { + expect(response.status).toBe(200); done(); }); }); it('can create a parse-server v1', done => { - var parseServer = new ParseServer.default(Object.assign({}, - defaultConfiguration, { - appId: "aTestApp", - masterKey: "aTestMasterKey", - serverURL: "http://localhost:12666/parse", - __indexBuildCompletionCallbackForTests: promise => { - promise - .then(() => { - expect(Parse.applicationId).toEqual("aTestApp"); - var app = express(); - app.use('/parse', parseServer.app); - - var server = app.listen(12666); - var obj = new Parse.Object("AnObject"); - var objId; - obj.save().then((obj) => { - objId = obj.id; - var q = new Parse.Query("AnObject"); - return q.first(); - }).then((obj) => { - expect(obj.id).toEqual(objId); - server.close(done); - }).fail(() => { - server.close(done); - }) + const parseServer = new ParseServer.default( + Object.assign({}, defaultConfiguration, { + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12666/parse', + serverStartComplete: () => { + expect(Parse.applicationId).toEqual('aTestApp'); + const app = express(); + app.use('/parse', parseServer.app); + + const server = app.listen(12666); + const obj = new Parse.Object('AnObject'); + let objId; + obj + .save() + .then(obj => { + objId = obj.id; + const q = new Parse.Query('AnObject'); + return q.first(); + }) + .then(obj => { + expect(obj.id).toEqual(objId); + server.close(done); + }) + .catch(() => { + server.close(done); }); - }}) + }, + }) ); }); it('can create a parse-server v2', done => { let objId; - let server - const parseServer = ParseServer.ParseServer(Object.assign({}, - defaultConfiguration, { - appId: "anOtherTestApp", - masterKey: "anOtherTestMasterKey", - serverURL: "http://localhost:12667/parse", - __indexBuildCompletionCallbackForTests: promise => { + let server; + const parseServer = ParseServer.ParseServer( + Object.assign({}, defaultConfiguration, { + appId: 'anOtherTestApp', + masterKey: 'anOtherTestMasterKey', + serverURL: 'http://localhost:12667/parse', + serverStartComplete: error => { + const promise = error ? Promise.reject(error) : Promise.resolve(); promise .then(() => { - expect(Parse.applicationId).toEqual("anOtherTestApp"); + expect(Parse.applicationId).toEqual('anOtherTestApp'); const app = express(); app.use('/parse', parseServer); server = app.listen(12667); - const obj = new Parse.Object("AnObject"); - return obj.save() + const obj = new Parse.Object('AnObject'); + return obj.save(); }) .then(obj => { objId = obj.id; - const q = new Parse.Query("AnObject"); + const q = new Parse.Query('AnObject'); return q.first(); }) .then(obj => { @@ -329,28 +363,35 @@ describe('server', () => { server.close(done); }) .catch(error => { - fail(JSON.stringify(error)) + fail(JSON.stringify(error)); if (server) { server.close(done); } else { done(); } }); - }} - )); + }, + }) + ); }); it('has createLiveQueryServer', done => { // original implementation through the factory - expect(typeof ParseServer.ParseServer.createLiveQueryServer).toEqual('function'); + expect(typeof ParseServer.ParseServer.createLiveQueryServer).toEqual( + 'function' + ); // For import calls - expect(typeof ParseServer.default.createLiveQueryServer).toEqual('function'); + expect(typeof ParseServer.default.createLiveQueryServer).toEqual( + 'function' + ); done(); }); it('exposes correct adapters', done => { expect(ParseServer.S3Adapter).toThrow(); - expect(ParseServer.GCSAdapter).toThrow('GCSAdapter is not provided by parse-server anymore; please install parse-server-gcs-adapter'); + expect(ParseServer.GCSAdapter).toThrow( + 'GCSAdapter is not provided by parse-server anymore; please install @parse/gcs-files-adapter' + ); expect(ParseServer.FileSystemAdapter).toThrow(); expect(ParseServer.InMemoryCacheAdapter).toThrow(); expect(ParseServer.NullCacheAdapter).toThrow(); @@ -358,29 +399,30 @@ describe('server', () => { }); it('properly gives publicServerURL when set', done => { - reconfigureServer({ publicServerURL: 'https://myserver.com/1' }) - .then(() => { - var config = Config.get('test', 'http://localhost:8378/1'); + reconfigureServer({ publicServerURL: 'https://myserver.com/1' }).then( + () => { + const config = Config.get('test', 'http://localhost:8378/1'); expect(config.mount).toEqual('https://myserver.com/1'); done(); - }); + } + ); }); it('properly removes trailing slash in mount', done => { - reconfigureServer({}) - .then(() => { - var config = Config.get('test', 'http://localhost:8378/1/'); - expect(config.mount).toEqual('http://localhost:8378/1'); - done(); - }); + reconfigureServer({}).then(() => { + const config = Config.get('test', 'http://localhost:8378/1/'); + expect(config.mount).toEqual('http://localhost:8378/1'); + done(); + }); }); it('should throw when getting invalid mount', done => { - reconfigureServer({ publicServerURL: 'blabla:/some' }) - .catch(error => { - expect(error).toEqual('publicServerURL should be a valid HTTPS URL starting with https://') - done(); - }) + reconfigureServer({ publicServerURL: 'blabla:/some' }).catch(error => { + expect(error).toEqual( + 'publicServerURL should be a valid HTTPS URL starting with https://' + ); + done(); + }); }); it('fails if the session length is not a number', done => { @@ -397,7 +439,7 @@ describe('server', () => { .then(done.fail) .catch(error => { expect(error).toEqual('Session length must be a value greater than 0.'); - return reconfigureServer({ sessionLength: '0' }) + return reconfigureServer({ sessionLength: '0' }); }) .catch(error => { expect(error).toEqual('Session length must be a value greater than 0.'); @@ -405,71 +447,88 @@ describe('server', () => { }); }); - it('ignores the session length when expireInactiveSessions set to false', (done) => { + it('ignores the session length when expireInactiveSessions set to false', done => { reconfigureServer({ sessionLength: '-33', - expireInactiveSessions: false + expireInactiveSessions: false, }) - .then(() => reconfigureServer({ - sessionLength: '0', - expireInactiveSessions: false - })) + .then(() => + reconfigureServer({ + sessionLength: '0', + expireInactiveSessions: false, + }) + ) .then(done); - }) + }); - it('fails if maxLimit is negative', (done) => { - reconfigureServer({ maxLimit: -100 }) - .catch(error => { - expect(error).toEqual('Max limit must be a value greater than 0.'); - done(); - }); + it('fails if maxLimit is negative', done => { + reconfigureServer({ maxLimit: -100 }).catch(error => { + expect(error).toEqual('Max limit must be a value greater than 0.'); + done(); + }); }); it('fails if you try to set revokeSessionOnPasswordReset to non-boolean', done => { - reconfigureServer({ revokeSessionOnPasswordReset: 'non-bool' }) - .catch(done); + reconfigureServer({ revokeSessionOnPasswordReset: 'non-bool' }).catch(done); }); it('fails if you provides invalid ip in masterKeyIps', done => { - reconfigureServer({ masterKeyIps: ['invalidIp','1.2.3.4'] }) - .catch(error => { + reconfigureServer({ masterKeyIps: ['invalidIp', '1.2.3.4'] }).catch( + error => { expect(error).toEqual('Invalid ip in masterKeyIps: invalidIp'); done(); - }) + } + ); }); it('should succeed if you provide valid ip in masterKeyIps', done => { - reconfigureServer({ masterKeyIps: ['1.2.3.4','2001:0db8:0000:0042:0000:8a2e:0370:7334'] }) - .then(done) + reconfigureServer({ + masterKeyIps: ['1.2.3.4', '2001:0db8:0000:0042:0000:8a2e:0370:7334'], + }).then(done); }); - it('should load a middleware', (done) => { + it('should load a middleware', done => { const obj = { middleware: function(req, res, next) { next(); - } - } + }, + }; const spy = spyOn(obj, 'middleware').and.callThrough(); reconfigureServer({ - middleware: obj.middleware - }).then(() => { - const query = new Parse.Query('AnObject'); - return query.find(); - }).then(() => { - expect(spy).toHaveBeenCalled(); - done(); - }).catch(done.fail); + middleware: obj.middleware, + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + done(); + }) + .catch(done.fail); }); - it('should load a middleware from string', (done) => { + it('should allow direct access', async () => { + const RESTController = Parse.CoreManager.getRESTController(); + const spy = spyOn(Parse.CoreManager, 'setRESTController').and.callThrough(); + await reconfigureServer({ + directAccess: true, + }); + expect(spy).toHaveBeenCalledTimes(1); + Parse.CoreManager.setRESTController(RESTController); + }); + + it('should load a middleware from string', done => { reconfigureServer({ - middleware: 'spec/support/CustomMiddleware' - }).then(() => { - return request.get('http://localhost:8378/1', (err, res) => { - // Just check that the middleware set the header - expect(res.headers['x-yolo']).toBe('1'); - done(); - }); - }).catch(done.fail); + middleware: 'spec/support/CustomMiddleware', + }) + .then(() => { + return request({ url: 'http://localhost:8378/1' }).then(fail, res => { + // Just check that the middleware set the header + expect(res.headers['x-yolo']).toBe('1'); + done(); + }); + }) + .catch(done.fail); }); }); diff --git a/spec/myoauth.js b/spec/myoauth.js index d28f9e8130..2367ad62ce 100644 --- a/spec/myoauth.js +++ b/spec/myoauth.js @@ -2,7 +2,7 @@ // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - if (authData.id == "12345" && authData.access_token == "12345") { + if (authData.id == '12345' && authData.access_token == '12345') { return Promise.resolve(); } return Promise.reject(); @@ -13,5 +13,5 @@ function validateAppId() { module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/spec/parsers.spec.js b/spec/parsers.spec.js index e6313bb091..d2a6f20991 100644 --- a/spec/parsers.spec.js +++ b/spec/parsers.spec.js @@ -1,4 +1,4 @@ -import { +const { numberParser, numberOrBoolParser, booleanParser, @@ -6,14 +6,16 @@ import { arrayParser, moduleOrObjectParser, nullParser, -} from '../src/Options/parsers'; +} = require('../lib/Options/parsers'); describe('parsers', () => { it('parses correctly with numberParser', () => { const parser = numberParser('key'); expect(parser(2)).toEqual(2); expect(parser('2')).toEqual(2); - expect(() => {parser('string')}).toThrow(); + expect(() => { + parser('string'); + }).toThrow(); }); it('parses correctly with numberOrBoolParser', () => { @@ -38,24 +40,28 @@ describe('parsers', () => { it('parses correctly with objectParser', () => { const parser = objectParser; - expect(parser({hello: 'world'})).toEqual({hello: 'world'}); - expect(parser('{"hello": "world"}')).toEqual({hello: 'world'}); - expect(() => {parser('string')}).toThrow(); + expect(parser({ hello: 'world' })).toEqual({ hello: 'world' }); + expect(parser('{"hello": "world"}')).toEqual({ hello: 'world' }); + expect(() => { + parser('string'); + }).toThrow(); }); it('parses correctly with moduleOrObjectParser', () => { const parser = moduleOrObjectParser; - expect(parser({hello: 'world'})).toEqual({hello: 'world'}); - expect(parser('{"hello": "world"}')).toEqual({hello: 'world'}); + expect(parser({ hello: 'world' })).toEqual({ hello: 'world' }); + expect(parser('{"hello": "world"}')).toEqual({ hello: 'world' }); expect(parser('string')).toEqual('string'); }); it('parses correctly with arrayParser', () => { const parser = arrayParser; - expect(parser([1,2,3])).toEqual([1,2,3]); + expect(parser([1, 2, 3])).toEqual([1, 2, 3]); expect(parser('{"hello": "world"}')).toEqual(['{"hello": "world"}']); - expect(parser('1,2,3')).toEqual(['1','2','3']); - expect(() => {parser(1)}).toThrow(); + expect(parser('1,2,3')).toEqual(['1', '2', '3']); + expect(() => { + parser(1); + }).toThrow(); }); it('parses correctly with nullParser', () => { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 1e897ef3c0..96bb223a16 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1,29 +1,28 @@ -"use strict"; +'use strict'; // These tests check the "create" / "update" functionality of the REST API. -var auth = require('../src/Auth'); -var Config = require('../src/Config'); -var Parse = require('parse/node').Parse; -var rest = require('../src/rest'); -var RestWrite = require('../src/RestWrite'); -var request = require('request'); -var rp = require('request-promise'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const Parse = require('parse/node').Parse; +const rest = require('../lib/rest'); +const RestWrite = require('../lib/RestWrite'); +const request = require('../lib/request'); let config; let database; describe('rest create', () => { - beforeEach(() => { config = Config.get('test'); database = config.database; }); it('handles _id', done => { - rest.create(config, auth.nobody(config), 'Foo', {}) + rest + .create(config, auth.nobody(config), 'Foo', {}) .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(typeof obj.objectId).toEqual('string'); expect(obj.objectId.length).toEqual(10); expect(obj._id).toBeUndefined(); @@ -33,63 +32,142 @@ describe('rest create', () => { it('can use custom _id size', done => { config.objectIdSize = 20; - rest.create(config, auth.nobody(config), 'Foo', {}) + rest + .create(config, auth.nobody(config), 'Foo', {}) .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) - .then((results) => { + .then(results => { expect(results.length).toEqual(1); - var obj = results[0]; + const obj = results[0]; expect(typeof obj.objectId).toEqual('string'); expect(obj.objectId.length).toEqual(20); done(); }); }); + it('should use objectId from client when allowCustomObjectId true', async () => { + config.allowCustomObjectId = true; + + // use time as unique custom id for test reusability + const customId = `${Date.now()}`; + const obj = { + objectId: customId, + }; + + const { + status, + response: { objectId }, + } = await rest.create(config, auth.nobody(config), 'MyClass', obj); + + expect(status).toEqual(201); + expect(objectId).toEqual(customId); + }); + + it('should throw on invalid objectId when allowCustomObjectId true', () => { + config.allowCustomObjectId = true; + + const objIdNull = { + objectId: null, + }; + + const objIdUndef = { + objectId: undefined, + }; + + const objIdEmpty = { + objectId: '', + }; + + const err = 'objectId must not be empty, null or undefined'; + + expect(() => + rest.create(config, auth.nobody(config), 'MyClass', objIdEmpty) + ).toThrowError(err); + + expect(() => + rest.create(config, auth.nobody(config), 'MyClass', objIdNull) + ).toThrowError(err); + + expect(() => + rest.create(config, auth.nobody(config), 'MyClass', objIdUndef) + ).toThrowError(err); + }); + + it('should generate objectId when not set by client with allowCustomObjectId true', async () => { + config.allowCustomObjectId = true; + + const { + status, + response: { objectId }, + } = await rest.create(config, auth.nobody(config), 'MyClass', {}); + + expect(status).toEqual(201); + expect(objectId).toBeDefined(); + }); + it('is backwards compatible when _id size changes', done => { - rest.create(config, auth.nobody(config), 'Foo', {size: 10}) + rest + .create(config, auth.nobody(config), 'Foo', { size: 10 }) .then(() => { config.objectIdSize = 20; - return rest.find(config, auth.nobody(config), 'Foo', {size: 10}); + return rest.find(config, auth.nobody(config), 'Foo', { size: 10 }); }) - .then((response) => { + .then(response => { expect(response.results.length).toEqual(1); expect(response.results[0].objectId.length).toEqual(10); - return rest.update(config, auth.nobody(config), 'Foo', {objectId: response.results[0].objectId}, {update: 20}); + return rest.update( + config, + auth.nobody(config), + 'Foo', + { objectId: response.results[0].objectId }, + { update: 20 } + ); }) .then(() => { - return rest.find(config, auth.nobody(config), 'Foo', {size: 10}); - }).then((response) => { + return rest.find(config, auth.nobody(config), 'Foo', { size: 10 }); + }) + .then(response => { expect(response.results.length).toEqual(1); expect(response.results[0].objectId.length).toEqual(10); expect(response.results[0].update).toEqual(20); - return rest.create(config, auth.nobody(config), 'Foo', {size: 20}); + return rest.create(config, auth.nobody(config), 'Foo', { size: 20 }); }) .then(() => { config.objectIdSize = 10; - return rest.find(config, auth.nobody(config), 'Foo', {size: 20}); + return rest.find(config, auth.nobody(config), 'Foo', { size: 20 }); }) - .then((response) => { + .then(response => { expect(response.results.length).toEqual(1); expect(response.results[0].objectId.length).toEqual(20); done(); }); }); - it('handles array, object, date', (done) => { + it('handles array, object, date', done => { const now = new Date(); - var obj = { + const obj = { array: [1, 2, 3], - object: {foo: 'bar'}, + object: { foo: 'bar' }, date: Parse._encode(now), }; - rest.create(config, auth.nobody(config), 'MyClass', obj) - .then(() => database.adapter.find('MyClass', { fields: { - array: { type: 'Array' }, - object: { type: 'Object' }, - date: { type: 'Date' }, - } }, {}, {})) + rest + .create(config, auth.nobody(config), 'MyClass', obj) + .then(() => + database.adapter.find( + 'MyClass', + { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }, + {}, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); - var mob = results[0]; + const mob = results[0]; expect(mob.array instanceof Array).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); @@ -99,14 +177,14 @@ describe('rest create', () => { }); it('handles object and subdocument', done => { - const obj = { subdoc: {foo: 'bar', wu: 'tan'} }; + const obj = { subdoc: { foo: 'bar', wu: 'tan' } }; - Parse.Cloud.beforeSave('MyClass', function(req, res) { + Parse.Cloud.beforeSave('MyClass', function() { // this beforeSave trigger should do nothing but can mess with the object - res.success(); }); - rest.create(config, auth.nobody(config), 'MyClass', obj) + rest + .create(config, auth.nobody(config), 'MyClass', obj) .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) .then(results => { expect(results.length).toEqual(1); @@ -116,7 +194,13 @@ describe('rest create', () => { expect(mob.subdoc.wu).toBe('tan'); expect(typeof mob.objectId).toEqual('string'); const obj = { 'subdoc.wu': 'clan' }; - return rest.update(config, auth.nobody(config), 'MyClass', { objectId: mob.objectId }, obj); + return rest.update( + config, + auth.nobody(config), + 'MyClass', + { objectId: mob.objectId }, + obj + ); }) .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) .then(results => { @@ -127,94 +211,112 @@ describe('rest create', () => { expect(mob.subdoc.wu).toBe('clan'); done(); }) - .catch(error => { - console.log(error); - fail(); - done(); - }); - }); - - it('handles create on non-existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}) - .then(() => { - fail('Should throw an error'); - done(); - }, (err) => { - expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); - expect(err.message).toEqual('This user is not allowed to access ' + - 'non-existent class: ClientClassCreation'); - done(); - }); + .catch(done.fail); }); - it('handles create on existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('ClientClassCreation', {})) - .then(actualSchema => { - expect(actualSchema.className).toEqual('ClientClassCreation'); - return rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}); - }) - .then(() => { - done(); - }, () => { - fail('Should not throw error') - }); + it('handles create on non-existent class when disabled client class creation', done => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + rest + .create( + customConfig, + auth.nobody(customConfig), + 'ClientClassCreation', + {} + ) + .then( + () => { + fail('Should throw an error'); + done(); + }, + err => { + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(err.message).toEqual( + 'This user is not allowed to access ' + + 'non-existent class: ClientClassCreation' + ); + done(); + } + ); }); - it('handles user signup', (done) => { - var user = { + it('handles create on existent class when disabled client class creation', async () => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + const schema = await config.database.loadSchema(); + const actualSchema = await schema.addClassIfNotExists( + 'ClientClassCreation', + {} + ); + expect(actualSchema.className).toEqual('ClientClassCreation'); + + await schema.reloadData({ clearCache: true }); + // Should not throw + await rest.create( + customConfig, + auth.nobody(customConfig), + 'ClientClassCreation', + {} + ); + }); + + it('handles user signup', done => { + const user = { username: 'asdf', password: 'zxcv', foo: 'bar', }; - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { - expect(Object.keys(r.response).length).toEqual(3); - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - done(); - }); + rest.create(config, auth.nobody(config), '_User', user).then(r => { + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + done(); + }); }); - it('handles anonymous user signup', (done) => { - var data1 = { + it('handles anonymous user signup', done => { + const data1 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } + id: '00000000-0000-0000-0000-000000000001', + }, + }, }; - var data2 = { + const data2 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000002' - } - } + id: '00000000-0000-0000-0000-000000000002', + }, + }, }; - var username1; - rest.create(config, auth.nobody(config), '_User', data1) - .then((r) => { + let username1; + rest + .create(config, auth.nobody(config), '_User', data1) + .then(r => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); expect(typeof r.response.username).toEqual('string'); return rest.create(config, auth.nobody(config), '_User', data1); - }).then((r) => { + }) + .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'); username1 = r.response.username; return rest.create(config, auth.nobody(config), '_User', data2); - }).then((r) => { + }) + .then(r => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); return rest.create(config, auth.nobody(config), '_User', data2); - }).then((r) => { + }) + .then(r => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.username).toEqual('string'); @@ -224,99 +326,123 @@ describe('rest create', () => { }); }); - it('handles anonymous user signup and upgrade to new user', (done) => { - var data1 = { + it('handles anonymous user signup and upgrade to new user', done => { + const data1 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } + id: '00000000-0000-0000-0000-000000000001', + }, + }, }; - var updatedData = { + const updatedData = { authData: { anonymous: null }, username: 'hello', - password: 'world' - } - var objectId; - rest.create(config, auth.nobody(config), '_User', data1) - .then((r) => { + password: 'world', + }; + let objectId; + rest + .create(config, auth.nobody(config), '_User', data1) + .then(r => { expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); objectId = r.response.objectId; - return auth.getAuthForSessionToken({config, sessionToken: r.response.sessionToken }) - }).then((sessionAuth) => { - return rest.update(config, sessionAuth, '_User', { objectId }, updatedData); - }).then(() => { + return auth.getAuthForSessionToken({ + config, + sessionToken: r.response.sessionToken, + }); + }) + .then(sessionAuth => { + return rest.update( + config, + sessionAuth, + '_User', + { objectId }, + updatedData + ); + }) + .then(() => { return Parse.User.logOut().then(() => { return Parse.User.logIn('hello', 'world'); - }) - }).then((r) => { + }); + }) + .then(r => { expect(r.id).toEqual(objectId); expect(r.get('username')).toEqual('hello'); done(); - }).catch((err) => { + }) + .catch(err => { jfail(err); done(); - }) + }); }); - it('handles no anonymous users config', (done) => { - var NoAnnonConfig = Object.assign({}, config); + it('handles no anonymous users config', done => { + const NoAnnonConfig = Object.assign({}, config); NoAnnonConfig.authDataManager.setEnableAnonymousUsers(false); - var data1 = { + const data1 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } + id: '00000000-0000-0000-0000-000000000001', + }, + }, }; - rest.create(NoAnnonConfig, auth.nobody(NoAnnonConfig), '_User', data1).then(() => { - fail("Should throw an error"); - done(); - }, (err) => { - expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); - expect(err.message).toEqual('This authentication method is unsupported.'); - NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); - done(); - }) + rest.create(NoAnnonConfig, auth.nobody(NoAnnonConfig), '_User', data1).then( + () => { + fail('Should throw an error'); + done(); + }, + err => { + expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); + expect(err.message).toEqual( + 'This authentication method is unsupported.' + ); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); + done(); + } + ); }); - it('test facebook signup and login', (done) => { - var data = { + it('test facebook signup and login', done => { + const data = { authData: { facebook: { id: '8675309', - access_token: 'jenny' - } - } + access_token: 'jenny', + }, + }, }; - var newUserSignedUpByFacebookObjectId; - rest.create(config, auth.nobody(config), '_User', data) - .then((r) => { + let 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) => { + }) + .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) => { + 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]; + const output = response.results[0]; expect(output.user.objectId).toEqual(newUserSignedUpByFacebookObjectId); done(); - }).catch(err => { + }) + .catch(err => { jfail(err); done(); - }) + }); }); it('stores pointers', done => { @@ -325,14 +451,24 @@ describe('rest create', () => { aPointer: { __type: 'Pointer', className: 'JustThePointer', - objectId: 'qwerty1234' // make it 10 chars to match PG storage - } + objectId: 'qwerty1234', // make it 10 chars to match PG storage + }, }; - rest.create(config, auth.nobody(config), 'APointerDarkly', obj) - .then(() => database.adapter.find('APointerDarkly', { fields: { - foo: { type: 'String' }, - aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}, {}, {})) + rest + .create(config, auth.nobody(config), 'APointerDarkly', obj) + .then(() => + database.adapter.find( + 'APointerDarkly', + { + fields: { + foo: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, + }, + }, + {}, + {} + ) + ) .then(results => { expect(results.length).toEqual(1); const output = results[0]; @@ -342,56 +478,81 @@ describe('rest create', () => { expect(output.aPointer).toEqual({ __type: 'Pointer', className: 'JustThePointer', - objectId: 'qwerty1234' + objectId: 'qwerty1234', }); done(); }); }); - it("cannot set objectId", (done) => { - var headers = { - 'Content-Type': 'application/octet-stream', + it('cannot set objectId', done => { + const headers = { + 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ headers: headers, + method: 'POST', url: 'http://localhost:8378/1/classes/TestObject', body: JSON.stringify({ - 'foo': 'bar', - 'objectId': 'hello' - }) - }, (error, response, body) => { - var b = JSON.parse(body); + foo: 'bar', + objectId: 'hello', + }), + }).then(fail, response => { + const b = response.data; expect(b.code).toEqual(105); expect(b.error).toEqual('objectId is an invalid field name.'); done(); }); }); - it("test default session length", (done) => { - var user = { + it('cannot set id', done => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: JSON.stringify({ + foo: 'bar', + id: 'hello', + }), + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(105); + expect(b.error).toEqual('id is an invalid field name.'); + done(); + }); + }); + + it('test default session length', done => { + const user = { username: 'asdf', password: 'zxcv', foo: 'bar', }; - var now = new Date(); + const now = new Date(); - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { + rest + .create(config, auth.nobody(config), '_User', user) + .then(r => { expect(Object.keys(r.response).length).toEqual(3); expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); }) - .then((r) => { + .then(r => { expect(r.results.length).toEqual(1); - var session = r.results[0]; - var actual = new Date(session.expiresAt.iso); - var expected = new Date(now.getTime() + (1000 * 3600 * 24 * 365)); + const session = r.results[0]; + const actual = new Date(session.expiresAt.iso); + const expected = new Date(now.getTime() + 1000 * 3600 * 24 * 365); expect(actual.getFullYear()).toEqual(expected.getFullYear()); expect(actual.getMonth()).toEqual(expected.getMonth()); @@ -403,31 +564,33 @@ describe('rest create', () => { }); }); - it("test specified session length", (done) => { - var user = { + it('test specified session length', done => { + const user = { username: 'asdf', password: 'zxcv', foo: 'bar', }; - var sessionLength = 3600, // 1 Hour ahead + const sessionLength = 3600, // 1 Hour ahead now = new Date(); // For reference later config.sessionLength = sessionLength; - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { + rest + .create(config, auth.nobody(config), '_User', user) + .then(r => { expect(Object.keys(r.response).length).toEqual(3); expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); }) - .then((r) => { + .then(r => { expect(r.results.length).toEqual(1); - var session = r.results[0]; - var actual = new Date(session.expiresAt.iso); - var expected = new Date(now.getTime() + (sessionLength * 1000)); + const session = r.results[0]; + const actual = new Date(session.expiresAt.iso); + const expected = new Date(now.getTime() + sessionLength * 1000); expect(actual.getFullYear()).toEqual(expected.getFullYear()); expect(actual.getMonth()).toEqual(expected.getMonth()); @@ -436,193 +599,212 @@ describe('rest create', () => { expect(actual.getMinutes()).toEqual(expected.getMinutes()); done(); - }).catch(err => { + }) + .catch(err => { jfail(err); done(); }); }); - it("can create a session with no expiration", (done) => { - var user = { + it('can create a session with no expiration', done => { + const user = { username: 'asdf', password: 'zxcv', - foo: 'bar' + foo: 'bar', }; config.expireInactiveSessions = false; - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { + rest + .create(config, auth.nobody(config), '_User', user) + .then(r => { expect(Object.keys(r.response).length).toEqual(3); expect(typeof r.response.objectId).toEqual('string'); expect(typeof r.response.createdAt).toEqual('string'); expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); }) - .then((r) => { + .then(r => { expect(r.results.length).toEqual(1); - var session = r.results[0]; + const session = r.results[0]; expect(session.expiresAt).toBeUndefined(); done(); - }).catch(err => { + }) + .catch(err => { console.error(err); fail(err); done(); - }) + }); }); - it("can create object in volatileClasses if masterKey", (done) =>{ - rest.create(config, auth.master(config), '_PushStatus', {}) - .then((r) => { + it('can create object in volatileClasses if masterKey', done => { + rest + .create(config, auth.master(config), '_PushStatus', {}) + .then(r => { expect(r.response.objectId.length).toBe(10); }) .then(() => { - rest.create(config, auth.master(config), '_JobStatus', {}) - .then((r) => { - expect(r.response.objectId.length).toBe(10); - done(); - }) - }) - + rest.create(config, auth.master(config), '_JobStatus', {}).then(r => { + expect(r.response.objectId.length).toBe(10); + done(); + }); + }); }); - it("cannot create object in volatileClasses if not masterKey", (done) =>{ + it('cannot create object in volatileClasses if not masterKey', done => { Promise.resolve() .then(() => { - rest.create(config, auth.nobody(config), '_PushStatus', {}) - }) - .then((r) => { - console.log(r); + return rest.create(config, auth.nobody(config), '_PushStatus', {}); }) - .catch((error) => { + .catch(error => { expect(error.code).toEqual(119); done(); - }) + }); }); - it ('locks down session', (done) => { + it('locks down session', done => { let currentUser; - Parse.User.signUp('foo', 'bar').then((user) => { - currentUser = user; - const sessionToken = user.getSessionToken(); - var headers = { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': sessionToken, - }; - let sessionId; - return rp.get({ - headers: headers, - url: 'http://localhost:8378/1/sessions/me', - json: true, - }).then(body => { - sessionId = body.objectId; - return rp.put({ - headers, - url: 'http://localhost:8378/1/sessions/' + sessionId, - json: { - installationId: 'yolo' - } - }) - }).then(done.fail, (res) => { - expect(res.statusCode).toBe(400); - expect(res.error.code).toBe(105); - return rp.put({ - headers, - url: 'http://localhost:8378/1/sessions/' + sessionId, - json: { - sessionToken: 'yolo' - } - }) - }).then(done.fail, (res) => { - expect(res.statusCode).toBe(400); - expect(res.error.code).toBe(105); - return Parse.User.signUp('other', 'user'); - }).then((otherUser) => { - const user = new Parse.User(); - user.id = otherUser.id; - return rp.put({ - headers, - url: 'http://localhost:8378/1/sessions/' + sessionId, - json: { - user: Parse._encode(user) - } - }) - }).then(done.fail, (res) => { - expect(res.statusCode).toBe(400); - expect(res.error.code).toBe(105); - const user = new Parse.User(); - user.id = currentUser.id; - return rp.put({ - headers, - url: 'http://localhost:8378/1/sessions/' + sessionId, - json: { - user: Parse._encode(user) - } + Parse.User.signUp('foo', 'bar') + .then(user => { + currentUser = user; + const sessionToken = user.getSessionToken(); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }; + let sessionId; + return request({ + headers: headers, + url: 'http://localhost:8378/1/sessions/me', }) - }).then(done).catch(done.fail); - }).catch(done.fail); + .then(response => { + sessionId = response.data.objectId; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + installationId: 'yolo', + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + sessionToken: 'yolo', + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + return Parse.User.signUp('other', 'user'); + }) + .then(otherUser => { + const user = new Parse.User(); + user.id = otherUser.id; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + user: Parse._encode(user), + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + const user = new Parse.User(); + user.id = currentUser.id; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + user: Parse._encode(user), + }, + }); + }) + .then(done) + .catch(done.fail); + }) + .catch(done.fail); }); - it ('sets current user in new sessions', (done) => { + it('sets current user in new sessions', done => { let currentUser; Parse.User.signUp('foo', 'bar') - .then((user) => { + .then(user => { currentUser = user; const sessionToken = user.getSessionToken(); const headers = { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', }; - return rp.post({ + return request({ headers, + method: 'POST', url: 'http://localhost:8378/1/sessions', - json: true, - body: { 'user': { '__type': 'Pointer', 'className':'_User', 'objectId': 'fakeId' } }, - }) + body: { + user: { __type: 'Pointer', className: '_User', objectId: 'fakeId' }, + }, + }); }) - .then((body) => { - if (body.user.objectId === currentUser.id) { + .then(response => { + if (response.data.user.objectId === currentUser.id) { return done(); } else { return done.fail(); } }) .catch(done.fail); - }) + }); }); describe('rest update', () => { - it('ignores createdAt', done => { + const config = Config.get('test'); const nobody = auth.nobody(config); const className = 'Foo'; const newCreatedAt = new Date('1970-01-01T00:00:00.000Z'); - rest.create(config, nobody, className, {}).then(res => { - const objectId = res.response.objectId; - const restObject = { - createdAt: {__type: "Date", iso: newCreatedAt}, // should be ignored - }; - - return rest.update(config, nobody, className, { objectId }, restObject).then(() => { - const restWhere = { - objectId: objectId, + rest + .create(config, nobody, className, {}) + .then(res => { + const objectId = res.response.objectId; + const restObject = { + createdAt: { __type: 'Date', iso: newCreatedAt }, // should be ignored }; - return rest.find(config, nobody, className, restWhere, {}); - }); - }).then(res2 => { - const updatedObject = res2.results[0]; - expect(new Date(updatedObject.createdAt)).not.toEqual(newCreatedAt); - done(); - }).then(done).catch(err => { - fail(err); - done(); - }); + + return rest + .update(config, nobody, className, { objectId }, restObject) + .then(() => { + const restWhere = { + objectId: objectId, + }; + return rest.find(config, nobody, className, restWhere, {}); + }); + }) + .then(res2 => { + const updatedObject = res2.results[0]; + expect(new Date(updatedObject.createdAt)).not.toEqual(newCreatedAt); + done(); + }) + .then(done) + .catch(done.fail); }); }); @@ -631,132 +813,196 @@ describe('read-only masterKey', () => { const config = Config.get('test'); const readOnly = auth.readOnly(config); expect(() => { - rest.create(config, readOnly, 'AnObject', {}) - }).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `read-only masterKey isn't allowed to perform the create operation.`)); + rest.create(config, readOnly, 'AnObject', {}); + }).toThrow( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `read-only masterKey isn't allowed to perform the create operation.` + ) + ); expect(() => { - rest.update(config, readOnly, 'AnObject', {}) + rest.update(config, readOnly, 'AnObject', {}); }).toThrow(); expect(() => { - rest.del(config, readOnly, 'AnObject', {}) + rest.del(config, readOnly, 'AnObject', {}); }).toThrow(); }); - it('properly blocks writes', (done) => { + it('properly blocks writes', done => { reconfigureServer({ - readOnlyMasterKey: 'yolo-read-only' - }).then(() => { - return rp.post(`${Parse.serverURL}/classes/MyYolo`, { - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': 'yolo-read-only', - }, - json: { foo: 'bar' } + readOnlyMasterKey: 'yolo-read-only', + }) + .then(() => { + return request({ + url: `${Parse.serverURL}/classes/MyYolo`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'yolo-read-only', + 'Content-Type': 'application/json', + }, + body: { foo: 'bar' }, + }); + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to perform the create operation." + ); + done(); }); - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to perform the create operation.'); - done(); - }); }); - it('should throw when masterKey and readOnlyMasterKey are the same', (done) => { + it('should throw when masterKey and readOnlyMasterKey are the same', done => { reconfigureServer({ masterKey: 'yolo', - readOnlyMasterKey: 'yolo' - }).then(done.fail).catch((err) => { - expect(err).toEqual(new Error('masterKey and readOnlyMasterKey should be different')); - done(); - }); + readOnlyMasterKey: 'yolo', + }) + .then(done.fail) + .catch(err => { + expect(err).toEqual( + new Error('masterKey and readOnlyMasterKey should be different') + ); + done(); + }); }); it('should throw when trying to create RestWrite', () => { const config = Config.get('test'); expect(() => { new RestWrite(config, auth.readOnly(config)); - }).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey')); - }); - - it('should throw when trying to create schema', (done) => { - return rp.post(`${Parse.serverURL}/schemas`, { + }).toThrow( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot perform a write operation when using readOnlyMasterKey' + ) + ); + }); + + it('should throw when trying to create schema', done => { + return request({ + method: 'POST', + url: `${Parse.serverURL}/schemas`, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to create a schema." + ); + done(); + }); }); - it('should throw when trying to create schema with a name', (done) => { - return rp.post(`${Parse.serverURL}/schemas/MyClass`, { + it('should throw when trying to create schema with a name', done => { + return request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'POST', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to create a schema." + ); + done(); + }); }); - it('should throw when trying to update schema', (done) => { - return rp.put(`${Parse.serverURL}/schemas/MyClass`, { + it('should throw when trying to update schema', done => { + return request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'PUT', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update a schema.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to update a schema." + ); + done(); + }); }); - it('should throw when trying to delete schema', (done) => { - return rp.del(`${Parse.serverURL}/schemas/MyClass`, { + it('should throw when trying to delete schema', done => { + return request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'DELETE', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to delete a schema.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to delete a schema." + ); + done(); + }); }); - it('should throw when trying to update the global config', (done) => { - return rp.put(`${Parse.serverURL}/config`, { + it('should throw when trying to update the global config', done => { + return request({ + url: `${Parse.serverURL}/config`, + method: 'PUT', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update the config.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to update the config." + ); + done(); + }); }); - it('should throw when trying to send push', (done) => { - return rp.post(`${Parse.serverURL}/push`, { + it('should throw when trying to send push', done => { + return request({ + url: `${Parse.serverURL}/push`, + method: 'POST', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', }, - json: {} - }).then(done.fail).catch((res) => { - expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); - expect(res.error.error).toBe('read-only masterKey isn\'t allowed to send push notifications.'); - done(); - }); + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + "read-only masterKey isn't allowed to send push notifications." + ); + done(); + }); }); }); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 6219130500..661a19ee24 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,23 +1,27 @@ 'use strict'; -var Parse = require('parse/node').Parse; -var request = require('request'); -var dd = require('deep-diff'); -var Config = require('../src/Config'); +const Parse = require('parse/node').Parse; +const dd = require('deep-diff'); +const Config = require('../lib/Config'); +const request = require('../lib/request'); +const TestUtils = require('../lib/TestUtils'); -var config; +let config; -var hasAllPODobject = () => { - var obj = new Parse.Object('HasAllPOD'); +const hasAllPODobject = () => { + const obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); obj.set('aString', 'string'); obj.set('aBool', true); obj.set('aDate', new Date()); - obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aObject', { k1: 'value', k2: true, k3: 5 }); obj.set('aArray', ['contents', true, 5]); - obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); - obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); - var objACL = new Parse.ACL(); + obj.set('aGeoPoint', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); + obj.set( + 'aFile', + new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' }) + ); + const objACL = new Parse.ACL(); objACL.setPublicWriteAccess(false); obj.setACL(objACL); return obj; @@ -25,54 +29,60 @@ var hasAllPODobject = () => { const defaultClassLevelPermissions = { find: { - '*': true + '*': true, + }, + count: { + '*': true, }, create: { - '*': true + '*': true, }, get: { - '*': true + '*': true, }, update: { - '*': true + '*': true, }, addField: { - '*': true + '*': true, }, delete: { - '*': true - } -} + '*': true, + }, + protectedFields: { + '*': [], + }, +}; -var plainOldDataSchema = { +const plainOldDataSchema = { className: 'HasAllPOD', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields - aNumber: {type: 'Number'}, - aString: {type: 'String'}, - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'} + aNumber: { type: 'Number' }, + aString: { type: 'String' }, + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }; -var pointersAndRelationsSchema = { +const pointersAndRelationsSchema = { className: 'HasPointersAndRelations', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields aPointer: { type: 'Pointer', @@ -83,51 +93,53 @@ var pointersAndRelationsSchema = { targetClass: 'HasAllPOD', }, }, - classLevelPermissions: defaultClassLevelPermissions -} + classLevelPermissions: defaultClassLevelPermissions, +}; const userSchema = { - "className": "_User", - "fields": { - "objectId": {"type": "String"}, - "createdAt": {"type": "Date"}, - "updatedAt": {"type": "Date"}, - "ACL": {"type": "ACL"}, - "username": {"type": "String"}, - "password": {"type": "String"}, - "email": {"type": "String"}, - "emailVerified": {"type": "Boolean"}, - "authData": {"type": "Object"} + className: '_User', + fields: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, }, - "classLevelPermissions": defaultClassLevelPermissions, -} + classLevelPermissions: defaultClassLevelPermissions, +}; const roleSchema = { - "className": "_Role", - "fields": { - "objectId": {"type": "String"}, - "createdAt": {"type": "Date"}, - "updatedAt": {"type": "Date"}, - "ACL": {"type": "ACL"}, - "name": {"type":"String"}, - "users": {"type":"Relation", "targetClass":"_User"}, - "roles": {"type":"Relation", "targetClass":"_Role"} + className: '_Role', + fields: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, }, - "classLevelPermissions": defaultClassLevelPermissions, -} + classLevelPermissions: defaultClassLevelPermissions, +}; -var noAuthHeaders = { +const noAuthHeaders = { 'X-Parse-Application-Id': 'test', }; -var restKeyHeaders = { +const restKeyHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }; -var masterKeyHeaders = { +const masterKeyHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', }; describe('schemas', () => { @@ -135,109 +147,146 @@ describe('schemas', () => { config = Config.get('test'); }); - afterEach(() => { - config.database.schemaCache.clear(); + afterEach(async () => { + await config.database.schemaCache.clear(); + await TestUtils.destroyAllDataPermanently(false); }); - it('requires the master key to get all schemas', (done) => { - request.get({ + it('requires the master key to get all schemas', done => { + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: noAuthHeaders, - }, (error, response, body) => { + }).then(fail, response => { //api.parse.com uses status code 401, but due to the lack of keys //being necessary in parse-server, 403 makes more sense - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); - it('requires the master key to get one schema', (done) => { - request.get({ + it('requires the master key to get one schema', done => { + request({ url: 'http://localhost:8378/1/schemas/SomeSchema', json: true, headers: restKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual( + 'unauthorized: master key is required' + ); done(); }); }); - it('asks for the master key if you use the rest key', (done) => { - request.get({ + it('asks for the master key if you use the rest key', done => { + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: restKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual( + 'unauthorized: master key is required' + ); done(); }); }); it('creates _User schema when server starts', done => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - var expected = { - results: [userSchema,roleSchema] + }).then(response => { + const expected = { + results: [userSchema, roleSchema], }; - expect(dd(body.results.sort((s1, s2) => s1.className > s2.className), expected.results.sort((s1, s2) => s1.className > s2.className))).toEqual(undefined); + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual( + expected.results.sort((s1, s2) => + s1.className.localeCompare(s2.className) + ) + ); done(); }); }); it('responds with a list of schemas after creating objects', done => { - var obj1 = hasAllPODobject(); - obj1.save().then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); - obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); - relation.add(obj1); - return obj2.save(); - }).then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas', - json: true, - headers: masterKeyHeaders, - }, (error, response, body) => { - var expected = { - results: [userSchema,roleSchema,plainOldDataSchema,pointersAndRelationsSchema] - }; - expect(dd(body.results.sort((s1, s2) => s1.className > s2.className), expected.results.sort((s1, s2) => s1.className > s2.className))).toEqual(undefined); - done(); + const obj1 = hasAllPODobject(); + obj1 + .save() + .then(savedObj1 => { + const obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + const relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); }) - }); + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: masterKeyHeaders, + }).then(response => { + const expected = { + results: [ + userSchema, + roleSchema, + plainOldDataSchema, + pointersAndRelationsSchema, + ], + }; + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual( + expected.results.sort((s1, s2) => + s1.className.localeCompare(s2.className) + ) + ); + done(); + }); + }); }); it('responds with a single schema', done => { - var obj = hasAllPODobject(); + const obj = hasAllPODobject(); obj.save().then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - expect(body).toEqual(plainOldDataSchema); + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); done(); }); }); }); it('treats class names case sensitively', done => { - var obj = hasAllPODobject(); + const obj = hasAllPODobject(); obj.save().then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas/HASALLPOD', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: 103, error: 'Class HASALLPOD does not exist.', }); @@ -247,46 +296,33 @@ describe('schemas', () => { }); it('requires the master key to create a schema', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', json: true, headers: noAuthHeaders, - body: { - className: 'MyClass', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); - done(); - }); - }); - - it('asks for the master key if you use the rest key', done => { - request.post({ - url: 'http://localhost:8378/1/schemas', - json: true, - headers: restKeyHeaders, body: { className: 'MyClass', }, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); it('sends an error if you use mismatching class names', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas/A', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'B', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, error: 'Class name mismatch between B and A.', }); @@ -295,43 +331,45 @@ describe('schemas', () => { }); it('sends an error if you use no class name', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: 135, error: 'POST /schemas needs a class name.', }); done(); - }) + }); }); it('sends an error if you try to create the same class twice', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'A', }, - }, (error) => { - expect(error).toEqual(null); - request.post({ + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'A', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'Class A already exists.' + error: 'Class A already exists.', }); done(); }); @@ -339,431 +377,1037 @@ describe('schemas', () => { }); it('responds with all fields when you create a class', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { - className: "NewClass", + className: 'NewClass', fields: { - foo: {type: 'Number'}, - ptr: {type: 'Pointer', targetClass: 'SomeClass'} - } - } - }, (error, response, body) => { - expect(body).toEqual({ + foo: { type: 'Number' }, + ptr: { type: 'Pointer', targetClass: 'SomeClass' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - foo: {type: 'Number'}, - ptr: {type: 'Pointer', targetClass: 'SomeClass'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + foo: { type: 'Number' }, + ptr: { type: 'Pointer', targetClass: 'SomeClass' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + done(); + }); + }); + + it('responds with all fields and options when you create a class with field options', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithOptions', + fields: { + foo1: { type: 'Number' }, + foo2: { type: 'Number', required: true, defaultValue: 10 }, + foo3: { + type: 'String', + required: false, + defaultValue: 'some string', + }, + foo4: { type: 'Date', required: true }, + foo5: { type: 'Number', defaultValue: 5 }, + ptr: { type: 'Pointer', targetClass: 'SomeClass', required: false }, + defaultFalse: { + type: 'Boolean', + required: true, + defaultValue: false, + }, + defaultZero: { type: 'Number', defaultValue: 0 }, + relation: { type: 'Relation', targetClass: 'SomeClass' }, + }, + }, + }).then(async response => { + expect(response.data).toEqual({ + className: 'NewClassWithOptions', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + foo1: { type: 'Number' }, + foo2: { type: 'Number', required: true, defaultValue: 10 }, + foo3: { + type: 'String', + required: false, + defaultValue: 'some string', + }, + foo4: { type: 'Date', required: true }, + foo5: { type: 'Number', defaultValue: 5 }, + ptr: { type: 'Pointer', targetClass: 'SomeClass', required: false }, + defaultFalse: { + type: 'Boolean', + required: true, + defaultValue: false, + }, + defaultZero: { type: 'Number', defaultValue: 0 }, + relation: { type: 'Relation', targetClass: 'SomeClass' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); + const obj = new Parse.Object('NewClassWithOptions'); + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.code).toEqual(142); + } + const date = new Date(); + obj.set('foo4', date); + await obj.save(); + expect(obj.get('foo1')).toBeUndefined(); + expect(obj.get('foo2')).toEqual(10); + expect(obj.get('foo3')).toEqual('some string'); + expect(obj.get('foo4')).toEqual(date); + expect(obj.get('foo5')).toEqual(5); + expect(obj.get('ptr')).toBeUndefined(); + expect(obj.get('defaultFalse')).toEqual(false); + expect(obj.get('defaultZero')).toEqual(0); + expect(obj.get('ptr')).toBeUndefined(); + expect(obj.get('relation')).toBeUndefined(); done(); }); }); + it('try to set a relation field as a required field', async done => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithRelationRequired', + fields: { + foo: { type: 'String' }, + relation: { + type: 'Relation', + targetClass: 'SomeClass', + required: true, + }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('try to set a relation field with a default value', async done => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + foo: { type: 'String' }, + relation: { + type: 'Relation', + targetClass: 'SomeClass', + defaultValue: { __type: 'Relation', className: '_User' }, + }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('try to update schemas with a relation field with options', async done => { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + foo: { type: 'String' }, + }, + }, + }); + try { + await request({ + url: 'http://localhost:8378/1/schemas/NewClassRelationWithOptions', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + relation: { + type: 'Relation', + targetClass: 'SomeClass', + required: true, + }, + }, + _method: 'PUT', + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + + try { + await request({ + url: 'http://localhost:8378/1/schemas/NewClassRelationWithOptions', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + relation: { + type: 'Relation', + targetClass: 'SomeClass', + defaultValue: { __type: 'Relation', className: '_User' }, + }, + }, + _method: 'PUT', + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('validated the data type of default values when creating a new class', async () => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo: { type: 'String', defaultValue: 10 }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.error).toEqual( + 'schema mismatch for NewClassWithValidation.foo default value; expected String but got Number' + ); + } + }); + + it('validated the data type of default values when adding new fields', async () => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo: { type: 'String', defaultValue: 'some value' }, + }, + }, + }); + await request({ + url: 'http://localhost:8378/1/schemas/NewClassWithValidation', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo2: { type: 'String', defaultValue: 10 }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.error).toEqual( + 'schema mismatch for NewClassWithValidation.foo2 default value; expected String but got Number' + ); + } + }); + it('responds with all fields when getting incomplete schema', done => { - config.database.loadSchema() - .then(schemaController => schemaController.addClassIfNotExists('_Installation', {}, defaultClassLevelPermissions)) + config.database + .loadSchema() + .then(schemaController => + schemaController.addClassIfNotExists( + '_Installation', + {}, + defaultClassLevelPermissions + ) + ) .then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas/_Installation', headers: masterKeyHeaders, - json: true - }, (error, response, body) => { - expect(dd(body,{ - className: '_Installation', - fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - installationId: {type: 'String'}, - deviceToken: {type: 'String'}, - channels: {type: 'Array'}, - deviceType: {type: 'String'}, - pushType: {type: 'String'}, - GCMSenderId: {type: 'String'}, - timeZone: {type: 'String'}, - badge: {type: 'Number'}, - appIdentifier: {type: 'String'}, - localeIdentifier: {type: 'String'}, - appVersion: {type: 'String'}, - appName: {type: 'String'}, - parseVersion: {type: 'String'}, - ACL: {type: 'ACL'} - }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); + json: true, + }).then(response => { + expect( + dd(response.data, { + className: '_Installation', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + badge: { type: 'Number' }, + appIdentifier: { type: 'String' }, + localeIdentifier: { type: 'String' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + parseVersion: { type: 'String' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toBeUndefined(); done(); }); }) .catch(error => { - fail(JSON.stringify(error)) + fail(JSON.stringify(error)); done(); }); }); it('lets you specify class name in both places', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: { - className: "NewClass", - } - }, (error, response, body) => { - expect(body).toEqual({ + className: 'NewClass', + }, + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); done(); }); }); it('requires the master key to modify schemas', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, () => { - request.put({ + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: noAuthHeaders, json: true, body: {}, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); }); it('rejects class name mis-matches in put', done => { - request.put({ + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: masterKeyHeaders, json: true, - body: {className: 'WrongClassName'} - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class name mismatch between WrongClassName and NewClass.'); + body: { className: 'WrongClassName' }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual( + 'Class name mismatch between WrongClassName and NewClass.' + ); done(); }); }); it('refuses to add fields to non-existent classes', done => { - request.put({ + request({ url: 'http://localhost:8378/1/schemas/NoClass', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class NoClass does not exist.'); + newField: { type: 'String' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual('Class NoClass does not exist.'); done(); }); }); it('refuses to put to existing fields, even if it would not be a change', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - body: { - fields: { - aString: {type: 'String'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toEqual('Field aString exists, cannot update.'); - done(); - }); - }) + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toEqual( + 'Field aString exists, cannot update.' + ); + done(); + }); + }); }); it('refuses to delete non-existent fields', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - body: { - fields: { - nonExistentKey: {__op: "Delete"}, - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toEqual('Field nonExistentKey does not exist, cannot delete.'); - done(); - }); + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + nonExistentKey: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toEqual( + 'Field nonExistentKey does not exist, cannot delete.' + ); + done(); }); + }); }); it('refuses to add a geopoint to a class that already has one', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - body: { - fields: { - newGeo: {type: 'GeoPoint'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.'); - done(); - }); + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newGeo: { type: 'GeoPoint' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.' + ); + done(); }); + }); }); it('refuses to add two geopoints', done => { - var obj = new Parse.Object('NewClass'); + const obj = new Parse.Object('NewClass'); obj.set('aString', 'aString'); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/NewClass', - headers: masterKeyHeaders, - json: true, - body: { - fields: { - newGeo1: {type: 'GeoPoint'}, - newGeo2: {type: 'GeoPoint'}, - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.'); - done(); - }); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newGeo1: { type: 'GeoPoint' }, + newGeo2: { type: 'GeoPoint' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.' + ); + done(); }); + }); }); it('allows you to delete and add a geopoint in the same request', done => { - var obj = new Parse.Object('NewClass'); - obj.set('geo1', new Parse.GeoPoint({latitude: 0, longitude: 0})); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/NewClass', - headers: masterKeyHeaders, - json: true, - body: { + const obj = new Parse.Object('NewClass'); + obj.set('geo1', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + geo2: { type: 'GeoPoint' }, + geo1: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', fields: { - geo2: {type: 'GeoPoint'}, - geo1: {__op: 'Delete'} - } - } - }, (error, response, body) => { - expect(dd(body, { - "className": "NewClass", - "fields": { - "ACL": {"type": "ACL"}, - "createdAt": {"type": "Date"}, - "objectId": {"type": "String"}, - "updatedAt": {"type": "Date"}, - "geo2": {"type": "GeoPoint"}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + geo2: { type: 'GeoPoint' }, }, - classLevelPermissions: defaultClassLevelPermissions - })).toEqual(undefined); - done(); - }); - }) + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); + done(); + }); + }); }); it('put with no modifications returns all fields', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); + done(); + }); + }); + }); + + it('lets you add fields', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newField: { type: 'String' }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + newField: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, - body: {}, - }, (error, response, body) => { - expect(body).toEqual(plainOldDataSchema); + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + newField: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); done(); }); - }) + }); + }); }); - it('lets you add fields', done => { - request.post({ + it('lets you add fields with options', done => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, () => { - request.put({ + }).then(() => { + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(dd(body, { - className: 'NewClass', - fields: { - "ACL": {"type": "ACL"}, - "createdAt": {"type": "Date"}, - "objectId": {"type": "String"}, - "updatedAt": {"type": "Date"}, - "newField": {"type": "String"}, - }, - classLevelPermissions: defaultClassLevelPermissions - })).toEqual(undefined); - request.get({ + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); + request({ url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(body).toEqual({ + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - newField: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); done(); }); }); - }) + }); + }); + + it('should validate required fields', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newRequiredField: { + type: 'String', + required: true, + }, + newRequiredFieldWithDefaultValue: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + newNotRequiredField: { + type: 'String', + required: false, + }, + newNotRequiredFieldWithDefaultValue: { + type: 'String', + required: false, + defaultValue: 'some value', + }, + newRegularFieldWithDefaultValue: { + type: 'String', + defaultValue: 'some value', + }, + newRegularField: { + type: 'String', + }, + }, + }, + }).then(async () => { + let obj = new Parse.Object('NewClass'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); + } + obj.set('newRequiredField', 'some value'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual( + 'some value' + ); + expect(obj.get('newNotRequiredField')).toEqual(undefined); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual( + 'some value' + ); + expect(obj.get('newRegularField')).toEqual(undefined); + obj.set('newRequiredField', null); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); + } + obj.unset('newRequiredField'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); + } + obj.set('newRequiredField', 'some value2'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value2'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual( + 'some value' + ); + expect(obj.get('newNotRequiredField')).toEqual(undefined); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual( + 'some value' + ); + expect(obj.get('newRegularField')).toEqual(undefined); + obj.unset('newRequiredFieldWithDefaultValue'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual( + 'newRequiredFieldWithDefaultValue is required' + ); + } + obj.set('newRequiredFieldWithDefaultValue', ''); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual( + 'newRequiredFieldWithDefaultValue is required' + ); + } + obj.set('newRequiredFieldWithDefaultValue', 'some value2'); + obj.set('newNotRequiredField', ''); + obj.set('newNotRequiredFieldWithDefaultValue', null); + obj.unset('newRegularField'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value2'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual( + 'some value2' + ); + expect(obj.get('newNotRequiredField')).toEqual(''); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual(null); + expect(obj.get('newRegularField')).toEqual(undefined); + obj = new Parse.Object('NewClass'); + obj.set('newRequiredField', 'some value3'); + obj.set('newRequiredFieldWithDefaultValue', 'some value3'); + obj.set('newNotRequiredField', 'some value3'); + obj.set('newNotRequiredFieldWithDefaultValue', 'some value3'); + obj.set('newRegularField', 'some value3'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value3'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual( + 'some value3' + ); + expect(obj.get('newNotRequiredField')).toEqual('some value3'); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual( + 'some value3' + ); + expect(obj.get('newRegularField')).toEqual('some value3'); + done(); + }); + }); + }); + + it('should validate required fields and set default values after before save trigger', async () => { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForBeforeSaveTest', + fields: { + foo1: { type: 'String' }, + foo2: { type: 'String', required: true }, + foo3: { + type: 'String', + required: true, + defaultValue: 'some default value 3', + }, + foo4: { type: 'String', defaultValue: 'some default value 4' }, + }, + }, + }); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + req.object.set('foo3', 'some value 3'); + req.object.set('foo4', 'some value 4'); + }); + + let obj = new Parse.Object('NewClassForBeforeSaveTest'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some value 3'); + expect(obj.get('foo4')).toEqual('some value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some default value 3'); + expect(obj.get('foo4')).toEqual('some default value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some default value 3'); + expect(obj.get('foo4')).toEqual('some default value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', undefined); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo2', 'some value 2'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.message).toEqual('foo2 is required'); + } + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.unset('foo2'); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo2', 'some value 2'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.message).toEqual('foo2 is required'); + } }); it('lets you add fields to system schema', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/_User', headers: masterKeyHeaders, - json: true - }, () => { - request.put({ + json: true, + }).then(fail, () => { + request({ url: 'http://localhost:8378/1/schemas/_User', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(dd(body,{ - className: '_User', - fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - username: {type: 'String'}, - password: {type: 'String'}, - email: {type: 'String'}, - emailVerified: {type: 'Boolean'}, - authData: {type: 'Object'}, - newField: {type: 'String'}, - ACL: {type: 'ACL'} - }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); - request.get({ - url: 'http://localhost:8378/1/schemas/_User', - headers: masterKeyHeaders, - json: true - }, (error, response, body) => { - expect(dd(body,{ + newField: { type: 'String' }, + }, + }, + }).then(response => { + expect( + dd(response.data, { className: '_User', fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - username: {type: 'String'}, - password: {type: 'String'}, - email: {type: 'String'}, - emailVerified: {type: 'Boolean'}, - authData: {type: 'Object'}, - newField: {type: 'String'}, - ACL: {type: 'ACL'} + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + newField: { type: 'String' }, + ACL: { type: 'ACL' }, }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); + classLevelPermissions: { + ...defaultClassLevelPermissions, + protectedFields: { + '*': ['email'], + }, + }, + }) + ).toBeUndefined(); + request({ + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect( + dd(response.data, { + className: '_User', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + newField: { type: 'String' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toBeUndefined(); done(); }); }); - }) + }); }); it('lets you delete multiple fields and check schema', done => { - var simpleOneObject = () => { - var obj = new Parse.Object('SimpleOne'); + const simpleOneObject = () => { + const obj = new Parse.Object('SimpleOne'); obj.set('aNumber', 5); obj.set('aString', 'string'); obj.set('aBool', true); return obj; }; - simpleOneObject().save() + simpleOneObject() + .save() .then(() => { - request.put({ + request({ url: 'http://localhost:8378/1/schemas/SimpleOne', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - aString: {__op: 'Delete'}, - aNumber: {__op: 'Delete'}, - } - } - }, (error, response, body) => { - expect(body).toEqual({ + aString: { __op: 'Delete' }, + aNumber: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ className: 'SimpleOne', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields - aBool: {type: 'Boolean'}, + aBool: { type: 'Boolean' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); done(); @@ -771,250 +1415,273 @@ describe('schemas', () => { }); }); - it_exclude_dbs(['postgres'])('lets you delete multiple fields and add fields', done => { - var obj1 = hasAllPODobject(); - obj1.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - body: { - fields: { - aString: {__op: 'Delete'}, - aNumber: {__op: 'Delete'}, - aNewString: {type: 'String'}, - aNewNumber: {type: 'Number'}, - aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, - aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, - } - } - }, (error, response, body) => { - expect(body).toEqual({ - className: 'HasAllPOD', - fields: { + it('lets you delete multiple fields and add fields', done => { + const obj1 = hasAllPODobject(); + obj1.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { __op: 'Delete' }, + aNumber: { __op: 'Delete' }, + aNewString: { type: 'String' }, + aNewNumber: { type: 'Number' }, + aNewRelation: { type: 'Relation', targetClass: 'HasAllPOD' }, + aNewPointer: { type: 'Pointer', targetClass: 'HasAllPOD' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'HasAllPOD', + fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - //Custom fields - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'}, - aNewNumber: {type: 'Number'}, - aNewString: {type: 'String'}, - aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, - aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, - }, - classLevelPermissions: defaultClassLevelPermissions - }); - var obj2 = new Parse.Object('HasAllPOD'); - obj2.set('aNewPointer', obj1); - var relation = obj2.relation('aNewRelation'); - relation.add(obj1); - obj2.save().then(done); //Just need to make sure saving works on the new object. + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + //Custom fields + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, + aNewNumber: { type: 'Number' }, + aNewString: { type: 'String' }, + aNewPointer: { type: 'Pointer', targetClass: 'HasAllPOD' }, + aNewRelation: { type: 'Relation', targetClass: 'HasAllPOD' }, + }, + classLevelPermissions: defaultClassLevelPermissions, }); + const obj2 = new Parse.Object('HasAllPOD'); + obj2.set('aNewPointer', obj1); + const relation = obj2.relation('aNewRelation'); + relation.add(obj1); + obj2.save().then(done); //Just need to make sure saving works on the new object. }); + }); }); it('will not delete any fields if the additions are invalid', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + fakeNewField: { type: 'fake type' }, + aString: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual('invalid field type: fake type'); + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/HasAllPOD', headers: masterKeyHeaders, json: true, - body: { - fields: { - fakeNewField: {type: 'fake type'}, - aString: {__op: 'Delete'} - } - } - }, (error, response, body) => { - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('invalid field type: fake type'); - request.get({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - }, (error, response) => { - expect(response.body).toEqual(plainOldDataSchema); - done(); - }); + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); + done(); }); }); + }); }); it('requires the master key to delete schemas', done => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/DoesntMatter', + method: 'DELETE', headers: noAuthHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); it('refuses to delete non-empty collection', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.del({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toMatch(/HasAllPOD/); - expect(body.error).toMatch(/contains 1/); - done(); - }); + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'DELETE', + headers: masterKeyHeaders, + json: true, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toMatch(/HasAllPOD/); + expect(response.data.error).toMatch(/contains 1/); + done(); }); + }); }); it('fails when deleting collections with invalid class names', done => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/_GlobalConfig', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Invalid classname: _GlobalConfig, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual( + 'Invalid classname: _GlobalConfig, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); done(); - }) + }); }); it('does not fail when deleting nonexistant collections', done => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/Missing', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); done(); }); }); it('deletes collections including join tables', done => { - var obj = new Parse.Object('MyClass'); + const obj = new Parse.Object('MyClass'); obj.set('data', 'data'); - obj.save() + obj + .save() .then(() => { - var obj2 = new Parse.Object('MyOtherClass'); - var relation = obj2.relation('aRelation'); + const obj2 = new Parse.Object('MyOtherClass'); + const relation = obj2.relation('aRelation'); relation.add(obj); return obj2.save(); }) .then(obj2 => obj2.destroy()) .then(() => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/MyOtherClass', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); - config.database.collectionExists('_Join:aRelation:MyOtherClass').then(exists => { - if (exists) { - fail('Relation collection should be deleted.'); - done(); - } - return config.database.collectionExists('MyOtherClass'); - }).then(exists => { - if (exists) { - fail('Class collection should be deleted.'); - done(); - } - }).then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas/MyOtherClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - //Expect _SCHEMA entry to be gone. - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class MyOtherClass does not exist.'); - done(); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); + config.database + .collectionExists('_Join:aRelation:MyOtherClass') + .then(exists => { + if (exists) { + fail('Relation collection should be deleted.'); + done(); + } + return config.database.collectionExists('MyOtherClass'); + }) + .then(exists => { + if (exists) { + fail('Class collection should be deleted.'); + done(); + } + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/MyOtherClass', + headers: masterKeyHeaders, + json: true, + }).then(fail, response => { + //Expect _SCHEMA entry to be gone. + expect(response.status).toEqual(400); + expect(response.data.code).toEqual( + Parse.Error.INVALID_CLASS_NAME + ); + expect(response.data.error).toEqual( + 'Class MyOtherClass does not exist.' + ); + done(); + }); }); - }); }); - }).then(() => { - }, error => { - fail(error); - done(); - }); + }) + .then( + () => {}, + error => { + fail(error); + done(); + } + ); }); it('deletes schema when actual collection does not exist', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, body: { - className: 'NewClassForDelete' - } - }, (error, response) => { - expect(error).toEqual(null); - expect(response.body.className).toEqual('NewClassForDelete'); - request.del({ + className: 'NewClassForDelete', + }, + }).then(response => { + expect(response.data.className).toEqual('NewClassForDelete'); + request({ url: 'http://localhost:8378/1/schemas/NewClassForDelete', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); config.database.loadSchema().then(schema => { schema.hasClass('NewClassForDelete').then(exist => { expect(exist).toEqual(false); done(); }); - }) + }); }); }); }); it('deletes schema when actual collection exists', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, body: { - className: 'NewClassForDelete' - } - }, (error, response) => { - expect(error).toEqual(null); - expect(response.body.className).toEqual('NewClassForDelete'); - request.post({ + className: 'NewClassForDelete', + }, + }).then(response => { + expect(response.data.className).toEqual('NewClassForDelete'); + request({ url: 'http://localhost:8378/1/classes/NewClassForDelete', + method: 'POST', headers: restKeyHeaders, - json: true - }, (error, response) => { - expect(error).toEqual(null); - expect(typeof response.body.objectId).toEqual('string'); - request.del({ - url: 'http://localhost:8378/1/classes/NewClassForDelete/' + response.body.objectId, + json: true, + }).then(response => { + expect(typeof response.data.objectId).toEqual('string'); + request({ + method: 'DELETE', + url: + 'http://localhost:8378/1/classes/NewClassForDelete/' + + response.data.objectId, headers: restKeyHeaders, json: true, - }, (error) => { - expect(error).toEqual(null); - request.del({ + }).then(() => { + request({ + method: 'DELETE', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, - }, (error, response) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); config.database.loadSchema().then(schema => { schema.hasClass('NewClassForDelete').then(exist => { expect(exist).toEqual(false); @@ -1028,39 +1695,41 @@ describe('schemas', () => { }); it('should set/get schema permissions', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': true + '*': true, }, create: { - 'role:admin': true - } - } - } - }, (error) => { - expect(error).toEqual(null); - request.get({ + 'role:admin': true, + }, + }, + }, + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, - }, (error, response) => { - expect(response.statusCode).toEqual(200); - expect(response.body.classLevelPermissions).toEqual({ + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data.classLevelPermissions).toEqual({ find: { - '*': true + '*': true, }, create: { - 'role:admin': true + 'role:admin': true, }, get: {}, + count: {}, update: {}, delete: {}, - addField: {} + addField: {}, + protectedFields: {}, }); done(); }); @@ -1068,244 +1737,313 @@ describe('schemas', () => { }); it('should fail setting schema permissions with invalid key', done => { - const object = new Parse.Object('AClass'); object.save().then(() => { - request.put({ + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': true + '*': true, }, create: { - 'role:admin': true + 'role:admin': true, }, dummy: { - 'some': true - } - } - } - }, (error, response, body) => { - expect(error).toEqual(null); - expect(body.code).toEqual(107); - expect(body.error).toEqual('dummy is not a valid operation for class level permissions'); + some: true, + }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toEqual(107); + expect(response.data.error).toEqual( + 'dummy is not a valid operation for class level permissions' + ); done(); }); }); }); it('should not be able to add a field', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { create: { - '*': true + '*': true, }, find: { - '*': true + '*': true, }, addField: { - 'role:admin': true - } - } - } - }, (error) => { - expect(error).toEqual(null); + 'role:admin': true, + }, + }, + }, + }).then(() => { const object = new Parse.Object('AClass'); object.set('hello', 'world'); - return object.save().then(() => { - fail('should not be able to add a field'); - done(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action addField on class AClass.'); - done(); - }) - }) + return object.save().then( + () => { + fail('should not be able to add a field'); + done(); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action addField on class AClass.' + ); + done(); + } + ); + }); }); it('should be able to add a field', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { create: { - '*': true + '*': true, }, addField: { - '*': true - } - } - } - }, (error) => { - expect(error).toEqual(null); + '*': true, + }, + }, + }, + }).then(() => { const object = new Parse.Object('AClass'); object.set('hello', 'world'); - return object.save().then(() => { - done(); - }, () => { - fail('should be able to add a field'); - done(); - }) - }) + return object.save().then( + () => { + done(); + }, + () => { + fail('should be able to add a field'); + done(); + } + ); + }); }); - it('should throw with invalid userId (>10 chars)', done => { - request.post({ + it('should aceept class-level permission with userid of any length', async done => { + await global.reconfigureServer({ + customIdSize: 11, + }); + + const id = 'e1evenChars'; + + const { data } = await request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '1234567890A': true + [id]: true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'1234567890A' is not a valid key for class level permissions"); - done(); - }) - }); + }, + }, + }); + + expect(data.classLevelPermissions.find[id]).toBe(true); + + done(); + }); + + it('should allow set class-level permission for custom userid of any length and chars', async done => { + await global.reconfigureServer({ + allowCustomObjectId: true, + }); - it('should throw with invalid userId (<10 chars)', done => { - request.post({ + const symbolsId = 'set:ID+symbol$=@llowed'; + const shortId = '1'; + const { data } = await request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - 'a12345678': true + [symbolsId]: true, + [shortId]: true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'a12345678' is not a valid key for class level permissions"); - done(); - }) + }, + }, + }); + + expect(data.classLevelPermissions.find[symbolsId]).toBe(true); + expect(data.classLevelPermissions.find[shortId]).toBe(true); + + done(); + }); + + it('should allow set ACL for custom userid', async done => { + await global.reconfigureServer({ + allowCustomObjectId: true, + }); + + const symbolsId = 'symbols:id@allowed='; + const shortId = '1'; + const normalId = 'tensymbols'; + + const { data } = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/AClass', + headers: masterKeyHeaders, + json: true, + body: { + ACL: { + [symbolsId]: { read: true, write: true }, + [shortId]: { read: true, write: true }, + [normalId]: { read: true, write: true }, + }, + }, + }); + + const { data: created } = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/AClass/${data.objectId}`, + headers: masterKeyHeaders, + json: true, + }); + + expect(created.ACL[normalId].write).toBe(true); + expect(created.ACL[symbolsId].write).toBe(true); + expect(created.ACL[shortId].write).toBe(true); + done(); }); it('should throw with invalid userId (invalid char)', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '12345_6789': true + '12345_6789': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'12345_6789' is not a valid key for class level permissions"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'12345_6789' is not a valid key for class level permissions" + ); done(); - }) + }); }); - it('should throw with invalid * (spaces)', done => { - request.post({ + it('should throw with invalid * (spaces before)', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - ' *': true + ' *': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("' *' is not a valid key for class level permissions"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "' *' is not a valid key for class level permissions" + ); done(); - }) + }); }); - it('should throw with invalid * (spaces)', done => { - request.post({ + it('should throw with invalid * (spaces after)', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '* ': true + '* ': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'* ' is not a valid key for class level permissions"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'* ' is not a valid key for class level permissions" + ); done(); - }) + }); }); - it('should throw with invalid value', done => { - request.post({ + it('should throw if permission is number', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': 1 + '*': 1, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'1' is not a valid value for class level permissions find:*:1"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'1' is not a valid value for class level permissions find:*:1" + ); done(); - }) + }); }); - it('should throw with invalid value', done => { - request.post({ + it('should throw if permission is empty string', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': "" + '*': '', }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'' is not a valid value for class level permissions find:*:"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'' is not a valid value for class level permissions find:*:" + ); done(); - }) + }); }); function setPermissionsOnClass(className, permissions, doPut) { - let op = request.post; - if (doPut) - { - op = request.put; - } - return new Promise((resolve, reject) => { - op({ - url: 'http://localhost:8378/1/schemas/' + className, - headers: masterKeyHeaders, - json: true, - body: { - classLevelPermissions: permissions - } - }, (error, response, body) => { - if (error) { - return reject(error); - } - if (body.error) { - return reject(body); - } - return resolve(body); - }) + return request({ + url: 'http://localhost:8378/1/schemas/' + className, + method: doPut ? 'PUT' : 'POST', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: permissions, + }, + }).then(response => { + if (response.data.error) { + throw response.data; + } + return response.data; }); } @@ -1321,39 +2059,54 @@ describe('schemas', () => { const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - const obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then(() => { - fail('Use should hot be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }).catch((err) => { - jfail(err); - done(); - }) + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('Use should hot be able to find!'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); it('validate CLP 2', done => { @@ -1368,55 +2121,79 @@ describe('schemas', () => { const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - const obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then(() => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // let everyone see it now - return setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true, - '*': true - } - }, true); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then((result) => { - expect(result.length).toBe(1); - }, () => { - fail('User should be able to find!') + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ); + }) + .then(() => { + // let everyone see it now + return setPermissionsOnClass( + 'AClass', + { + find: { + 'role:admin': true, + '*': true, + }, + }, + true + ); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + result => { + expect(result.length).toBe(1); + }, + () => { + fail('User should be able to find!'); + done(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); done(); }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }).catch((err) => { - jfail(err); - done(); - }) }); it('validate CLP 3', done => { @@ -1431,50 +2208,70 @@ describe('schemas', () => { const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - const obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then(() => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // delete all CLP - return setPermissionsOnClass('AClass', null, true); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then((result) => { - expect(result.length).toBe(1); - }, () => { - fail('User should be able to find!') + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ); + }) + .then(() => { + // delete all CLP + return setPermissionsOnClass('AClass', null, true); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + result => { + expect(result.length).toBe(1); + }, + () => { + fail('User should be able to find!'); + done(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); done(); }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }).catch((err) => { - jfail(err); - done(); - }); }); it('validate CLP 4', done => { @@ -1489,58 +2286,87 @@ describe('schemas', () => { const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - const obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then(() => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // borked CLP should not affec security - return setPermissionsOnClass('AClass', { - 'found': { - 'role:admin': true - } - }, true).then(() => { - fail("Should not be able to save a borked CLP"); - }, () => { - return Promise.resolve(); + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then(() => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ); + }) + .then(() => { + // borked CLP should not affec security + return setPermissionsOnClass( + 'AClass', + { + found: { + 'role:admin': true, + }, + }, + true + ).then( + () => { + fail('Should not be able to save a borked CLP'); + }, + () => { + return Promise.resolve(); + } + ); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + done(); }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }).catch((err) => { - jfail(err); - done(); - }) }); it('validate CLP 5', done => { @@ -1557,203 +2383,1231 @@ describe('schemas', () => { const role = new Parse.Role('admin', new Parse.ACL()); - Promise.resolve().then(() => { - return Parse.Object.saveAll([user, user2, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}).then(() => { - const perm = { - find: {} - }; - // let the user find - perm['find'][user.id] = true; - return setPermissionsOnClass('AClass', perm); + Promise.resolve() + .then(() => { + return Parse.Object.saveAll([user, user2, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - const obj = new Parse.Object('AClass'); - return obj.save(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }).then(() => { + const perm = { + find: {}, + }; + // let the user find + perm['find'][user.id] = true; + return setPermissionsOnClass('AClass', perm); + }); }) - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find().then((res) => { - expect(res.length).toEqual(1); - }, () => { - fail('User should be able to find!') - return Promise.resolve(); + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(); + }); }) - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then(() => { - fail("should not be able to read!"); - return Promise.resolve(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action create on class AClass.'); - return Promise.resolve(); - }).then(() => { - return Parse.User.logIn('user2', 'user2'); - }).then(() => { - const query = new Parse.Query('AClass'); - return query.find(); - }).then(() => { - fail("should not be able to read!"); - return Promise.resolve(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); - }).then(() => { - done(); - }); - }); - - it('can query with include and CLP (issue #2005)', (done) => { - setPermissionsOnClass('AnotherObject', { - get: {"*": true}, - find: {}, - create: {'*': true}, - update: {'*': true}, - delete: {'*': true}, - addField:{'*': true} - }).then(() => { - const obj = new Parse.Object('AnObject'); - const anotherObject = new Parse.Object('AnotherObject'); - return obj.save({ - anotherObject + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + res => { + expect(res.length).toEqual(1); + }, + () => { + fail('User should be able to find!'); + return Promise.resolve(); + } + ); }) - }).then(() => { - const query = new Parse.Query('AnObject'); - query.include('anotherObject'); - return query.find(); - }).then((res) => { - expect(res.length).toBe(1); - expect(res[0].get('anotherObject')).not.toBeUndefined(); - done(); - }).catch((err) => { - jfail(err); - done(); + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then( + () => { + fail('should not be able to read!'); + return Promise.resolve(); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action create on class AClass.' + ); + return Promise.resolve(); + } + ) + .then(() => { + return Parse.User.logIn('user2', 'user2'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then( + () => { + fail('should not be able to read!'); + return Promise.resolve(); + }, + err => { + expect(err.message).toEqual( + 'Permission denied for action find on class AClass.' + ); + return Promise.resolve(); + } + ) + .then(() => { + done(); + }); + }); + + it('can query with include and CLP (issue #2005)', done => { + setPermissionsOnClass('AnotherObject', { + get: { '*': true }, + find: {}, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, }) + .then(() => { + const obj = new Parse.Object('AnObject'); + const anotherObject = new Parse.Object('AnotherObject'); + return obj.save({ + anotherObject, + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('anotherObject'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + expect(res[0].get('anotherObject')).not.toBeUndefined(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('can add field as master (issue #1257)', (done) => { + it('can add field as master (issue #1257)', done => { setPermissionsOnClass('AClass', { - 'addField': {} - }).then(() => { - var obj = new Parse.Object('AClass'); - obj.set('key', 'value'); - return obj.save(null, {useMasterKey: true}) - }).then((obj) => { - expect(obj.get('key')).toEqual('value'); - done(); - }, () => { - fail('should not fail'); - done(); - }); + addField: {}, + }) + .then(() => { + const obj = new Parse.Object('AClass'); + obj.set('key', 'value'); + return obj.save(null, { useMasterKey: true }); + }) + .then( + obj => { + expect(obj.get('key')).toEqual('value'); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); }); - it('can login when addFields is false (issue #1355)', (done) => { - setPermissionsOnClass('_User', { - 'create': {'*': true}, - 'addField': {} - }, true).then(() => { - return Parse.User.signUp('foo', 'bar'); - }).then((user) => { - expect(user.getUsername()).toBe('foo'); - done() - }, error => { - fail(JSON.stringify(error)); - done(); + it('can login when addFields is false (issue #1355)', done => { + setPermissionsOnClass( + '_User', + { + create: { '*': true }, + addField: {}, + }, + true + ) + .then(() => { + return Parse.User.signUp('foo', 'bar'); + }) + .then( + user => { + expect(user.getUsername()).toBe('foo'); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); + }); + + it('unset field in beforeSave should not stop object creation', done => { + const hook = { + method: function(req) { + if (req.object.get('undesiredField')) { + req.object.unset('undesiredField'); + } + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeSave('AnObject', hook.method); + setPermissionsOnClass('AnObject', { + get: { '*': true }, + find: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, }) - }) + .then(() => { + const obj = new Parse.Object('AnObject'); + obj.set('desiredField', 'createMe'); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + const obj = new Parse.Object('AnObject'); + obj.set('desiredField', 'This value should be kept'); + obj.set('undesiredField', 'This value should be IGNORED'); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(2); + expect(results[0].has('desiredField')).toBe(true); + expect(results[1].has('desiredField')).toBe(true); + expect(results[0].has('undesiredField')).toBe(false); + expect(results[1].has('undesiredField')).toBe(false); + expect(hook.method).toHaveBeenCalled(); + done(); + }); + }); it('gives correct response when deleting a schema with CLPs (regression test #1919)', done => { - new Parse.Object('MyClass').save({ data: 'foo'}) + new Parse.Object('MyClass') + .save({ data: 'foo' }) .then(obj => obj.destroy()) .then(() => setPermissionsOnClass('MyClass', { find: {}, get: {} }, true)) .then(() => { - request.del({ + request({ + method: 'DELETE', url: 'http://localhost:8378/1/schemas/MyClass', headers: masterKeyHeaders, json: true, - }, (error, response) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); done(); }); }); }); - it("regression test for #1991", done => { + it('regression test for #1991', done => { const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); const role = new Parse.Role('admin', new Parse.ACL()); const obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, role]).then(() => { - role.relation('users').add(user); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return setPermissionsOnClass('AnObject', { - 'get': {"*": true}, - 'find': {"*": true}, - 'create': {'*': true}, - 'update': {'role:admin': true}, - 'delete': {'role:admin': true} + Parse.Object.saveAll([user, role]) + .then(() => { + role.relation('users').add(user); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - return obj.save(); - }).then(() => { - return Parse.User.logIn('user', 'user') - }).then(() => { - return obj.destroy(); - }).then(() => { - const query = new Parse.Query('AnObject'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(0); - done(); - }).catch((err) => { - fail('should not fail'); - jfail(err); - done(); - }); + .then(() => { + return setPermissionsOnClass('AnObject', { + get: { '*': true }, + find: { '*': true }, + create: { '*': true }, + update: { 'role:admin': true }, + delete: { 'role:admin': true }, + }); + }) + .then(() => { + return obj.save(); + }) + .then(() => { + return Parse.User.logIn('user', 'user'); + }) + .then(() => { + return obj.destroy(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(0); + done(); + }) + .catch(err => { + fail('should not fail'); + jfail(err); + done(); + }); + }); + + it('regression test for #4409 (indexes override the clp)', done => { + setPermissionsOnClass( + '_Role', + { + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + }, + true + ) + .then(() => { + const config = Config.get('test'); + return config.database.adapter.updateSchemaWithIndexes(); + }) + .then(() => { + return request({ + url: 'http://localhost:8378/1/schemas/_Role', + headers: masterKeyHeaders, + json: true, + }); + }) + .then(res => { + expect(res.data.classLevelPermissions).toEqual({ + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }); + }) + .then(done) + .catch(done.fail); + }); + + it('regression test for #5177', async () => { + Parse.Object.disableSingleInstance(); + Parse.Cloud.beforeSave('AClass', () => {}); + await setPermissionsOnClass( + 'AClass', + { + update: { '*': true }, + }, + false + ); + const obj = new Parse.Object('AClass'); + await obj.save({ key: 1 }, { useMasterKey: true }); + obj.increment('key', 10); + const objectAgain = await obj.save(); + expect(objectAgain.get('key')).toBe(11); }); it('regression test for #2246', done => { const profile = new Parse.Object('UserProfile'); const user = new Parse.User(); function initialize() { - return user.save({ - username: 'user', - password: 'password' + return user + .save({ + username: 'user', + password: 'password', + }) + .then(() => { + return profile.save({ user }).then(() => { + return user.save( + { + userProfile: profile, + }, + { useMasterKey: true } + ); + }); + }); + } + + initialize() + .then(() => { + return setPermissionsOnClass( + 'UserProfile', + { + readUserFields: ['user'], + writeUserFields: ['user'], + }, + true + ); + }) + .then(() => { + return Parse.User.logIn('user', 'password'); + }) + .then(() => { + const query = new Parse.Query('_User'); + query.include('userProfile'); + return query.get(user.id); + }) + .then( + user => { + expect(user.get('userProfile')).not.toBeUndefined(); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should reject creating class schema with field with invalid key', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const fieldName = '1invalid'; + + const schemaCreation = () => + schemaController.addClassIfNotExists('AnObject', { + [fieldName]: { __type: 'String' }, + }); + + await expectAsync(schemaCreation()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `invalid field name: ${fieldName}` + ) + ); + done(); + }); + + it('should reject creating invalid field name', async done => { + const object = new Parse.Object('AnObject'); + + await expectAsync( + object.save({ + '!12field': 'field', + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.INVALID_KEY_NAME)); + done(); + }); + + it('should be rejected if CLP operation is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP protectedFields is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = 'wrongtype'; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP read/writeUserFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'readUserFields'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ) + ); + + done(); + }); + + it('should be rejected if CLP pointerFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const entity = 'pointerFields'; + const value = {}; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: { + [entity]: value, + }, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${value}' is not a valid value for ${operationKey}[${entity}] - expected an array.` + ) + ); + + done(); + }); + + describe('index management', () => { + beforeEach(() => require('../lib/TestUtils').destroyAllDataPermanently()); + it('cannot create index if field does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, }).then(() => { - return profile.save({user}).then(() => { - return user.save({ - userProfile: profile - }, {useMasterKey: true}); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe( + 'Field aString does not exist, cannot add index.' + ); + done(); }); }); - } + }); - initialize().then(() => { - return setPermissionsOnClass('UserProfile', { - 'readUserFields': ['user'], - 'writeUserFields': ['user'] - }, true); - }).then(() => { - return Parse.User.logIn('user', 'password') - }).then(() => { - const query = new Parse.Query('_User'); - query.include('userProfile'); - return query.get(user.id); - }).then((user) => { - expect(user.get('userProfile')).not.toBeUndefined(); - done(); - }, (err) => { - jfail(err); - done(); + it('can create index on default field', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { createdAt: 1 }, + }, + }, + }).then(response => { + expect(response.data.indexes.name1).toEqual({ createdAt: 1 }); + done(); + }); + }); + }); + + it('cannot create compound index if field does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1, bString: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe( + 'Field bString does not exist, cannot add index.' + ); + done(); + }); + }); }); + + it('allows add index when you create a class', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClass', + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + name1: { aString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toBe(2); + done(); + }); + }); + }); + + it('empty index returns nothing', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClass', + fields: { + aString: { type: 'String' }, + }, + indexes: {}, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + done(); + }); + }); + + it('lets you add indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }); + }); + + it('lets you add multiple indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(4); + done(); + }); + }); + }); + }); + }); + + it('lets you delete indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(1); + done(); + }); + }); + }); + }); + }); + + it('lets you delete multiple indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }); + }); + + it('lets you add and delete indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + name4: { dString: 1 }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + name4: { dString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(3); + done(); + }); + }); + }); + }); + }); + + it('cannot delete index that does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + unknownIndex: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe( + 'Index unknownIndex does not exist, cannot delete.' + ); + done(); + }); + }); + }); + + it('cannot update index that exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { field2: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe( + 'Index name1 exists, cannot update.' + ); + done(); + }); + }); + }); + }); + + it_exclude_dbs(['postgres'])('get indexes on startup', done => { + const obj = new Parse.Object('TestObject'); + obj + .save() + .then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data.indexes._id_).toBeDefined(); + done(); + }); + }); + }); + + it_exclude_dbs(['postgres'])('get compound indexes on startup', done => { + const obj = new Parse.Object('TestObject'); + obj.set('subject', 'subject'); + obj.set('comment', 'comment'); + obj + .save() + .then(() => { + return config.database.adapter.createIndex('TestObject', { + subject: 'text', + comment: 'text', + }); + }) + .then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data.indexes._id_).toBeDefined(); + expect(response.data.indexes._id_._id).toEqual(1); + expect( + response.data.indexes.subject_text_comment_text + ).toBeDefined(); + expect( + response.data.indexes.subject_text_comment_text.subject + ).toEqual('text'); + expect( + response.data.indexes.subject_text_comment_text.comment + ).toEqual('text'); + done(); + }); + }); + }); + + it_exclude_dbs(['postgres'])( + 'cannot update to duplicate value on unique index', + done => { + const index = { + code: 1, + }; + const obj1 = new Parse.Object('UniqueIndexClass'); + obj1.set('code', 1); + const obj2 = new Parse.Object('UniqueIndexClass'); + obj2.set('code', 2); + const adapter = config.database.adapter; + adapter + ._adaptiveCollection('UniqueIndexClass') + .then(collection => { + return collection._ensureSparseUniqueIndexInBackground(index); + }) + .then(() => { + return obj1.save(); + }) + .then(() => { + return obj2.save(); + }) + .then(() => { + obj1.set('code', 2); + return obj1.save(); + }) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + }); + } + ); }); }); diff --git a/spec/support/CustomAuth.js b/spec/support/CustomAuth.js index 3a2630cff9..922b77ac4c 100644 --- a/spec/support/CustomAuth.js +++ b/spec/support/CustomAuth.js @@ -1,4 +1,3 @@ - module.exports = { validateAppId: function() { return Promise.resolve(); @@ -8,5 +7,5 @@ module.exports = { return Promise.resolve(); } return Promise.reject(); - } -} + }, +}; diff --git a/spec/support/CustomAuthFunction.js b/spec/support/CustomAuthFunction.js index d13165e75d..8dd5a31f1a 100644 --- a/spec/support/CustomAuthFunction.js +++ b/spec/support/CustomAuthFunction.js @@ -1,4 +1,3 @@ - module.exports = function(validAuthData) { return { validateAppId: function() { @@ -9,6 +8,6 @@ module.exports = function(validAuthData) { return Promise.resolve(); } return Promise.reject(); - } - } -} + }, + }; +}; diff --git a/spec/support/CustomMiddleware.js b/spec/support/CustomMiddleware.js index 4debc14677..368500b847 100644 --- a/spec/support/CustomMiddleware.js +++ b/spec/support/CustomMiddleware.js @@ -1,4 +1,4 @@ module.exports = function(req, res, next) { res.set('X-Yolo', '1'); next(); -} +}; diff --git a/spec/support/FailingServer.js b/spec/support/FailingServer.js new file mode 100755 index 0000000000..6467cef9e3 --- /dev/null +++ b/spec/support/FailingServer.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +const MongoStorageAdapter = require('../../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const { + GridFSBucketAdapter, +} = require('../../lib/Adapters/Files/GridFSBucketAdapter'); + +const ParseServer = require('../../lib/index').ParseServer; + +const databaseURI = + 'mongodb://doesnotexist:27017/parseServerMongoAdapterTestDatabase'; + +ParseServer.start({ + appId: 'test', + masterKey: 'test', + databaseAdapter: new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { + serverSelectionTimeoutMS: 2000, + }, + }), + filesAdapter: new GridFSBucketAdapter(databaseURI), +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index e0347ebfe7..1fbe0c31bf 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -1,10 +1,6 @@ { "spec_dir": "spec", - "spec_files": [ - "*spec.js" - ], - "helpers": [ - "../node_modules/babel-core/register.js", - "helper.js" - ] + "spec_files": ["*spec.js"], + "helpers": ["helper.js"], + "random": false } diff --git a/spec/testing-routes.js b/spec/testing-routes.js index 187fcb8faa..8ba0287ad2 100644 --- a/spec/testing-routes.js +++ b/spec/testing-routes.js @@ -1,33 +1,34 @@ // testing-routes.js -import AppCache from '../src/cache'; -import * as middlewares from '../src/middlewares'; -import { ParseServer } from '../src/index'; -import { Parse } from 'parse/node'; +const AppCache = require('../lib/cache').default; +const middlewares = require('../lib/middlewares'); +const { ParseServer } = require('../lib/index'); +const { Parse } = require('parse/node'); -var express = require('express'), - cryptoUtils = require('../src/cryptoUtils'); +const express = require('express'), + cryptoUtils = require('../lib/cryptoUtils'); -var router = express.Router(); +const router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { - var appId = cryptoUtils.randomHexString(32); + const appId = cryptoUtils.randomHexString(32); ParseServer({ - databaseURI: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', + databaseURI: + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', appId: appId, masterKey: 'master', serverURL: Parse.serverURL, - collectionPrefix: appId + collectionPrefix: appId, }); - var keys = { - 'application_id': appId, - 'client_key' : 'unused', - 'windows_key' : 'unused', - 'javascript_key': 'unused', - 'webhook_key' : 'unused', - 'rest_api_key' : 'unused', - 'master_key' : 'master' + const keys = { + application_id: appId, + client_key: 'unused', + windows_key: 'unused', + javascript_key: 'unused', + webhook_key: 'unused', + rest_api_key: 'unused', + master_key: 'master', }; res.status(200).send(keys); } @@ -35,7 +36,7 @@ function createApp(req, res) { // deletes all collections that belong to the app function clearApp(req, res) { if (!req.auth.isMaster) { - return res.status(401).send({ "error": "unauthorized" }); + return res.status(401).send({ error: 'unauthorized' }); } return req.config.database.deleteEverything().then(() => { res.status(200).send({}); @@ -45,7 +46,7 @@ function clearApp(req, res) { // deletes all collections and drops the app from cache function dropApp(req, res) { if (!req.auth.isMaster) { - return res.status(401).send({ "error": "unauthorized" }); + return res.status(401).send({ error: 'unauthorized' }); } return req.config.database.deleteEverything().then(() => { AppCache.del(req.config.applicationId); @@ -60,13 +61,29 @@ function notImplementedYet(req, res) { router.post('/rest_clear_app', middlewares.handleParseHeaders, clearApp); router.post('/rest_block', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_mock_v8_client', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_unmock_v8_client', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_verify_analytics', middlewares.handleParseHeaders, notImplementedYet); +router.post( + '/rest_mock_v8_client', + middlewares.handleParseHeaders, + notImplementedYet +); +router.post( + '/rest_unmock_v8_client', + middlewares.handleParseHeaders, + notImplementedYet +); +router.post( + '/rest_verify_analytics', + middlewares.handleParseHeaders, + notImplementedYet +); router.post('/rest_create_app', createApp); router.post('/rest_drop_app', middlewares.handleParseHeaders, dropApp); -router.post('/rest_configure_app', middlewares.handleParseHeaders, notImplementedYet); +router.post( + '/rest_configure_app', + middlewares.handleParseHeaders, + notImplementedYet +); module.exports = { - router: router + router: router, }; diff --git a/src/AccountLockout.js b/src/AccountLockout.js index 358f1ac402..da3049cddc 100644 --- a/src/AccountLockout.js +++ b/src/AccountLockout.js @@ -12,11 +12,11 @@ export class AccountLockout { */ _setFailedLoginCount(value) { const query = { - username: this._user.username + username: this._user.username, }; const updateFields = { - _failed_login_count: value + _failed_login_count: value, }; return this._config.database.update('_User', query, updateFields); @@ -28,17 +28,16 @@ export class AccountLockout { _isFailedLoginCountSet() { const query = { username: this._user.username, - _failed_login_count: { $exists: true } + _failed_login_count: { $exists: true }, }; - return this._config.database.find('_User', query) - .then(users => { - if (Array.isArray(users) && users.length > 0) { - return true; - } else { - return false; - } - }); + return this._config.database.find('_User', query).then(users => { + if (Array.isArray(users) && users.length > 0) { + return true; + } else { + return false; + } + }); } /** @@ -46,12 +45,11 @@ export class AccountLockout { * else do nothing */ _initFailedLoginCount() { - return this._isFailedLoginCountSet() - .then(failedLoginCountIsSet => { - if (!failedLoginCountIsSet) { - return this._setFailedLoginCount(0); - } - }); + return this._isFailedLoginCountSet().then(failedLoginCountIsSet => { + if (!failedLoginCountIsSet) { + return this._setFailedLoginCount(0); + } + }); } /** @@ -59,10 +57,12 @@ export class AccountLockout { */ _incrementFailedLoginCount() { const query = { - username: this._user.username + username: this._user.username, }; - const updateFields = {_failed_login_count: {__op: 'Increment', amount: 1}}; + const updateFields = { + _failed_login_count: { __op: 'Increment', amount: 1 }, + }; return this._config.database.update('_User', query, updateFields); } @@ -75,18 +75,29 @@ export class AccountLockout { _setLockoutExpiration() { const query = { username: this._user.username, - _failed_login_count: { $gte: this._config.accountLockout.threshold } + _failed_login_count: { $gte: this._config.accountLockout.threshold }, }; const now = new Date(); const updateFields = { - _account_lockout_expires_at: Parse._encode(new Date(now.getTime() + this._config.accountLockout.duration * 60 * 1000)) + _account_lockout_expires_at: Parse._encode( + new Date( + now.getTime() + this._config.accountLockout.duration * 60 * 1000 + ) + ), }; - return this._config.database.update('_User', query, updateFields) + return this._config.database + .update('_User', query, updateFields) .catch(err => { - if (err && err.code && err.message && err.code === 101 && err.message === 'Object not found.') { + if ( + err && + err.code && + err.message && + err.code === 101 && + err.message === 'Object not found.' + ) { return; // nothing to update so we are good } else { throw err; // unknown error @@ -104,15 +115,19 @@ export class AccountLockout { const query = { username: this._user.username, _account_lockout_expires_at: { $gt: Parse._encode(new Date()) }, - _failed_login_count: {$gte: this._config.accountLockout.threshold} + _failed_login_count: { $gte: this._config.accountLockout.threshold }, }; - return this._config.database.find('_User', query) - .then(users => { - if (Array.isArray(users) && users.length > 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Your account is locked due to multiple failed login attempts. Please try again after ' + this._config.accountLockout.duration + ' minute(s)'); - } - }); + return this._config.database.find('_User', query).then(users => { + if (Array.isArray(users) && users.length > 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + this._config.accountLockout.duration + + ' minute(s)' + ); + } + }); } /** @@ -139,16 +154,14 @@ export class AccountLockout { if (!this._config.accountLockout) { return Promise.resolve(); } - return this._notLocked() - .then(() => { - if (loginSuccessful) { - return this._setFailedLoginCount(0); - } else { - return this._handleFailedLoginAttempt(); - } - }); + return this._notLocked().then(() => { + if (loginSuccessful) { + return this._setFailedLoginCount(0); + } else { + return this._handleFailedLoginAttempt(); + } + }); } - } export default AccountLockout; diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index cd7f33f348..990b406b56 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,14 +1,25 @@ -export function loadAdapter(adapter, defaultAdapter, options) { +/** + * @module AdapterLoader + */ +/** + * @static + * Attempt to load an adapter or fallback to the default. + * @param {Adapter} adapter an adapter + * @param {Adapter} defaultAdapter the default adapter to load + * @param {any} options options to pass to the contstructor + * @returns {Object} the loaded adapter + */ +export function loadAdapter(adapter, defaultAdapter, options): T { if (!adapter) { if (!defaultAdapter) { return options; } // Load from the default adapter when no adapter is set return loadAdapter(defaultAdapter, undefined, options); - } else if (typeof adapter === "function") { + } else if (typeof adapter === 'function') { try { return adapter(options); - } catch(e) { + } catch (e) { if (e.name === 'TypeError') { var Adapter = adapter; return new Adapter(options); @@ -16,7 +27,7 @@ export function loadAdapter(adapter, defaultAdapter, options) { throw e; } } - } else if (typeof adapter === "string") { + } else if (typeof adapter === 'string') { /* eslint-disable */ adapter = require(adapter); // If it's define as a module, get the default diff --git a/src/Adapters/Analytics/AnalyticsAdapter.js b/src/Adapters/Analytics/AnalyticsAdapter.js index 7d13ef96a3..3fd50242d9 100644 --- a/src/Adapters/Analytics/AnalyticsAdapter.js +++ b/src/Adapters/Analytics/AnalyticsAdapter.js @@ -1,18 +1,23 @@ /*eslint no-unused-vars: "off"*/ +/** + * @module Adapters + */ +/** + * @interface AnalyticsAdapter + */ export class AnalyticsAdapter { - - /* - @param parameters: the analytics request body, analytics info will be in the dimensions property - @param req: the original http request + /** + @param {any} parameters: the analytics request body, analytics info will be in the dimensions property + @param {Request} req: the original http request */ appOpened(parameters, req) { return Promise.resolve({}); } - /* - @param eventName: the name of the custom eventName - @param parameters: the analytics request body, analytics info will be in the dimensions property - @param req: the original http request + /** + @param {String} eventName: the name of the custom eventName + @param {any} parameters: the analytics request body, analytics info will be in the dimensions property + @param {Request} req: the original http request */ trackEvent(eventName, parameters, req) { return Promise.resolve({}); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index dd8fd838c3..9af6d5e449 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -1,12 +1,12 @@ /*eslint no-unused-vars: "off"*/ export class AuthAdapter { - /* @param appIds: the specified app ids in the configuration @param authData: the client provided authData + @param options: additional options @returns a promise that resolves if the applicationId is valid */ - validateAppId(appIds, authData) { + validateAppId(appIds, authData, options) { return Promise.resolve({}); } diff --git a/src/Adapters/Auth/OAuth1Client.js b/src/Adapters/Auth/OAuth1Client.js index 01e4033f7b..4e7f9267d3 100644 --- a/src/Adapters/Auth/OAuth1Client.js +++ b/src/Adapters/Auth/OAuth1Client.js @@ -3,8 +3,11 @@ var https = require('https'), var Parse = require('parse/node').Parse; var OAuth = function(options) { - if(!options) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'No options passed to OAuth'); + if (!options) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'No options passed to OAuth' + ); } this.consumer_key = options.consumer_key; this.consumer_secret = options.consumer_secret; @@ -14,23 +17,24 @@ var OAuth = function(options) { this.oauth_params = options.oauth_params || {}; }; -OAuth.prototype.send = function(method, path, params, body){ - +OAuth.prototype.send = function(method, path, params, body) { var request = this.buildRequest(method, path, params, body); // Encode the body properly, the current Parse Implementation don't do it properly return new Promise(function(resolve, reject) { - var httpRequest = https.request(request, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); + var httpRequest = https + .request(request, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }) + .on('error', function() { + reject('Failed to make an OAuth request'); }); - }).on('error', function() { - reject('Failed to make an OAuth request'); - }); if (request.body) { httpRequest.write(request.body); } @@ -39,40 +43,45 @@ OAuth.prototype.send = function(method, path, params, body){ }; OAuth.prototype.buildRequest = function(method, path, params, body) { - if (path.indexOf("/") != 0) { - path = "/" + path; + if (path.indexOf('/') != 0) { + path = '/' + path; } if (params && Object.keys(params).length > 0) { - path += "?" + OAuth.buildParameterString(params); + path += '?' + OAuth.buildParameterString(params); } var request = { - host: this.host, - path: path, - method: method.toUpperCase() + host: this.host, + path: path, + method: method.toUpperCase(), }; var oauth_params = this.oauth_params || {}; oauth_params.oauth_consumer_key = this.consumer_key; - if(this.auth_token){ - oauth_params["oauth_token"] = this.auth_token; + if (this.auth_token) { + oauth_params['oauth_token'] = this.auth_token; } - request = OAuth.signRequest(request, oauth_params, this.consumer_secret, this.auth_token_secret); + request = OAuth.signRequest( + request, + oauth_params, + this.consumer_secret, + this.auth_token_secret + ); if (body && Object.keys(body).length > 0) { request.body = OAuth.buildParameterString(body); } return request; -} +}; OAuth.prototype.get = function(path, params) { - return this.send("GET", path, params); -} + return this.send('GET', path, params); +}; OAuth.prototype.post = function(path, params, body) { - return this.send("POST", path, params, body); -} + return this.send('POST', path, params, body); +}; /* Proper string %escape encoding @@ -99,8 +108,7 @@ OAuth.encode = function(str) { // example 3: rawurlencode('http://www.google.nl/search?q=php.js&ie=utf-8&oe=utf-8&aq=t&rls=com.ubuntu:en-US:unofficial&client=firefox-a'); // returns 3: 'http%3A%2F%2Fwww.google.nl%2Fsearch%3Fq%3Dphp.js%26ie%3Dutf-8%26oe%3Dutf-8%26aq%3Dt%26rls%3Dcom.ubuntu%3Aen-US%3Aunofficial%26client%3Dfirefox-a' - str = (str + '') - .toString(); + str = (str + '').toString(); // Tilde should be allowed unescaped in future versions of PHP (as reflected below), but if you want to reflect current // PHP behavior, you would need to add ".replace(/~/g, '%7E');" to the following. @@ -110,55 +118,72 @@ OAuth.encode = function(str) { .replace(/\(/g, '%28') .replace(/\)/g, '%29') .replace(/\*/g, '%2A'); -} +}; -OAuth.signatureMethod = "HMAC-SHA1"; -OAuth.version = "1.0"; +OAuth.signatureMethod = 'HMAC-SHA1'; +OAuth.version = '1.0'; /* Generate a nonce */ -OAuth.nonce = function(){ - var text = ""; - var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +OAuth.nonce = function() { + var text = ''; + var possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for(var i = 0; i < 30; i++) + for (var i = 0; i < 30; i++) text += possible.charAt(Math.floor(Math.random() * possible.length)); return text; -} +}; -OAuth.buildParameterString = function(obj){ +OAuth.buildParameterString = function(obj) { // Sort keys and encode values if (obj) { var keys = Object.keys(obj).sort(); // Map key=value, join them by & - return keys.map(function(key){ - return key + "=" + OAuth.encode(obj[key]); - }).join("&"); + return keys + .map(function(key) { + return key + '=' + OAuth.encode(obj[key]); + }) + .join('&'); } - return ""; -} + return ''; +}; /* Build the signature string from the object */ -OAuth.buildSignatureString = function(method, url, parameters){ - return [method.toUpperCase(), OAuth.encode(url), OAuth.encode(parameters)].join("&"); -} +OAuth.buildSignatureString = function(method, url, parameters) { + return [ + method.toUpperCase(), + OAuth.encode(url), + OAuth.encode(parameters), + ].join('&'); +}; /* Retuns encoded HMAC-SHA1 from key and text */ -OAuth.signature = function(text, key){ - crypto = require("crypto"); - return OAuth.encode(crypto.createHmac('sha1', key).update(text).digest('base64')); -} +OAuth.signature = function(text, key) { + crypto = require('crypto'); + return OAuth.encode( + crypto + .createHmac('sha1', key) + .update(text) + .digest('base64') + ); +}; -OAuth.signRequest = function(request, oauth_parameters, consumer_secret, auth_token_secret){ +OAuth.signRequest = function( + request, + oauth_parameters, + consumer_secret, + auth_token_secret +) { oauth_parameters = oauth_parameters || {}; // Set default values @@ -175,20 +200,20 @@ OAuth.signRequest = function(request, oauth_parameters, consumer_secret, auth_to oauth_parameters.oauth_version = OAuth.version; } - if(!auth_token_secret){ - auth_token_secret = ""; + if (!auth_token_secret) { + auth_token_secret = ''; } // Force GET method if unset if (!request.method) { - request.method = "GET" + request.method = 'GET'; } // Collect all the parameters in one signatureParameters object var signatureParams = {}; var parametersToMerge = [request.params, request.body, oauth_parameters]; - for(var i in parametersToMerge) { + for (var i in parametersToMerge) { var parameters = parametersToMerge[i]; - for(var k in parameters) { + for (var k in parameters) { signatureParams[k] = parameters[k]; } } @@ -197,32 +222,41 @@ OAuth.signRequest = function(request, oauth_parameters, consumer_secret, auth_to var parameterString = OAuth.buildParameterString(signatureParams); // Build the signature string - var url = "https://" + request.host + "" + request.path; + var url = 'https://' + request.host + '' + request.path; - var signatureString = OAuth.buildSignatureString(request.method, url, parameterString); + var signatureString = OAuth.buildSignatureString( + request.method, + url, + parameterString + ); // Hash the signature string - var signatureKey = [OAuth.encode(consumer_secret), OAuth.encode(auth_token_secret)].join("&"); + var signatureKey = [ + OAuth.encode(consumer_secret), + OAuth.encode(auth_token_secret), + ].join('&'); var signature = OAuth.signature(signatureString, signatureKey); // Set the signature in the params oauth_parameters.oauth_signature = signature; - if(!request.headers){ + if (!request.headers) { request.headers = {}; } // Set the authorization header - var authHeader = Object.keys(oauth_parameters).sort().map(function(key){ - var value = oauth_parameters[key]; - return key + '="' + value + '"'; - }).join(", ") + var authHeader = Object.keys(oauth_parameters) + .sort() + .map(function(key) { + var value = oauth_parameters[key]; + return key + '="' + value + '"'; + }) + .join(', '); request.headers.Authorization = 'OAuth ' + authHeader; // Set the content type header - request.headers["Content-Type"] = "application/x-www-form-urlencoded"; + request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; return request; - -} +}; module.exports = OAuth; diff --git a/src/Adapters/Auth/apple.js b/src/Adapters/Auth/apple.js new file mode 100644 index 0000000000..8d1b376836 --- /dev/null +++ b/src/Adapters/Auth/apple.js @@ -0,0 +1,102 @@ +// Apple SignIn Auth +// https://developer.apple.com/documentation/signinwithapplerestapi + +const Parse = require('parse/node').Parse; +const jwksClient = require('jwks-rsa'); +const util = require('util'); +const jwt = require('jsonwebtoken'); + +const TOKEN_ISSUER = 'https://appleid.apple.com'; + +const getAppleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: `${TOKEN_ISSUER}/auth/keys`, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + const asyncGetSigningKeyFunction = util.promisify(client.getSigningKey); + + let key; + try { + key = await asyncGetSigningKeyFunction(keyId); + } catch (error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const getHeaderFromToken = token => { + const decodedToken = jwt.decode(token, { complete: true }); + if (!decodedToken) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `provided token does not decode as JWT` + ); + } + + return decodedToken.header; +}; + +const verifyIdToken = async ( + { token, id }, + { clientId, cacheMaxEntries, cacheMaxAge } +) => { + if (!token) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token is invalid for this user.` + ); + } + + const { kid: keyId, alg: algorithm } = getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; + let jwtClaims; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const appleKey = await getAppleKeyByKeyId( + keyId, + cacheMaxEntries, + cacheMaxAge + ); + const signingKey = appleKey.publicKey || appleKey.rsaPublicKey; + + try { + jwtClaims = jwt.verify(token, signingKey, { + algorithms: algorithm, + // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. + audience: clientId, + // the issuer can be checked against a string or array of strings + issuer: TOKEN_ISSUER, + // the subject can be checked against a string + subject: id, + }); + } catch (exception) { + const message = exception.message; + + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + return jwtClaims; +}; + +// Returns a promise that fulfills if this id token is valid +function validateAuthData(authData, options = {}) { + return verifyIdToken(authData, options); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/facebook.js b/src/Adapters/Auth/facebook.js index ab846e43e6..1ee0147aa2 100644 --- a/src/Adapters/Auth/facebook.js +++ b/src/Adapters/Auth/facebook.js @@ -1,62 +1,72 @@ // Helper functions for accessing the Facebook Graph API. -var https = require('https'); +const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; +const crypto = require('crypto'); + +function getAppSecretPath(authData, options = {}) { + const appSecret = options.appSecret; + if (!appSecret) { + return ''; + } + const appsecret_proof = crypto + .createHmac('sha256', appSecret) + .update(authData.access_token) + .digest('hex'); + + return `&appsecret_proof=${appsecret_proof}`; +} // Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return graphRequest('me?fields=id&access_token=' + authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); +function validateAuthData(authData, options) { + return graphRequest( + 'me?fields=id&access_token=' + + authData.access_token + + getAppSecretPath(authData, options) + ).then(data => { + if ( + (data && data.id == authData.id) || + (process.env.TESTING && authData.id === 'test') + ) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills iff this app id is valid. -function validateAppId(appIds, authData) { +function validateAppId(appIds, authData, options) { var access_token = authData.access_token; + if (process.env.TESTING && access_token === 'test') { + return Promise.resolve(); + } if (!appIds.length) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is not configured.'); + 'Facebook auth is not configured.' + ); } - return graphRequest('app?access_token=' + access_token) - .then((data) => { - if (data && appIds.indexOf(data.id) != -1) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); + return graphRequest( + 'app?access_token=' + access_token + getAppSecretPath(authData, options) + ).then(data => { + if (data && appIds.indexOf(data.id) != -1) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.' + ); + }); } // A promisey wrapper for FB graph requests. function graphRequest(path) { - return new Promise(function(resolve, reject) { - https.get('https://graph.facebook.com/v2.5/' + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Facebook.'); - }); - }); + return httpsRequest.get('https://graph.facebook.com/' + path); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/facebookaccountkit.js b/src/Adapters/Auth/facebookaccountkit.js new file mode 100644 index 0000000000..a650d23840 --- /dev/null +++ b/src/Adapters/Auth/facebookaccountkit.js @@ -0,0 +1,60 @@ +const crypto = require('crypto'); +const httpsRequest = require('./httpsRequest'); +const Parse = require('parse/node').Parse; + +const graphRequest = path => { + return httpsRequest.get(`https://graph.accountkit.com/v1.1/${path}`); +}; + +function getRequestPath(authData, options) { + const access_token = authData.access_token, + appSecret = options && options.appSecret; + if (appSecret) { + const appsecret_proof = crypto + .createHmac('sha256', appSecret) + .update(access_token) + .digest('hex'); + return `me?access_token=${access_token}&appsecret_proof=${appsecret_proof}`; + } + return `me?access_token=${access_token}`; +} + +function validateAppId(appIds, authData, options) { + if (!appIds.length) { + return Promise.reject( + new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook app id for Account Kit is not configured.' + ) + ); + } + return graphRequest(getRequestPath(authData, options)).then(data => { + if (data && data.application && appIds.indexOf(data.application.id) != -1) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook app id for Account Kit is invalid for this user.' + ); + }); +} + +function validateAuthData(authData, options) { + return graphRequest(getRequestPath(authData, options)).then(data => { + if (data && data.error) { + throw data.error; + } + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook Account Kit auth is invalid for this user.' + ); + }); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/gcenter.js b/src/Adapters/Auth/gcenter.js new file mode 100644 index 0000000000..20c453db9f --- /dev/null +++ b/src/Adapters/Auth/gcenter.js @@ -0,0 +1,127 @@ +/* Apple Game Center Auth +https://developer.apple.com/documentation/gamekit/gklocalplayer/1515407-generateidentityverificationsign#discussion + +const authData = { + publicKeyUrl: 'https://valid.apple.com/public/timeout.cer', + timestamp: 1460981421303, + signature: 'PoDwf39DCN464B49jJCU0d9Y0J', + salt: 'saltST==', + bundleId: 'com.valid.app' + id: 'playerId', +}; +*/ + +const { Parse } = require('parse/node'); +const crypto = require('crypto'); +const https = require('https'); +const url = require('url'); + +const cache = {}; // (publicKey -> cert) cache + +function verifyPublicKeyUrl(publicKeyUrl) { + const parsedUrl = url.parse(publicKeyUrl); + if (parsedUrl.protocol !== 'https:') { + return false; + } + const hostnameParts = parsedUrl.hostname.split('.'); + const length = hostnameParts.length; + const domainParts = hostnameParts.slice(length - 2, length); + const domain = domainParts.join('.'); + return domain === 'apple.com'; +} + +function convertX509CertToPEM(X509Cert) { + const pemPreFix = '-----BEGIN CERTIFICATE-----\n'; + const pemPostFix = '-----END CERTIFICATE-----'; + + const base64 = X509Cert; + const certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n'); + + return pemPreFix + certBody + pemPostFix; +} + +function getAppleCertificate(publicKeyUrl) { + if (!verifyPublicKeyUrl(publicKeyUrl)) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` + ); + } + if (cache[publicKeyUrl]) { + return cache[publicKeyUrl]; + } + return new Promise((resolve, reject) => { + https + .get(publicKeyUrl, res => { + let data = ''; + res.on('data', chunk => { + data += chunk.toString('base64'); + }); + res.on('end', () => { + const cert = convertX509CertToPEM(data); + if (res.headers['cache-control']) { + var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); + if (expire) { + cache[publicKeyUrl] = cert; + // we'll expire the cache entry later, as per max-age + setTimeout(() => { + delete cache[publicKeyUrl]; + }, parseInt(expire[1], 10) * 1000); + } + } + resolve(cert); + }); + }) + .on('error', reject); + }); +} + +function convertTimestampToBigEndian(timestamp) { + const buffer = Buffer.alloc(8); + + const high = ~~(timestamp / 0xffffffff); + const low = timestamp % (0xffffffff + 0x1); + + buffer.writeUInt32BE(parseInt(high, 10), 0); + buffer.writeUInt32BE(parseInt(low, 10), 4); + + return buffer; +} + +function verifySignature(publicKey, authData) { + const verifier = crypto.createVerify('sha256'); + verifier.update(authData.playerId, 'utf8'); + verifier.update(authData.bundleId, 'utf8'); + verifier.update(convertTimestampToBigEndian(authData.timestamp)); + verifier.update(authData.salt, 'base64'); + + if (!verifier.verify(publicKey, authData.signature, 'base64')) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Apple Game Center - invalid signature' + ); + } +} + +// Returns a promise that fulfills if this user id is valid. +async function validateAuthData(authData) { + if (!authData.id) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Apple Game Center - authData id missing' + ); + } + authData.playerId = authData.id; + const publicKey = await getAppleCertificate(authData.publicKeyUrl); + return verifySignature(publicKey, authData); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/github.js b/src/Adapters/Auth/github.js index 146fbdc6f2..c3a167fdaa 100644 --- a/src/Adapters/Auth/github.js +++ b/src/Adapters/Auth/github.js @@ -1,18 +1,18 @@ // Helper functions for accessing the github API. -var https = require('https'); var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return request('user', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Github auth is invalid for this user.'); - }); + return request('user', authData.access_token).then(data => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Github auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills iff this app id is valid. @@ -22,34 +22,17 @@ function validateAppId() { // A promisey wrapper for api requests function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.github.com', - path: '/' + path, - headers: { - 'Authorization': 'bearer ' + access_token, - 'User-Agent': 'parse-server' - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Github.'); - }); + return httpsRequest.get({ + host: 'api.github.com', + path: '/' + path, + headers: { + Authorization: 'bearer ' + access_token, + 'User-Agent': 'parse-server', + }, }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/google.js b/src/Adapters/Auth/google.js index 7cc414922a..9dacabdd62 100644 --- a/src/Adapters/Auth/google.js +++ b/src/Adapters/Auth/google.js @@ -1,29 +1,29 @@ // Helper functions for accessing the google API. -var https = require('https'); var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); function validateIdToken(id, token) { - return request("tokeninfo?id_token=" + token) - .then((response) => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.'); - }); + return googleRequest('tokeninfo?id_token=' + token).then(response => { + if (response && (response.sub == id || response.user_id == id)) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Google auth is invalid for this user.' + ); + }); } function validateAuthToken(id, token) { - return request("tokeninfo?access_token=" + token) - .then((response) => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.'); - }); + return googleRequest('tokeninfo?access_token=' + token).then(response => { + if (response && (response.sub == id || response.user_id == id)) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Google auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills if this user id is valid. @@ -31,13 +31,16 @@ function validateAuthData(authData) { if (authData.id_token) { return validateIdToken(authData.id, authData.id_token); } else { - return validateAuthToken(authData.id, authData.access_token).then(() => { - // Validation with auth token worked - return; - }, () => { - // Try with the id_token param - return validateIdToken(authData.id, authData.access_token); - }); + return validateAuthToken(authData.id, authData.access_token).then( + () => { + // Validation with auth token worked + return; + }, + () => { + // Try with the id_token param + return validateIdToken(authData.id, authData.access_token); + } + ); } } @@ -47,28 +50,11 @@ function validateAppId() { } // A promisey wrapper for api requests -function request(path) { - return new Promise(function(resolve, reject) { - https.get("https://www.googleapis.com/oauth2/v3/" + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Google.'); - }); - }); +function googleRequest(path) { + return httpsRequest.get('https://www.googleapis.com/oauth2/v3/' + path); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/gpgames.js b/src/Adapters/Auth/gpgames.js new file mode 100644 index 0000000000..4462a7897d --- /dev/null +++ b/src/Adapters/Auth/gpgames.js @@ -0,0 +1,33 @@ +/* Google Play Game Services +https://developers.google.com/games/services/web/api/players/get + +const authData = { + id: 'playerId', + access_token: 'token', +}; +*/ +const { Parse } = require('parse/node'); +const httpsRequest = require('./httpsRequest'); + +// Returns a promise that fulfills if this user id is valid. +async function validateAuthData(authData) { + const response = await httpsRequest.get( + `https://www.googleapis.com/games/v1/players/${authData.id}?access_token=${authData.access_token}` + ); + if (!(response && response.playerId === authData.id)) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Google Play Games Services - authData is invalid for this user.' + ); + } +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/httpsRequest.js b/src/Adapters/Auth/httpsRequest.js new file mode 100644 index 0000000000..0233904048 --- /dev/null +++ b/src/Adapters/Auth/httpsRequest.js @@ -0,0 +1,41 @@ +const https = require('https'); + +function makeCallback(resolve, reject, noJSON) { + return function(res) { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + if (noJSON) { + return resolve(data); + } + try { + data = JSON.parse(data); + } catch (e) { + return reject(e); + } + resolve(data); + }); + res.on('error', reject); + }; +} + +function get(options, noJSON = false) { + return new Promise((resolve, reject) => { + https + .get(options, makeCallback(resolve, reject, noJSON)) + .on('error', reject); + }); +} + +function request(options, postData) { + return new Promise((resolve, reject) => { + const req = https.request(options, makeCallback(resolve, reject)); + req.on('error', reject); + req.write(postData); + req.end(); + }); +} + +module.exports = { get, request }; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index aa99f09cec..e5e8c955bd 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,20 +1,29 @@ import loadAdapter from '../AdapterLoader'; +const apple = require('./apple'); +const gcenter = require('./gcenter'); +const gpgames = require('./gpgames'); const facebook = require('./facebook'); -const instagram = require("./instagram"); -const linkedin = require("./linkedin"); -const meetup = require("./meetup"); -const google = require("./google"); -const github = require("./github"); -const twitter = require("./twitter"); -const spotify = require("./spotify"); -const digits = require("./twitter"); // digits tokens are validated by twitter -const janrainengage = require("./janrainengage"); -const janraincapture = require("./janraincapture"); -const vkontakte = require("./vkontakte"); -const qq = require("./qq"); -const wechat = require("./wechat"); -const weibo = require("./weibo"); +const facebookaccountkit = require('./facebookaccountkit'); +const instagram = require('./instagram'); +const linkedin = require('./linkedin'); +const meetup = require('./meetup'); +const google = require('./google'); +const github = require('./github'); +const twitter = require('./twitter'); +const spotify = require('./spotify'); +const digits = require('./twitter'); // digits tokens are validated by twitter +const janrainengage = require('./janrainengage'); +const janraincapture = require('./janraincapture'); +const line = require('./line'); +const vkontakte = require('./vkontakte'); +const qq = require('./qq'); +const wechat = require('./wechat'); +const weibo = require('./weibo'); +const oauth2 = require('./oauth2'); +const phantauth = require('./phantauth'); +const microsoft = require('./microsoft'); +const ldap = require('./ldap'); const anonymous = { validateAuthData: () => { @@ -22,11 +31,15 @@ const anonymous = { }, validateAppId: () => { return Promise.resolve(); - } -} + }, +}; const providers = { + apple, + gcenter, + gpgames, facebook, + facebookaccountkit, instagram, linkedin, meetup, @@ -38,11 +51,15 @@ const providers = { digits, janrainengage, janraincapture, + line, vkontakte, qq, wechat, - weibo -} + weibo, + phantauth, + microsoft, + ldap, +}; function authDataValidator(adapter, appIds, options) { return function(authData) { @@ -52,25 +69,36 @@ function authDataValidator(adapter, appIds, options) { } return Promise.resolve(); }); - } + }; } function loadAuthAdapter(provider, authOptions) { - const defaultAdapter = providers[provider]; - const adapter = Object.assign({}, defaultAdapter); + let defaultAdapter = providers[provider]; const providerOptions = authOptions[provider]; + if ( + providerOptions && + Object.prototype.hasOwnProperty.call(providerOptions, 'oauth2') && + providerOptions['oauth2'] === true + ) { + defaultAdapter = oauth2; + } if (!defaultAdapter && !providerOptions) { return; } + const adapter = Object.assign({}, defaultAdapter); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods if (providerOptions) { - const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); + const optionalAdapter = loadAdapter( + providerOptions, + undefined, + providerOptions + ); if (optionalAdapter) { - ['validateAuthData', 'validateAppId'].forEach((key) => { + ['validateAuthData', 'validateAppId'].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -78,38 +106,40 @@ function loadAuthAdapter(provider, authOptions) { } } + // TODO: create a new module from validateAdapter() in + // src/Controllers/AdaptableController.js so we can use it here for adapter + // validation based on the src/Adapters/Auth/AuthAdapter.js expected class + // signature. if (!adapter.validateAuthData || !adapter.validateAppId) { return; } - return {adapter, appIds, providerOptions}; + return { adapter, appIds, providerOptions }; } module.exports = function(authOptions = {}, enableAnonymousUsers = true) { let _enableAnonymousUsers = enableAnonymousUsers; const setEnableAnonymousUsers = function(enable) { _enableAnonymousUsers = enable; - } + }; // To handle the test cases on configuration const getValidatorForProvider = function(provider) { - if (provider === 'anonymous' && !_enableAnonymousUsers) { return; } - const { - adapter, - appIds, - providerOptions - } = loadAuthAdapter(provider, authOptions); + const { adapter, appIds, providerOptions } = loadAuthAdapter( + provider, + authOptions + ); return authDataValidator(adapter, appIds, providerOptions); - } + }; return Object.freeze({ getValidatorForProvider, - setEnableAnonymousUsers - }) -} + setEnableAnonymousUsers, + }); +}; module.exports.loadAuthAdapter = loadAuthAdapter; diff --git a/src/Adapters/Auth/instagram.js b/src/Adapters/Auth/instagram.js index 1c6c0f73cf..ee6302c138 100644 --- a/src/Adapters/Auth/instagram.js +++ b/src/Adapters/Auth/instagram.js @@ -1,18 +1,20 @@ // Helper functions for accessing the instagram API. -var https = require('https'); var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return request("users/self/?access_token=" + authData.access_token) - .then((response) => { + return request('users/self/?access_token=' + authData.access_token).then( + response => { if (response && response.data && response.data.id == authData.id) { return; } throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'Instagram auth is invalid for this user.'); - }); + 'Instagram auth is invalid for this user.' + ); + } + ); } // Returns a promise that fulfills iff this app id is valid. @@ -22,23 +24,10 @@ function validateAppId() { // A promisey wrapper for api requests function request(path) { - return new Promise(function(resolve, reject) { - https.get("https://api.instagram.com/v1/" + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Instagram.'); - }); - }); + return httpsRequest.get('https://api.instagram.com/v1/' + path); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/janraincapture.js b/src/Adapters/Auth/janraincapture.js index 85ae98da7d..fbff3c2421 100644 --- a/src/Adapters/Auth/janraincapture.js +++ b/src/Adapters/Auth/janraincapture.js @@ -1,19 +1,23 @@ // Helper functions for accessing the Janrain Capture API. -var https = require('https'); var Parse = require('parse/node').Parse; var querystring = require('querystring'); +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData, options) { - return request(options.janrain_capture_host, authData.access_token) - .then((data) => { + return request(options.janrain_capture_host, authData.access_token).then( + data => { //successful response will have a "stat" (status) of 'ok' and a result node that stores the uuid, because that's all we asked for //see: https://docs.janrain.com/api/registration/entity/#entity if (data && data.stat == 'ok' && data.result == authData.id) { return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Janrain capture auth is invalid for this user.'); - }); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Janrain capture auth is invalid for this user.' + ); + } + ); } // Returns a promise that fulfills iff this app id is valid. @@ -24,31 +28,15 @@ function validateAppId() { // A promisey wrapper for api requests function request(host, access_token) { - var query_string_data = querystring.stringify({ - 'access_token': access_token, - 'attribute_name': 'uuid' // we only need to pull the uuid for this access token to make sure it matches + access_token: access_token, + attribute_name: 'uuid', // we only need to pull the uuid for this access token to make sure it matches }); - return new Promise(function(resolve, reject) { - https.get({ - host: host, - path: '/entity?' + query_string_data - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function () { - resolve(JSON.parse(data)); - }); - }).on('error', function() { - reject('Failed to validate this access token with Janrain capture.'); - }); - }); + return httpsRequest.get({ host: host, path: '/entity?' + query_string_data }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/janrainengage.js b/src/Adapters/Auth/janrainengage.js index 7de682e7d4..6e1589e724 100644 --- a/src/Adapters/Auth/janrainengage.js +++ b/src/Adapters/Auth/janrainengage.js @@ -1,19 +1,21 @@ // Helper functions for accessing the Janrain Engage API. -var https = require('https'); +var httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; var querystring = require('querystring'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData, options) { - return request(options.api_key, authData.auth_token) - .then((data) => { - //successful response will have a "stat" (status) of 'ok' and a profile node with an identifier - //see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data - if (data && data.stat == 'ok' && data.profile.identifier == authData.id) { - return; - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Janrain engage auth is invalid for this user.'); - }); + return apiRequest(options.api_key, authData.auth_token).then(data => { + //successful response will have a "stat" (status) of 'ok' and a profile node with an identifier + //see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data + if (data && data.stat == 'ok' && data.profile.identifier == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Janrain engage auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills iff this app id is valid. @@ -23,12 +25,11 @@ function validateAppId() { } // A promisey wrapper for api requests -function request(api_key, auth_token) { - +function apiRequest(api_key, auth_token) { var post_data = querystring.stringify({ - 'token': auth_token, - 'apiKey': api_key, - 'format': 'json' + token: auth_token, + apiKey: api_key, + format: 'json', }); var post_options = { @@ -37,36 +38,14 @@ function request(api_key, auth_token) { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': post_data.length - } + 'Content-Length': post_data.length, + }, }; - return new Promise(function (resolve, reject) { - // Create the post request. - var post_req = https.request(post_options, function (res) { - var data = ''; - res.setEncoding('utf8'); - // Append data as we receive it from the Janrain engage server. - res.on('data', function (d) { - data += d; - }); - // Once we have all the data, we can parse it and return the data we want. - res.on('end', function () { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }); - - post_req.write(post_data); - post_req.end(); - }); + return httpsRequest.request(post_options, post_data); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js new file mode 100644 index 0000000000..5b3c3a05f3 --- /dev/null +++ b/src/Adapters/Auth/ldap.js @@ -0,0 +1,113 @@ +const ldapjs = require('ldapjs'); +const Parse = require('parse/node').Parse; + +function validateAuthData(authData, options) { + if (!optionsAreValid(options)) { + return new Promise((_, reject) => { + reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP auth configuration missing' + ) + ); + }); + } + + const client = ldapjs.createClient({ url: options.url }); + const userCn = + typeof options.dn === 'string' + ? options.dn.replace('{{id}}', authData.id) + : `uid=${authData.id},${options.suffix}`; + + return new Promise((resolve, reject) => { + client.bind(userCn, authData.password, err => { + if (err) { + client.destroy(err); + return reject( + new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'LDAP: Wrong username or password' + ) + ); + } + + if ( + typeof options.groupCn === 'string' && + typeof options.groupFilter === 'string' + ) { + searchForGroup(client, options, authData.id, resolve, reject); + } else { + client.unbind(); + client.destroy(); + resolve(); + } + }); + }); +} + +function optionsAreValid(options) { + return ( + typeof options === 'object' && + typeof options.suffix === 'string' && + typeof options.url === 'string' && + options.url.startsWith('ldap://') + ); +} + +function searchForGroup(client, options, id, resolve, reject) { + const filter = options.groupFilter.replace(/{{id}}/gi, id); + const opts = { + scope: 'sub', + filter: filter, + }; + let found = false; + client.search(options.suffix, opts, (searchError, res) => { + if (searchError) { + client.unbind(); + client.destroy(); + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP group search failed' + ) + ); + } + res.on('searchEntry', entry => { + if (entry.object.cn === options.groupCn) { + found = true; + client.unbind(); + client.destroy(); + return resolve(); + } + }); + res.on('end', () => { + if (!found) { + client.unbind(); + client.destroy(); + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP: User not in group' + ) + ); + } + }); + res.on('error', () => { + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP group search failed' + ) + ); + }); + }); +} + +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/line.js b/src/Adapters/Auth/line.js new file mode 100644 index 0000000000..e1a5584933 --- /dev/null +++ b/src/Adapters/Auth/line.js @@ -0,0 +1,39 @@ +// Helper functions for accessing the line API. +var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData) { + return request('profile', authData.access_token).then(response => { + if (response && response.userId && response.userId === authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Line auth is invalid for this user.' + ); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + var options = { + host: 'api.line.me', + path: '/v2/' + path, + method: 'GET', + headers: { + Authorization: 'Bearer ' + access_token, + }, + }; + return httpsRequest.get(options); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/linkedin.js b/src/Adapters/Auth/linkedin.js index de5fc66ce5..ede1c5df5f 100644 --- a/src/Adapters/Auth/linkedin.js +++ b/src/Adapters/Auth/linkedin.js @@ -1,18 +1,20 @@ // Helper functions for accessing the linkedin API. -var https = require('https'); var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return request('people/~:(id)', authData.access_token, authData.is_mobile_sdk) - .then((data) => { + return request('me', authData.access_token, authData.is_mobile_sdk).then( + data => { if (data && data.id == authData.id) { return; } throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'Linkedin auth is invalid for this user.'); - }); + 'Linkedin auth is invalid for this user.' + ); + } + ); } // Returns a promise that fulfills iff this app id is valid. @@ -23,39 +25,21 @@ function validateAppId() { // A promisey wrapper for api requests function request(path, access_token, is_mobile_sdk) { var headers = { - 'Authorization': 'Bearer ' + access_token, + Authorization: 'Bearer ' + access_token, 'x-li-format': 'json', - } + }; - if(is_mobile_sdk) { + if (is_mobile_sdk) { headers['x-li-src'] = 'msdk'; } - - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.linkedin.com', - path: '/v1/' + path, - headers: headers - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Linkedin.'); - }); + return httpsRequest.get({ + host: 'api.linkedin.com', + path: '/v2/' + path, + headers: headers, }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/meetup.js b/src/Adapters/Auth/meetup.js index bb14dc547b..d949a65e4a 100644 --- a/src/Adapters/Auth/meetup.js +++ b/src/Adapters/Auth/meetup.js @@ -1,18 +1,18 @@ // Helper functions for accessing the meetup API. -var https = require('https'); var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return request('member/self', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Meetup auth is invalid for this user.'); - }); + return request('member/self', authData.access_token).then(data => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Meetup auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills iff this app id is valid. @@ -22,33 +22,16 @@ function validateAppId() { // A promisey wrapper for api requests function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.meetup.com', - path: '/2/' + path, - headers: { - 'Authorization': 'bearer ' + access_token - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Meetup.'); - }); + return httpsRequest.get({ + host: 'api.meetup.com', + path: '/2/' + path, + headers: { + Authorization: 'bearer ' + access_token, + }, }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/microsoft.js b/src/Adapters/Auth/microsoft.js new file mode 100644 index 0000000000..1574045528 --- /dev/null +++ b/src/Adapters/Auth/microsoft.js @@ -0,0 +1,39 @@ +// Helper functions for accessing the microsoft graph API. +var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); + +// Returns a promise that fulfills if this user mail is valid. +function validateAuthData(authData) { + return request('me', authData.access_token).then( + response => { + if (response && response.id && response.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Microsoft Graph auth is invalid for this user.' + ); + } + ); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return httpsRequest.get({ + host: 'graph.microsoft.com', + path: '/v1.0/' + path, + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js new file mode 100644 index 0000000000..80564d5b32 --- /dev/null +++ b/src/Adapters/Auth/oauth2.js @@ -0,0 +1,139 @@ +/* + * This auth adapter is based on the OAuth 2.0 Token Introspection specification. + * See RFC 7662 for details (https://tools.ietf.org/html/rfc7662). + * It's purpose is to validate OAuth2 access tokens using the OAuth2 provider's + * token introspection endpoint (if implemented by the provider). + * + * The adapter accepts the following config parameters: + * + * 1. "tokenIntrospectionEndpointUrl" (string, required) + * The URL of the token introspection endpoint of the OAuth2 provider that + * issued the access token to the client that is to be validated. + * + * 2. "useridField" (string, optional) + * The name of the field in the token introspection response that contains + * the userid. If specified, it will be used to verify the value of the "id" + * field in the "authData" JSON that is coming from the client. + * This can be the "aud" (i.e. audience), the "sub" (i.e. subject) or the + * "username" field in the introspection response, but since only the + * "active" field is required and all other reponse fields are optional + * in the RFC, it has to be optional in this adapter as well. + * Default: - (undefined) + * + * 3. "appidField" (string, optional) + * The name of the field in the token introspection response that contains + * the appId of the client. If specified, it will be used to verify it's + * value against the set of appIds in the adapter config. The concept of + * appIds comes from the two major social login providers + * (Google and Facebook). They have not yet implemented the token + * introspection endpoint, but the concept can be valid for any OAuth2 + * provider. + * Default: - (undefined) + * + * 4. "appIds" (array of strings, required if appidField is defined) + * A set of appIds that are used to restrict accepted access tokens based + * on a specific field's value in the token introspection response. + * Default: - (undefined) + * + * 5. "authorizationHeader" (string, optional) + * The value of the "Authorization" HTTP header in requests sent to the + * introspection endpoint. It must contain the raw value. + * Thus if HTTP Basic authorization is to be used, it must contain the + * "Basic" string, followed by whitespace, then by the base64 encoded + * version of the concatenated + ":" + string. + * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" + * + * The adapter expects requests with the following authData JSON: + * + * { + * "someadapter": { + * "id": "user's OAuth2 provider-specific id as a string", + * "access_token": "an authorized OAuth2 access token for the user", + * } + * } + */ + +const Parse = require('parse/node').Parse; +const url = require('url'); +const querystring = require('querystring'); +const httpsRequest = require('./httpsRequest'); + +const INVALID_ACCESS = 'OAuth2 access token is invalid for this user.'; +const INVALID_ACCESS_APPID = + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."; +const MISSING_APPIDS = + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'; +const MISSING_URL = + 'OAuth2 token introspection endpoint URL is missing from configuration!'; + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, options) { + return requestTokenInfo(options, authData.access_token).then(response => { + if ( + !response || + !response.active || + (options.useridField && authData.id !== response[options.useridField]) + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + } + }); +} + +function validateAppId(appIds, authData, options) { + if (!options || !options.appidField) { + return Promise.resolve(); + } + if (!appIds || appIds.length === 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_APPIDS); + } + return requestTokenInfo(options, authData.access_token).then(response => { + if (!response || !response.active) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + } + const appidField = options.appidField; + if (!response[appidField]) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + } + const responseValue = response[appidField]; + if (!Array.isArray(responseValue) && appIds.includes(responseValue)) { + return; + } else if ( + Array.isArray(responseValue) && + responseValue.some(appId => appIds.includes(appId)) + ) { + return; + } else { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + } + }); +} + +// A promise wrapper for requests to the OAuth2 token introspection endpoint. +function requestTokenInfo(options, access_token) { + if (!options || !options.tokenIntrospectionEndpointUrl) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_URL); + } + const parsedUrl = url.parse(options.tokenIntrospectionEndpointUrl); + const postData = querystring.stringify({ + token: access_token, + }); + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData), + }; + if (options.authorizationHeader) { + headers['Authorization'] = options.authorizationHeader; + } + const postOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.pathname, + method: 'POST', + headers: headers, + }; + return httpsRequest.request(postOptions, postData); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/phantauth.js b/src/Adapters/Auth/phantauth.js new file mode 100644 index 0000000000..1fca7e9794 --- /dev/null +++ b/src/Adapters/Auth/phantauth.js @@ -0,0 +1,44 @@ +/* + * PhantAuth was designed to simplify testing for applications using OpenID Connect + * authentication by making use of random generated users. + * + * To learn more, please go to: https://www.phantauth.net + */ + +const { Parse } = require('parse/node'); +const httpsRequest = require('./httpsRequest'); + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData) { + return request('auth/userinfo', authData.access_token).then(data => { + if (data && data.sub == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'PhantAuth auth is invalid for this user.' + ); + }); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return httpsRequest.get({ + host: 'phantauth.net', + path: '/' + path, + headers: { + Authorization: 'bearer ' + access_token, + 'User-Agent': 'parse-server', + }, + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/qq.js b/src/Adapters/Auth/qq.js index 6f4dfdc0cf..45e776665e 100644 --- a/src/Adapters/Auth/qq.js +++ b/src/Adapters/Auth/qq.js @@ -1,14 +1,19 @@ // Helper functions for accessing the qq Graph API. -var https = require('https'); +const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return graphRequest('me?access_token=' + authData.access_token).then(function (data) { + return graphRequest('me?access_token=' + authData.access_token).then(function( + data + ) { if (data && data.openid == authData.id) { return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'qq auth is invalid for this user.' + ); }); } @@ -19,33 +24,28 @@ function validateAppId() { // A promisey wrapper for qq graph requests. function graphRequest(path) { - return new Promise(function (resolve, reject) { - https.get('https://graph.qq.com/oauth2.0/' + path, function (res) { - var data = ''; - res.on('data', function (chunk) { - data += chunk; - }); - res.on('end', function () { - var starPos = data.indexOf("("); - var endPos = data.indexOf(")"); - if(starPos == -1 || endPos == -1){ - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); - } - data = data.substring(starPos + 1,endPos - 1); - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function () { - reject('Failed to validate this access token with qq.'); + return httpsRequest + .get('https://graph.qq.com/oauth2.0/' + path, true) + .then(data => { + return parseResponseData(data); }); - }); +} + +function parseResponseData(data) { + const starPos = data.indexOf('('); + const endPos = data.indexOf(')'); + if (starPos == -1 || endPos == -1) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'qq auth is invalid for this user.' + ); + } + data = data.substring(starPos + 1, endPos - 1); + return JSON.parse(data); } module.exports = { validateAppId, - validateAuthData + validateAuthData, + parseResponseData, }; diff --git a/src/Adapters/Auth/spotify.js b/src/Adapters/Auth/spotify.js index 701422c585..1bafc44ba3 100644 --- a/src/Adapters/Auth/spotify.js +++ b/src/Adapters/Auth/spotify.js @@ -1,18 +1,18 @@ // Helper functions for accessing the Spotify API. -var https = require('https'); +const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return request('me', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is invalid for this user.'); - }); + return request('me', authData.access_token).then(data => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Spotify auth is invalid for this user.' + ); + }); } // Returns a promise that fulfills if this app id is valid. @@ -21,48 +21,32 @@ function validateAppId(appIds, authData) { if (!appIds.length) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is not configured.'); + 'Spotify auth is not configured.' + ); } - return request('me', access_token) - .then((data) => { - if (data && appIds.indexOf(data.id) != -1) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is invalid for this user.'); - }); + return request('me', access_token).then(data => { + if (data && appIds.indexOf(data.id) != -1) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Spotify auth is invalid for this user.' + ); + }); } // A promisey wrapper for Spotify API requests. function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.spotify.com', - path: '/v1/' + path, - headers: { - 'Authorization': 'Bearer ' + access_token - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function() { - reject('Failed to validate this access token with Spotify.'); - }); + return httpsRequest.get({ + host: 'api.spotify.com', + path: '/v1/' + path, + headers: { + Authorization: 'Bearer ' + access_token, + }, }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/twitter.js b/src/Adapters/Auth/twitter.js index ee8517819e..b6dd49a3fb 100644 --- a/src/Adapters/Auth/twitter.js +++ b/src/Adapters/Auth/twitter.js @@ -1,26 +1,29 @@ // Helper functions for accessing the twitter API. var OAuth = require('./OAuth1Client'); var Parse = require('parse/node').Parse; -var logger = require('../../logger').default; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData, options) { - if(!options) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Twitter auth configuration missing'); + if (!options) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Twitter auth configuration missing' + ); } options = handleMultipleConfigurations(authData, options); var client = new OAuth(options); - client.host = "api.twitter.com"; + client.host = 'api.twitter.com'; client.auth_token = authData.auth_token; client.auth_token_secret = authData.auth_token_secret; - return client.get("/1.1/account/verify_credentials.json").then((data) => { + return client.get('/1.1/account/verify_credentials.json').then(data => { if (data && data.id_str == '' + authData.id) { return; } throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'Twitter auth is invalid for this user.'); + 'Twitter auth is invalid for this user.' + ); }); } @@ -33,16 +36,20 @@ function handleMultipleConfigurations(authData, options) { if (Array.isArray(options)) { const consumer_key = authData.consumer_key; if (!consumer_key) { - logger.error('Twitter Auth', 'Multiple twitter configurations are available, by no consumer_key was sent by the client.'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); } - options = options.filter((option) => { + options = options.filter(option => { return option.consumer_key == consumer_key; }); if (options.length == 0) { - logger.error('Twitter Auth','Cannot find a configuration for the provided consumer_key'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); } options = options[0]; } @@ -52,5 +59,5 @@ function handleMultipleConfigurations(authData, options) { module.exports = { validateAppId, validateAuthData, - handleMultipleConfigurations + handleMultipleConfigurations, }; diff --git a/src/Adapters/Auth/vkontakte.js b/src/Adapters/Auth/vkontakte.js index 8c9bd5efc8..9fb2935b2a 100644 --- a/src/Adapters/Auth/vkontakte.js +++ b/src/Adapters/Auth/vkontakte.js @@ -2,35 +2,62 @@ // Helper functions for accessing the vkontakte API. -var https = require('https'); +const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; -var logger = require('../../logger').default; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData, params) { - return vkOAuth2Request(params).then(function (response) { + return vkOAuth2Request(params).then(function(response) { if (response && response.access_token) { - return request("api.vk.com", "method/secure.checkToken?token=" + authData.access_token + "&client_secret=" + params.appSecret + "&access_token=" + response.access_token).then(function (response) { - if (response && response.response && response.response.user_id == authData.id) { + return request( + 'api.vk.com', + 'method/users.get?access_token=' + authData.access_token + '&v=5.8' + ).then(function(response) { + if ( + response && + response.response && + response.response.length && + response.response[0].id == authData.id + ) { return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Vk auth is invalid for this user.' + ); }); } - logger.error('Vk Auth', 'Vk appIds or appSecret is incorrect.'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk appIds or appSecret is incorrect.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Vk appIds or appSecret is incorrect.' + ); }); } function vkOAuth2Request(params) { - return new Promise(function (resolve) { - if (!params || !params.appIds || !params.appIds.length || !params.appSecret || !params.appSecret.length) { - logger.error('Vk Auth', 'Vk auth is not configured. Missing appIds or appSecret.'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is not configured. Missing appIds or appSecret.'); + return new Promise(function(resolve) { + if ( + !params || + !params.appIds || + !params.appIds.length || + !params.appSecret || + !params.appSecret.length + ) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Vk auth is not configured. Missing appIds or appSecret.' + ); } resolve(); - }).then(function () { - return request("oauth.vk.com", "access_token?client_id=" + params.appIds + "&client_secret=" + params.appSecret + "&v=5.59&grant_type=client_credentials"); + }).then(function() { + return request( + 'oauth.vk.com', + 'access_token?client_id=' + + params.appIds + + '&client_secret=' + + params.appSecret + + '&v=5.59&grant_type=client_credentials' + ); }); } @@ -41,27 +68,10 @@ function validateAppId() { // A promisey wrapper for api requests function request(host, path) { - return new Promise(function (resolve, reject) { - https.get("https://" + host + "/" + path, function (res) { - var data = ''; - res.on('data', function (chunk) { - data += chunk; - }); - res.on('end', function () { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function () { - reject('Failed to validate this access token with Vk.'); - }); - }); + return httpsRequest.get('https://' + host + '/' + path); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/src/Adapters/Auth/wechat.js b/src/Adapters/Auth/wechat.js index e42d5c365c..56c2293b52 100644 --- a/src/Adapters/Auth/wechat.js +++ b/src/Adapters/Auth/wechat.js @@ -1,14 +1,19 @@ // Helper functions for accessing the WeChat Graph API. -var https = require('https'); +const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return graphRequest('auth?access_token=' + authData.access_token + '&openid=' + authData.id).then(function (data) { + return graphRequest( + 'auth?access_token=' + authData.access_token + '&openid=' + authData.id + ).then(function(data) { if (data.errcode == 0) { return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'wechat auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'wechat auth is invalid for this user.' + ); }); } @@ -19,27 +24,10 @@ function validateAppId() { // A promisey wrapper for WeChat graph requests. function graphRequest(path) { - return new Promise(function (resolve, reject) { - https.get('https://api.weixin.qq.com/sns/' + path, function (res) { - var data = ''; - res.on('data', function (chunk) { - data += chunk; - }); - res.on('end', function () { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - }).on('error', function () { - reject('Failed to validate this access token with wechat.'); - }); - }); + return httpsRequest.get('https://api.weixin.qq.com/sns/' + path); } module.exports = { validateAppId, - validateAuthData + validateAuthData, }; diff --git a/src/Adapters/Auth/weibo.js b/src/Adapters/Auth/weibo.js index 64efada2f6..bcdaf11a36 100644 --- a/src/Adapters/Auth/weibo.js +++ b/src/Adapters/Auth/weibo.js @@ -1,15 +1,18 @@ // Helper functions for accessing the weibo Graph API. -var https = require('https'); +var httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; var querystring = require('querystring'); // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - return graphRequest(authData.access_token).then(function (data) { + return graphRequest(authData.access_token).then(function(data) { if (data && data.uid == authData.id) { return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'weibo auth is invalid for this user.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'weibo auth is invalid for this user.' + ); }); } @@ -20,45 +23,22 @@ function validateAppId() { // A promisey wrapper for weibo graph requests. function graphRequest(access_token) { - return new Promise(function (resolve, reject) { - var postData = querystring.stringify({ - "access_token":access_token - }); - var options = { - hostname: 'api.weibo.com', - path: '/oauth2/get_token_info', - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData) - } - }; - var req = https.request(options, function(res){ - var data = ''; - res.on('data', function (chunk) { - data += chunk; - }); - res.on('end', function () { - try { - data = JSON.parse(data); - } catch(e) { - return reject(e); - } - resolve(data); - }); - res.on('error', function () { - reject('Failed to validate this access token with weibo.'); - }); - }); - req.on('error', function () { - reject('Failed to validate this access token with weibo.'); - }); - req.write(postData); - req.end(); + var postData = querystring.stringify({ + access_token: access_token, }); + var options = { + hostname: 'api.weibo.com', + path: '/oauth2/get_token_info', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData), + }, + }; + return httpsRequest.request(options, postData); } module.exports = { validateAppId, - validateAuthData + validateAuthData, }; diff --git a/src/Adapters/Cache/CacheAdapter.js b/src/Adapters/Cache/CacheAdapter.js index 1c8c0ce202..224d0c006e 100644 --- a/src/Adapters/Cache/CacheAdapter.js +++ b/src/Adapters/Cache/CacheAdapter.js @@ -1,23 +1,29 @@ /*eslint no-unused-vars: "off"*/ +/** + * @module Adapters + */ +/** + * @interface CacheAdapter + */ export class CacheAdapter { /** * Get a value in the cache - * @param key Cache key to get - * @return Promise that will eventually resolve to the value in the cache. + * @param {String} key Cache key to get + * @return {Promise} that will eventually resolve to the value in the cache. */ get(key) {} /** * Set a value in the cache - * @param key Cache key to set - * @param value Value to set the key - * @param ttl Optional TTL + * @param {String} key Cache key to set + * @param {String} value Value to set the key + * @param {String} ttl Optional TTL */ put(key, value, ttl) {} /** * Remove a value from the cache. - * @param key Cache key to remove + * @param {String} key Cache key to remove */ del(key) {} diff --git a/src/Adapters/Cache/InMemoryCache.js b/src/Adapters/Cache/InMemoryCache.js index 38a0e0f39a..c97f82e34d 100644 --- a/src/Adapters/Cache/InMemoryCache.js +++ b/src/Adapters/Cache/InMemoryCache.js @@ -1,10 +1,7 @@ const DEFAULT_CACHE_TTL = 5 * 1000; - export class InMemoryCache { - constructor({ - ttl = DEFAULT_CACHE_TTL - }) { + constructor({ ttl = DEFAULT_CACHE_TTL }) { this.ttl = ttl; this.cache = Object.create(null); } @@ -32,8 +29,8 @@ export class InMemoryCache { var record = { value: value, - expire: ttl + Date.now() - } + expire: ttl + Date.now(), + }; if (!isNaN(record.expire)) { record.timeout = setTimeout(() => { @@ -59,7 +56,6 @@ export class InMemoryCache { clear() { this.cache = Object.create(null); } - } export default InMemoryCache; diff --git a/src/Adapters/Cache/InMemoryCacheAdapter.js b/src/Adapters/Cache/InMemoryCacheAdapter.js index 585e2eadde..e8036c51da 100644 --- a/src/Adapters/Cache/InMemoryCacheAdapter.js +++ b/src/Adapters/Cache/InMemoryCacheAdapter.js @@ -1,9 +1,8 @@ -import {LRUCache} from './LRUCache'; +import { LRUCache } from './LRUCache'; export class InMemoryCacheAdapter { - constructor(ctx) { - this.cache = new LRUCache(ctx) + this.cache = new LRUCache(ctx); } get(key) { diff --git a/src/Adapters/Cache/LRUCache.js b/src/Adapters/Cache/LRUCache.js index a580768a74..889ca3d015 100644 --- a/src/Adapters/Cache/LRUCache.js +++ b/src/Adapters/Cache/LRUCache.js @@ -1,14 +1,11 @@ import LRU from 'lru-cache'; -import defaults from '../../defaults'; +import defaults from '../../defaults'; export class LRUCache { - constructor({ - ttl = defaults.cacheTTL, - maxSize = defaults.cacheMaxSize, - }) { + constructor({ ttl = defaults.cacheTTL, maxSize = defaults.cacheMaxSize }) { this.cache = new LRU({ max: maxSize, - maxAge: ttl + maxAge: ttl, }); } @@ -27,7 +24,6 @@ export class LRUCache { clear() { this.cache.reset(); } - } export default LRUCache; diff --git a/src/Adapters/Cache/NullCacheAdapter.js b/src/Adapters/Cache/NullCacheAdapter.js index aafd8eaa95..812ee2ee38 100644 --- a/src/Adapters/Cache/NullCacheAdapter.js +++ b/src/Adapters/Cache/NullCacheAdapter.js @@ -1,11 +1,10 @@ export class NullCacheAdapter { - constructor() {} get() { - return new Promise((resolve) => { + return new Promise(resolve => { return resolve(null); - }) + }); } put() { diff --git a/src/Adapters/Cache/RedisCacheAdapter.js b/src/Adapters/Cache/RedisCacheAdapter.js deleted file mode 100644 index a59f1c7e4e..0000000000 --- a/src/Adapters/Cache/RedisCacheAdapter.js +++ /dev/null @@ -1,84 +0,0 @@ -import redis from 'redis'; -import logger from '../../logger'; - -const DEFAULT_REDIS_TTL = 30 * 1000; // 30 seconds in milliseconds - -function debug() { - logger.debug.apply(logger, ['RedisCacheAdapter', ...arguments]); -} - -export class RedisCacheAdapter { - - constructor(redisCtx, ttl = DEFAULT_REDIS_TTL) { - this.client = redis.createClient(redisCtx); - this.p = Promise.resolve(); - this.ttl = ttl; - } - - get(key) { - debug('get', key); - this.p = this.p.then(() => { - return new Promise((resolve) => { - this.client.get(key, function(err, res) { - debug('-> get', key, res); - if(!res) { - return resolve(null); - } - resolve(JSON.parse(res)); - }); - }); - }); - return this.p; - } - - put(key, value, ttl = this.ttl) { - value = JSON.stringify(value); - debug('put', key, value, ttl); - if (ttl === 0) { - return this.p; // ttl of zero is a logical no-op, but redis cannot set expire time of zero - } - if (ttl < 0 || isNaN(ttl)) { - ttl = DEFAULT_REDIS_TTL; - } - this.p = this.p.then(() => { - return new Promise((resolve) => { - if (ttl === Infinity) { - this.client.set(key, value, function() { - resolve(); - }); - } else { - this.client.psetex(key, ttl, value, function() { - resolve(); - }); - } - }); - }); - return this.p; - } - - del(key) { - debug('del', key); - this.p = this.p.then(() => { - return new Promise((resolve) => { - this.client.del(key, function() { - resolve(); - }); - }); - }); - return this.p; - } - - clear() { - debug('clear'); - this.p = this.p.then(() => { - return new Promise((resolve) => { - this.client.flushdb(function() { - resolve(); - }); - }); - }); - return this.p; - } -} - -export default RedisCacheAdapter; diff --git a/src/Adapters/Cache/RedisCacheAdapter/KeyPromiseQueue.js b/src/Adapters/Cache/RedisCacheAdapter/KeyPromiseQueue.js new file mode 100644 index 0000000000..64458f346e --- /dev/null +++ b/src/Adapters/Cache/RedisCacheAdapter/KeyPromiseQueue.js @@ -0,0 +1,43 @@ +// KeyPromiseQueue is a simple promise queue +// used to queue operations per key basis. +// Once the tail promise in the key-queue fulfills, +// the chain on that key will be cleared. +export class KeyPromiseQueue { + constructor() { + this.queue = {}; + } + + enqueue(key, operation) { + const tuple = this.beforeOp(key); + const toAwait = tuple[1]; + const nextOperation = toAwait.then(operation); + const wrappedOperation = nextOperation.then(result => { + this.afterOp(key); + return result; + }); + tuple[1] = wrappedOperation; + return wrappedOperation; + } + + beforeOp(key) { + let tuple = this.queue[key]; + if (!tuple) { + tuple = [0, Promise.resolve()]; + this.queue[key] = tuple; + } + tuple[0]++; + return tuple; + } + + afterOp(key) { + const tuple = this.queue[key]; + if (!tuple) { + return; + } + tuple[0]--; + if (tuple[0] <= 0) { + delete this.queue[key]; + return; + } + } +} diff --git a/src/Adapters/Cache/RedisCacheAdapter/index.js b/src/Adapters/Cache/RedisCacheAdapter/index.js new file mode 100644 index 0000000000..cf0bb71638 --- /dev/null +++ b/src/Adapters/Cache/RedisCacheAdapter/index.js @@ -0,0 +1,114 @@ +import redis from 'redis'; +import logger from '../../../logger'; +import { KeyPromiseQueue } from './KeyPromiseQueue'; + +const DEFAULT_REDIS_TTL = 30 * 1000; // 30 seconds in milliseconds +const FLUSH_DB_KEY = '__flush_db__'; + +function debug() { + logger.debug.apply(logger, ['RedisCacheAdapter', ...arguments]); +} + +const isValidTTL = ttl => typeof ttl === 'number' && ttl > 0; + +export class RedisCacheAdapter { + constructor(redisCtx, ttl = DEFAULT_REDIS_TTL) { + this.ttl = isValidTTL(ttl) ? ttl : DEFAULT_REDIS_TTL; + this.client = redis.createClient(redisCtx); + this.queue = new KeyPromiseQueue(); + } + + get(key) { + debug('get', key); + return this.queue.enqueue( + key, + () => + new Promise(resolve => { + this.client.get(key, function(err, res) { + debug('-> get', key, res); + if (!res) { + return resolve(null); + } + resolve(JSON.parse(res)); + }); + }) + ); + } + + put(key, value, ttl = this.ttl) { + value = JSON.stringify(value); + debug('put', key, value, ttl); + + if (ttl === 0) { + // ttl of zero is a logical no-op, but redis cannot set expire time of zero + return this.queue.enqueue(key, () => Promise.resolve()); + } + + if (ttl === Infinity) { + return this.queue.enqueue( + key, + () => + new Promise(resolve => { + this.client.set(key, value, function() { + resolve(); + }); + }) + ); + } + + if (!isValidTTL(ttl)) { + ttl = this.ttl; + } + + return this.queue.enqueue( + key, + () => + new Promise(resolve => { + this.client.psetex(key, ttl, value, function() { + resolve(); + }); + }) + ); + } + + del(key) { + debug('del', key); + return this.queue.enqueue( + key, + () => + new Promise(resolve => { + this.client.del(key, function() { + resolve(); + }); + }) + ); + } + + clear() { + debug('clear'); + return this.queue.enqueue( + FLUSH_DB_KEY, + () => + new Promise(resolve => { + this.client.flushdb(function() { + resolve(); + }); + }) + ); + } + + // Used for testing + async getAllKeys() { + return new Promise((resolve, reject) => { + this.client.keys('*', (err, keys) => { + if (err) { + reject(err); + } else { + resolve(keys); + } + }); + }); + } +} + +export default RedisCacheAdapter; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index e11a129976..791f9e6bd4 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,10 +1,14 @@ /*eslint no-unused-vars: "off"*/ -/* - Mail Adapter prototype - A MailAdapter should implement at least sendMail() +/** + * @module Adapters + */ +/** + * @interface MailAdapter + * Mail Adapter prototype + * A MailAdapter should implement at least sendMail() */ export class MailAdapter { - /* + /** * A method for sending mail * @param options would have the parameters * - to: the recipient diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 906ac2dbbd..8c92454c2e 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -8,16 +8,24 @@ // * deleteFile(filename) // * getFileData(filename) // * getFileLocation(config, filename) +// Adapter classes should implement the following functions: +// * validateFilename(filename) +// * handleFileStream(filename, req, res, contentType) // -// Default is GridStoreAdapter, which requires mongo +// Default is GridFSBucketAdapter, which requires mongo // and for the API server to be using the DatabaseController with Mongo // database adapter. -import type { Config } from '../../Config' - +import type { Config } from '../../Config'; +import Parse from 'parse/node'; +/** + * @module Adapters + */ +/** + * @interface FilesAdapter + */ export class FilesAdapter { - - /* Responsible for storing the file in order to be retrieved later by its filename + /** Responsible for storing the file in order to be retrieved later by its filename * * @param {string} filename - the filename to save * @param {*} data - the buffer of data from the file @@ -26,32 +34,72 @@ export class FilesAdapter { * * @return {Promise} a promise that should fail if the storage didn't succeed */ - createFile(filename: string, data, contentType: string): Promise { } + createFile(filename: string, data, contentType: string): Promise {} - /* Responsible for deleting the specified file + /** Responsible for deleting the specified file * * @param {string} filename - the filename to delete * * @return {Promise} a promise that should fail if the deletion didn't succeed */ - deleteFile(filename: string): Promise { } + deleteFile(filename: string): Promise {} - /* Responsible for retrieving the data of the specified file + /** Responsible for retrieving the data of the specified file * * @param {string} filename - the name of file to retrieve * * @return {Promise} a promise that should pass with the file data or fail on error */ - getFileData(filename: string): Promise { } + getFileData(filename: string): Promise {} - /* Returns an absolute URL where the file can be accessed + /** Returns an absolute URL where the file can be accessed * * @param {Config} config - server configuration * @param {string} filename * * @return {string} Absolute URL */ - getFileLocation(config: Config, filename: string): string { } + getFileLocation(config: Config, filename: string): string {} + + /** Validate a filename for this adapter type + * + * @param {string} filename + * + * @returns {null|Parse.Error} null if there are no errors + */ + // validateFilename(filename: string): ?Parse.Error {} + + /** Handles Byte-Range Requests for Streaming + * + * @param {string} filename + * @param {object} req + * @param {object} res + * @param {string} contentType + * + * @returns {Promise} Data for byte range + */ + // handleFileStream(filename: string, res: any, req: any, contentType: string): Promise +} + +/** + * Simple filename validation + * + * @param filename + * @returns {null|Parse.Error} + */ +export function validateFilename(filename): ?Parse.Error { + if (filename.length > 128) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.'); + } + + const regx = /^[_a-zA-Z0-9][a-zA-Z0-9@. ~_-]*$/; + if (!filename.match(regx)) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'Filename contains invalid characters.' + ); + } + return null; } export default FilesAdapter; diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js new file mode 100644 index 0000000000..28b238efe3 --- /dev/null +++ b/src/Adapters/Files/GridFSBucketAdapter.js @@ -0,0 +1,148 @@ +/** + GridFSBucketAdapter + Stores files in Mongo using GridStore + Requires the database adapter to be based on mongoclient + + @flow weak + */ + +// @flow-disable-next +import { MongoClient, GridFSBucket, Db } from 'mongodb'; +import { FilesAdapter, validateFilename } from './FilesAdapter'; +import defaults from '../../defaults'; + +export class GridFSBucketAdapter extends FilesAdapter { + _databaseURI: string; + _connectionPromise: Promise; + _mongoOptions: Object; + + constructor(mongoDatabaseURI = defaults.DefaultMongoURI, mongoOptions = {}) { + super(); + this._databaseURI = mongoDatabaseURI; + + const defaultMongoOptions = { + useNewUrlParser: true, + useUnifiedTopology: true, + }; + this._mongoOptions = Object.assign(defaultMongoOptions, mongoOptions); + } + + _connect() { + if (!this._connectionPromise) { + this._connectionPromise = MongoClient.connect( + this._databaseURI, + this._mongoOptions + ).then(client => { + this._client = client; + return client.db(client.s.options.dbName); + }); + } + return this._connectionPromise; + } + + _getBucket() { + return this._connect().then(database => new GridFSBucket(database)); + } + + // For a given config object, filename, and data, store a file + // Returns a promise + async createFile(filename: string, data) { + const bucket = await this._getBucket(); + const stream = await bucket.openUploadStream(filename); + await stream.write(data); + stream.end(); + return new Promise((resolve, reject) => { + stream.on('finish', resolve); + stream.on('error', reject); + }); + } + + async deleteFile(filename: string) { + const bucket = await this._getBucket(); + const documents = await bucket.find({ filename }).toArray(); + if (documents.length === 0) { + throw new Error('FileNotFound'); + } + return Promise.all( + documents.map(doc => { + return bucket.delete(doc._id); + }) + ); + } + + async getFileData(filename: string) { + const bucket = await this._getBucket(); + const stream = bucket.openDownloadStreamByName(filename); + stream.read(); + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', data => { + chunks.push(data); + }); + stream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + stream.on('error', err => { + reject(err); + }); + }); + } + + getFileLocation(config, filename) { + return ( + config.mount + + '/files/' + + config.applicationId + + '/' + + encodeURIComponent(filename) + ); + } + + async handleFileStream(filename: string, req, res, contentType) { + const bucket = await this._getBucket(); + const files = await bucket.find({ filename }).toArray(); + if (files.length === 0) { + throw new Error('FileNotFound'); + } + const parts = req + .get('Range') + .replace(/bytes=/, '') + .split('-'); + const partialstart = parts[0]; + const partialend = parts[1]; + + const start = parseInt(partialstart, 10); + const end = partialend ? parseInt(partialend, 10) : files[0].length - 1; + + res.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': 'bytes ' + start + '-' + end + '/' + files[0].length, + 'Content-Type': contentType, + }); + const stream = bucket.openDownloadStreamByName(filename); + stream.start(start); + stream.on('data', chunk => { + res.write(chunk); + }); + stream.on('error', () => { + res.sendStatus(404); + }); + stream.on('end', () => { + res.end(); + }); + } + + handleShutdown() { + if (!this._client) { + return Promise.resolve(); + } + return this._client.close(false); + } + + validateFilename(filename) { + return validateFilename(filename); + } +} + +export default GridFSBucketAdapter; diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 6814297036..3d6bba1072 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -2,26 +2,41 @@ GridStoreAdapter Stores files in Mongo using GridStore Requires the database adapter to be based on mongoclient + (GridStore is deprecated, Please use GridFSBucket instead) @flow weak */ -import { MongoClient, GridStore, Db} from 'mongodb'; -import { FilesAdapter } from './FilesAdapter'; -import defaults from '../../defaults'; +// @flow-disable-next +import { MongoClient, GridStore, Db } from 'mongodb'; +import { FilesAdapter, validateFilename } from './FilesAdapter'; +import defaults from '../../defaults'; export class GridStoreAdapter extends FilesAdapter { _databaseURI: string; _connectionPromise: Promise; + _mongoOptions: Object; - constructor(mongoDatabaseURI = defaults.DefaultMongoURI) { + constructor(mongoDatabaseURI = defaults.DefaultMongoURI, mongoOptions = {}) { super(); this._databaseURI = mongoDatabaseURI; + + const defaultMongoOptions = { + useNewUrlParser: true, + useUnifiedTopology: true, + }; + this._mongoOptions = Object.assign(defaultMongoOptions, mongoOptions); } _connect() { if (!this._connectionPromise) { - this._connectionPromise = MongoClient.connect(this._databaseURI); + this._connectionPromise = MongoClient.connect( + this._databaseURI, + this._mongoOptions + ).then(client => { + this._client = client; + return client.db(client.s.options.dbName); + }); } return this._connectionPromise; } @@ -29,51 +44,145 @@ export class GridStoreAdapter extends FilesAdapter { // For a given config object, filename, and data, store a file // Returns a promise createFile(filename: string, data) { - return this._connect().then(database => { - const gridStore = new GridStore(database, filename, 'w'); - return gridStore.open(); - }).then(gridStore => { - return gridStore.write(data); - }).then(gridStore => { - return gridStore.close(); - }); + return this._connect() + .then(database => { + const gridStore = new GridStore(database, filename, 'w'); + return gridStore.open(); + }) + .then(gridStore => { + return gridStore.write(data); + }) + .then(gridStore => { + return gridStore.close(); + }); } deleteFile(filename: string) { - return this._connect().then(database => { - const gridStore = new GridStore(database, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.unlink(); - }).then((gridStore) => { - return gridStore.close(); - }); + return this._connect() + .then(database => { + const gridStore = new GridStore(database, filename, 'r'); + return gridStore.open(); + }) + .then(gridStore => { + return gridStore.unlink(); + }) + .then(gridStore => { + return gridStore.close(); + }); } getFileData(filename: string) { - return this._connect().then(database => { - return GridStore.exist(database, filename) - .then(() => { + return this._connect() + .then(database => { + return GridStore.exist(database, filename).then(() => { const gridStore = new GridStore(database, filename, 'r'); return gridStore.open(); }); - }).then(gridStore => { - return gridStore.read(); - }); + }) + .then(gridStore => { + return gridStore.read(); + }); } getFileLocation(config, filename) { - return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); + return ( + config.mount + + '/files/' + + config.applicationId + + '/' + + encodeURIComponent(filename) + ); } - getFileStream(filename: string) { - return this._connect().then(database => { + async handleFileStream(filename: string, req, res, contentType) { + const stream = await this._connect().then(database => { return GridStore.exist(database, filename).then(() => { const gridStore = new GridStore(database, filename, 'r'); return gridStore.open(); }); }); + handleRangeRequest(stream, req, res, contentType); + } + + handleShutdown() { + if (!this._client) { + return Promise.resolve(); + } + return this._client.close(false); + } + + validateFilename(filename) { + return validateFilename(filename); + } +} + +// handleRangeRequest is licensed under Creative Commons Attribution 4.0 International License (https://creativecommons.org/licenses/by/4.0/). +// Author: LEROIB at weightingformypizza (https://weightingformypizza.wordpress.com/2015/06/24/stream-html5-media-content-like-video-audio-from-mongodb-using-express-and-gridstore/). +function handleRangeRequest(stream, req, res, contentType) { + const buffer_size = 1024 * 1024; //1024Kb + // Range request, partial stream the file + const parts = req + .get('Range') + .replace(/bytes=/, '') + .split('-'); + let [start, end] = parts; + const notEnded = !end && end !== 0; + const notStarted = !start && start !== 0; + // No end provided, we want all bytes + if (notEnded) { + end = stream.length - 1; + } + // No start provided, we're reading backwards + if (notStarted) { + start = stream.length - end; + end = start + end - 1; } + + // Data exceeds the buffer_size, cap + if (end - start >= buffer_size) { + end = start + buffer_size - 1; + } + + const contentLength = end - start + 1; + + res.writeHead(206, { + 'Content-Range': 'bytes ' + start + '-' + end + '/' + stream.length, + 'Accept-Ranges': 'bytes', + 'Content-Length': contentLength, + 'Content-Type': contentType, + }); + + stream.seek(start, function() { + // Get gridFile stream + const gridFileStream = stream.stream(true); + let bufferAvail = 0; + let remainingBytesToWrite = contentLength; + let totalBytesWritten = 0; + // Write to response + gridFileStream.on('data', function(data) { + bufferAvail += data.length; + if (bufferAvail > 0) { + // slice returns the same buffer if overflowing + // safe to call in any case + const buffer = data.slice(0, remainingBytesToWrite); + // Write the buffer + res.write(buffer); + // Increment total + totalBytesWritten += buffer.length; + // Decrement remaining + remainingBytesToWrite -= data.length; + // Decrement the available buffer + bufferAvail -= buffer.length; + } + // In case of small slices, all values will be good at that point + // we've written enough, end... + if (totalBytesWritten >= contentLength) { + stream.close(); + res.end(); + this.destroy(); + } + }); + }); } export default GridStoreAdapter; diff --git a/src/Adapters/Logger/LoggerAdapter.js b/src/Adapters/Logger/LoggerAdapter.js index f8b2be5baa..21df5ccd48 100644 --- a/src/Adapters/Logger/LoggerAdapter.js +++ b/src/Adapters/Logger/LoggerAdapter.js @@ -1,16 +1,22 @@ /*eslint no-unused-vars: "off"*/ -// Logger Adapter -// -// Allows you to change the logger mechanism -// -// Adapter classes must implement the following functions: -// * log() {} -// * query(options, callback) /* optional */ -// Default is WinstonLoggerAdapter.js - +/** + * @module Adapters + */ +/** + * @interface LoggerAdapter + * Logger Adapter + * Allows you to change the logger mechanism + * Default is WinstonLoggerAdapter.js + */ export class LoggerAdapter { constructor(options) {} - log(level, message, /* meta */) {} + /** + * log + * @param {String} level + * @param {String} message + * @param {Object} metadata + */ + log(level, message /* meta */) {} } export default LoggerAdapter; diff --git a/src/Adapters/Logger/WinstonLogger.js b/src/Adapters/Logger/WinstonLogger.js index 4cf75cb7ae..04e0283258 100644 --- a/src/Adapters/Logger/WinstonLogger.js +++ b/src/Adapters/Logger/WinstonLogger.js @@ -1,47 +1,75 @@ -import winston from 'winston'; +import winston, { format } from 'winston'; import fs from 'fs'; import path from 'path'; import DailyRotateFile from 'winston-daily-rotate-file'; import _ from 'lodash'; -import defaults from '../../defaults'; +import defaults from '../../defaults'; -const logger = new winston.Logger(); -const additionalTransports = []; +const logger = winston.createLogger(); -function updateTransports(options) { - const transports = Object.assign({}, logger.transports); +function configureTransports(options) { + const transports = []; if (options) { const silent = options.silent; delete options.silent; - if (_.isNull(options.dirname)) { - delete transports['parse-server']; - delete transports['parse-server-error']; - } else if (!_.isUndefined(options.dirname)) { - transports['parse-server'] = new (DailyRotateFile)( - Object.assign({}, { - filename: 'parse-server.info', - name: 'parse-server', - }, options, { timestamp: true })); - transports['parse-server-error'] = new (DailyRotateFile)( - Object.assign({}, { - filename: 'parse-server.err', - name: 'parse-server-error', - }, options, { level: 'error', timestamp: true })); + + try { + if (!_.isNil(options.dirname)) { + const parseServer = new DailyRotateFile( + Object.assign( + { + filename: 'parse-server.info', + json: true, + format: format.combine( + format.timestamp(), + format.splat(), + format.json() + ), + }, + options + ) + ); + parseServer.name = 'parse-server'; + transports.push(parseServer); + + const parseServerError = new DailyRotateFile( + Object.assign( + { + filename: 'parse-server.err', + json: true, + format: format.combine( + format.timestamp(), + format.splat(), + format.json() + ), + }, + options, + { level: 'error' } + ) + ); + parseServerError.name = 'parse-server-error'; + transports.push(parseServerError); + } + } catch (e) { + /* */ } - transports.console = new (winston.transports.Console)( - Object.assign({ + const consoleFormat = options.json ? format.json() : format.simple(); + const consoleOptions = Object.assign( + { colorize: true, name: 'console', - silent - }, options)); + silent, + format: consoleFormat, + }, + options + ); + + transports.push(new winston.transports.Console(consoleOptions)); } - // Mount the additional transports - additionalTransports.forEach((transport) => { - transports[transport.name] = transport; - }); + logger.configure({ - transports: _.values(transports) + transports, }); } @@ -50,8 +78,9 @@ export function configureLogger({ jsonLogs = defaults.jsonLogs, logLevel = winston.level, verbose = defaults.verbose, - silent = defaults.silent } = {}) { - + silent = defaults.silent, + maxLogFiles, +} = {}) { if (verbose) { logLevel = 'verbose'; } @@ -65,34 +94,40 @@ export function configureLogger({ } try { fs.mkdirSync(logsFolder); - } catch (e) { /* */ } + } catch (e) { + /* */ + } } options.dirname = logsFolder; options.level = logLevel; options.silent = silent; + options.maxFiles = maxLogFiles; if (jsonLogs) { options.json = true; options.stringify = true; } - updateTransports(options); + configureTransports(options); } export function addTransport(transport) { - additionalTransports.push(transport); - updateTransports(); + // we will remove the existing transport + // before replacing it with a new one + removeTransport(transport.name); + + logger.add(transport); } export function removeTransport(transport) { - const transportName = typeof transport == 'string' ? transport : transport.name; - const transports = Object.assign({}, logger.transports); - delete transports[transportName]; - logger.configure({ - transports: _.values(transports) - }); - _.remove(additionalTransports, (transport) => { - return transport.name === transportName; + const matchingTransport = logger.transports.find(t1 => { + return typeof transport === 'string' + ? t1.name === transport + : t1 === transport; }); + + if (matchingTransport) { + logger.remove(matchingTransport); + } } export { logger }; diff --git a/src/Adapters/Logger/WinstonLoggerAdapter.js b/src/Adapters/Logger/WinstonLoggerAdapter.js index 70616bbd56..0a662eb28d 100644 --- a/src/Adapters/Logger/WinstonLoggerAdapter.js +++ b/src/Adapters/Logger/WinstonLoggerAdapter.js @@ -28,7 +28,8 @@ export class WinstonLoggerAdapter extends LoggerAdapter { options = {}; } // defaults to 7 days prior - const from = options.from || new Date(Date.now() - (7 * MILLISECONDS_IN_A_DAY)); + const from = + options.from || new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); const until = options.until || new Date(); const limit = options.size || 10; const order = options.order || 'desc'; @@ -38,7 +39,7 @@ export class WinstonLoggerAdapter extends LoggerAdapter { from, until, limit, - order + order, }; return new Promise((resolve, reject) => { @@ -47,14 +48,15 @@ export class WinstonLoggerAdapter extends LoggerAdapter { callback(err); return reject(err); } - if (level == 'error') { + + if (level === 'error') { callback(res['parse-server-error']); resolve(res['parse-server-error']); } else { callback(res['parse-server']); resolve(res['parse-server']); } - }) + }); }); } } diff --git a/src/Adapters/MessageQueue/EventEmitterMQ.js b/src/Adapters/MessageQueue/EventEmitterMQ.js index e23bc83d59..1f0081aad5 100644 --- a/src/Adapters/MessageQueue/EventEmitterMQ.js +++ b/src/Adapters/MessageQueue/EventEmitterMQ.js @@ -35,9 +35,9 @@ class Consumer extends events.EventEmitter { subscribe(channel: string): void { unsubscribe(channel); - const handler = (message) => { + const handler = message => { this.emit('message', channel, message); - } + }; subscriptions.set(channel, handler); this.emitter.on(channel, handler); } @@ -57,9 +57,7 @@ function createSubscriber(): any { const EventEmitterMQ = { createPublisher, - createSubscriber -} + createSubscriber, +}; -export { - EventEmitterMQ -} +export { EventEmitterMQ }; diff --git a/src/Adapters/PubSub/EventEmitterPubSub.js b/src/Adapters/PubSub/EventEmitterPubSub.js index f81388192b..1ecc006e0c 100644 --- a/src/Adapters/PubSub/EventEmitterPubSub.js +++ b/src/Adapters/PubSub/EventEmitterPubSub.js @@ -25,9 +25,9 @@ class Subscriber extends events.EventEmitter { } subscribe(channel: string): void { - const handler = (message) => { + const handler = message => { this.emit('message', channel, message); - } + }; this.subscriptions.set(channel, handler); this.emitter.on(channel, handler); } @@ -51,9 +51,7 @@ function createSubscriber(): any { const EventEmitterPubSub = { createPublisher, - createSubscriber -} + createSubscriber, +}; -export { - EventEmitterPubSub -} +export { EventEmitterPubSub }; diff --git a/src/Adapters/PubSub/PubSubAdapter.js b/src/Adapters/PubSub/PubSubAdapter.js new file mode 100644 index 0000000000..9e6b13dfc7 --- /dev/null +++ b/src/Adapters/PubSub/PubSubAdapter.js @@ -0,0 +1,49 @@ +/*eslint no-unused-vars: "off"*/ +/** + * @module Adapters + */ +/** + * @interface PubSubAdapter + */ +export class PubSubAdapter { + /** + * @returns {PubSubAdapter.Publisher} + */ + static createPublisher() {} + /** + * @returns {PubSubAdapter.Subscriber} + */ + static createSubscriber() {} +} + +/** + * @interface Publisher + * @memberof PubSubAdapter + */ +interface Publisher { + /** + * @param {String} channel the channel in which to publish + * @param {String} message the message to publish + */ + publish(channel: string, message: string): void; +} + +/** + * @interface Subscriber + * @memberof PubSubAdapter + */ +interface Subscriber { + /** + * called when a new subscription the channel is required + * @param {String} channel the channel to subscribe + */ + subscribe(channel: string): void; + + /** + * called when the subscription from the channel should be stopped + * @param {String} channel + */ + unsubscribe(channel: string): void; +} + +export default PubSubAdapter; diff --git a/src/Adapters/PubSub/RedisPubSub.js b/src/Adapters/PubSub/RedisPubSub.js index 7fb62dfca2..e259f09a3b 100644 --- a/src/Adapters/PubSub/RedisPubSub.js +++ b/src/Adapters/PubSub/RedisPubSub.js @@ -1,18 +1,18 @@ import redis from 'redis'; -function createPublisher({redisURL}): any { - return redis.createClient(redisURL, { no_ready_check: true }); +function createPublisher({ redisURL, redisOptions = {} }): any { + redisOptions.no_ready_check = true; + return redis.createClient(redisURL, redisOptions); } -function createSubscriber({redisURL}): any { - return redis.createClient(redisURL, { no_ready_check: true }); +function createSubscriber({ redisURL, redisOptions = {} }): any { + redisOptions.no_ready_check = true; + return redis.createClient(redisURL, redisOptions); } const RedisPubSub = { createPublisher, - createSubscriber -} + createSubscriber, +}; -export { - RedisPubSub -} +export { RedisPubSub }; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js index 58e50df1e6..191fa15b40 100644 --- a/src/Adapters/Push/PushAdapter.js +++ b/src/Adapters/Push/PushAdapter.js @@ -11,7 +11,19 @@ // Default is ParsePushAdapter, which uses GCM for // android push and APNS for ios push. +/** + * @module Adapters + */ +/** + * @interface PushAdapter + */ export class PushAdapter { + /** + * @param {any} body + * @param {Parse.Installation[]} installations + * @param {any} pushStatus + * @returns {Promise} + */ send(body: any, installations: any[], pushStatus: any): ?Promise<*> {} /** @@ -19,7 +31,7 @@ export class PushAdapter { * @returns {Array} An array of valid push types */ getValidPushTypes(): string[] { - return [] + return []; } } diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index ad1b458d25..8ab24fc29b 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -2,9 +2,9 @@ const mongodb = require('mongodb'); const Collection = mongodb.Collection; export default class MongoCollection { - _mongoCollection:Collection; + _mongoCollection: Collection; - constructor(mongoCollection:Collection) { + constructor(mongoCollection: Collection) { this._mongoCollection = mongoCollection; } @@ -13,85 +13,190 @@ export default class MongoCollection { // none, then build the geoindex. // This could be improved a lot but it's not clear if that's a good // idea. Or even if this behavior is a good idea. - find(query, { skip, limit, sort, keys, maxTimeMS, readPreference } = {}) { + find( + query, + { + skip, + limit, + sort, + keys, + maxTimeMS, + readPreference, + hint, + caseInsensitive, + explain, + } = {} + ) { // Support for Full Text Search - $text - if(keys && keys.$score) { + if (keys && keys.$score) { delete keys.$score; - keys.score = {$meta: 'textScore'}; + keys.score = { $meta: 'textScore' }; } - return this._rawFind(query, { skip, limit, sort, keys, maxTimeMS, readPreference }) - .catch(error => { - // Check for "no geoindex" error - if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) { - throw error; - } - // Figure out what key needs an index - const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; - if (!key) { - throw error; - } - - var index = {}; - index[key] = '2d'; - return this._mongoCollection.createIndex(index) + return this._rawFind(query, { + skip, + limit, + sort, + keys, + maxTimeMS, + readPreference, + hint, + caseInsensitive, + explain, + }).catch(error => { + // Check for "no geoindex" error + if ( + error.code != 17007 && + !error.message.match(/unable to find index for .geoNear/) + ) { + throw error; + } + // Figure out what key needs an index + const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; + if (!key) { + throw error; + } + + var index = {}; + index[key] = '2d'; + return ( + this._mongoCollection + .createIndex(index) // Retry, but just once. - .then(() => this._rawFind(query, { skip, limit, sort, keys, maxTimeMS, readPreference })); - }); + .then(() => + this._rawFind(query, { + skip, + limit, + sort, + keys, + maxTimeMS, + readPreference, + hint, + caseInsensitive, + explain, + }) + ) + ); + }); } - _rawFind(query, { skip, limit, sort, keys, maxTimeMS, readPreference } = {}) { - let findOperation = this._mongoCollection - .find(query, { skip, limit, sort, readPreference }) + /** + * Collation to support case insensitive queries + */ + static caseInsensitiveCollation() { + return { locale: 'en_US', strength: 2 }; + } + + _rawFind( + query, + { + skip, + limit, + sort, + keys, + maxTimeMS, + readPreference, + hint, + caseInsensitive, + explain, + } = {} + ) { + let findOperation = this._mongoCollection.find(query, { + skip, + limit, + sort, + readPreference, + hint, + }); if (keys) { findOperation = findOperation.project(keys); } + if (caseInsensitive) { + findOperation = findOperation.collation( + MongoCollection.caseInsensitiveCollation() + ); + } + if (maxTimeMS) { findOperation = findOperation.maxTimeMS(maxTimeMS); } - return findOperation.toArray(); + return explain ? findOperation.explain(explain) : findOperation.toArray(); } - count(query, { skip, limit, sort, maxTimeMS, readPreference } = {}) { - const countOperation = this._mongoCollection.count(query, { skip, limit, sort, maxTimeMS, readPreference }); + count(query, { skip, limit, sort, maxTimeMS, readPreference, hint } = {}) { + // If query is empty, then use estimatedDocumentCount instead. + // This is due to countDocuments performing a scan, + // which greatly increases execution time when being run on large collections. + // See https://github.com/Automattic/mongoose/issues/6713 for more info regarding this problem. + if (typeof query !== 'object' || !Object.keys(query).length) { + return this._mongoCollection.estimatedDocumentCount({ + maxTimeMS, + }); + } + + const countOperation = this._mongoCollection.countDocuments(query, { + skip, + limit, + sort, + maxTimeMS, + readPreference, + hint, + }); return countOperation; } - insertOne(object) { - return this._mongoCollection.insertOne(object); + distinct(field, query) { + return this._mongoCollection.distinct(field, query); + } + + aggregate(pipeline, { maxTimeMS, readPreference, hint, explain } = {}) { + return this._mongoCollection + .aggregate(pipeline, { maxTimeMS, readPreference, hint, explain }) + .toArray(); + } + + insertOne(object, session) { + return this._mongoCollection.insertOne(object, { session }); } // Atomically updates data in the database for a single (first) object that matched the query // If there is nothing that matches the query - does insert // Postgres Note: `INSERT ... ON CONFLICT UPDATE` that is available since 9.5. - upsertOne(query, update) { - return this._mongoCollection.update(query, update, { upsert: true }) + upsertOne(query, update, session) { + return this._mongoCollection.updateOne(query, update, { + upsert: true, + session, + }); } updateOne(query, update) { return this._mongoCollection.updateOne(query, update); } - updateMany(query, update) { - return this._mongoCollection.updateMany(query, update); + updateMany(query, update, session) { + return this._mongoCollection.updateMany(query, update, { session }); } - deleteMany(query) { - return this._mongoCollection.deleteMany(query); + deleteMany(query, session) { + return this._mongoCollection.deleteMany(query, { session }); } _ensureSparseUniqueIndexInBackground(indexRequest) { return new Promise((resolve, reject) => { - this._mongoCollection.ensureIndex(indexRequest, { unique: true, background: true, sparse: true }, (error) => { - if (error) { - reject(error); - } else { - resolve(); + this._mongoCollection.createIndex( + indexRequest, + { unique: true, background: true, sparse: true }, + error => { + if (error) { + reject(error); + } else { + resolve(); + } } - }); + ); }); } diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 051bac65cf..46ba3e8165 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -1,5 +1,5 @@ import MongoCollection from './MongoCollection'; -import Parse from 'parse/node'; +import Parse from 'parse/node'; function mongoFieldToParseSchemaField(type) { if (type[0] === '*') { @@ -15,61 +15,95 @@ function mongoFieldToParseSchemaField(type) { }; } switch (type) { - case 'number': return {type: 'Number'}; - case 'string': return {type: 'String'}; - case 'boolean': return {type: 'Boolean'}; - case 'date': return {type: 'Date'}; - case 'map': - case 'object': return {type: 'Object'}; - case 'array': return {type: 'Array'}; - case 'geopoint': return {type: 'GeoPoint'}; - case 'file': return {type: 'File'}; - case 'bytes': return {type: 'Bytes'}; - case 'polygon': return {type: 'Polygon'}; + case 'number': + return { type: 'Number' }; + case 'string': + return { type: 'String' }; + case 'boolean': + return { type: 'Boolean' }; + case 'date': + return { type: 'Date' }; + case 'map': + case 'object': + return { type: 'Object' }; + case 'array': + return { type: 'Array' }; + case 'geopoint': + return { type: 'GeoPoint' }; + case 'file': + return { type: 'File' }; + case 'bytes': + return { type: 'Bytes' }; + case 'polygon': + return { type: 'Polygon' }; } } const nonFieldSchemaKeys = ['_id', '_metadata', '_client_permissions']; function mongoSchemaFieldsToParseSchemaFields(schema) { - var fieldNames = Object.keys(schema).filter(key => nonFieldSchemaKeys.indexOf(key) === -1); + var fieldNames = Object.keys(schema).filter( + key => nonFieldSchemaKeys.indexOf(key) === -1 + ); var response = fieldNames.reduce((obj, fieldName) => { - obj[fieldName] = mongoFieldToParseSchemaField(schema[fieldName]) + obj[fieldName] = mongoFieldToParseSchemaField(schema[fieldName]); + if ( + schema._metadata && + schema._metadata.fields_options && + schema._metadata.fields_options[fieldName] + ) { + obj[fieldName] = Object.assign( + {}, + obj[fieldName], + schema._metadata.fields_options[fieldName] + ); + } return obj; }, {}); - response.ACL = {type: 'ACL'}; - response.createdAt = {type: 'Date'}; - response.updatedAt = {type: 'Date'}; - response.objectId = {type: 'String'}; + response.ACL = { type: 'ACL' }; + response.createdAt = { type: 'Date' }; + response.updatedAt = { type: 'Date' }; + response.objectId = { type: 'String' }; return response; } const emptyCLPS = Object.freeze({ find: {}, + count: {}, get: {}, create: {}, update: {}, delete: {}, addField: {}, + protectedFields: {}, }); const defaultCLPS = Object.freeze({ - find: {'*': true}, - get: {'*': true}, - create: {'*': true}, - update: {'*': true}, - delete: {'*': true}, - addField: {'*': true}, + find: { '*': true }, + count: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, }); function mongoSchemaToParseSchema(mongoSchema) { let clps = defaultCLPS; - if (mongoSchema._metadata && mongoSchema._metadata.class_permissions) { - clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions}; + let indexes = {}; + if (mongoSchema._metadata) { + if (mongoSchema._metadata.class_permissions) { + clps = { ...emptyCLPS, ...mongoSchema._metadata.class_permissions }; + } + if (mongoSchema._metadata.indexes) { + indexes = { ...mongoSchema._metadata.indexes }; + } } return { className: mongoSchema._id, fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema), classLevelPermissions: clps, + indexes: indexes, }; } @@ -83,23 +117,34 @@ function _mongoSchemaQueryFromNameQuery(name: string, query) { return object; } - // Returns a type suitable for inserting into mongo _SCHEMA collection. // Does no validation. That is expected to be done in Parse Server. function parseFieldTypeToMongoFieldType({ type, targetClass }) { switch (type) { - case 'Pointer': return `*${targetClass}`; - case 'Relation': return `relation<${targetClass}>`; - case 'Number': return 'number'; - case 'String': return 'string'; - case 'Boolean': return 'boolean'; - case 'Date': return 'date'; - case 'Object': return 'object'; - case 'Array': return 'array'; - case 'GeoPoint': return 'geopoint'; - case 'File': return 'file'; - case 'Bytes': return 'bytes'; - case 'Polygon': return 'polygon'; + case 'Pointer': + return `*${targetClass}`; + case 'Relation': + return `relation<${targetClass}>`; + case 'Number': + return 'number'; + case 'String': + return 'string'; + case 'Boolean': + return 'boolean'; + case 'Date': + return 'date'; + case 'Object': + return 'object'; + case 'Array': + return 'array'; + case 'GeoPoint': + return 'geopoint'; + case 'File': + return 'file'; + case 'Bytes': + return 'bytes'; + case 'Polygon': + return 'polygon'; } } @@ -111,31 +156,60 @@ class MongoSchemaCollection { } _fetchAllSchemasFrom_SCHEMA() { - return this._collection._rawFind({}) + return this._collection + ._rawFind({}) .then(schemas => schemas.map(mongoSchemaToParseSchema)); } _fetchOneSchemaFrom_SCHEMA(name: string) { - return this._collection._rawFind(_mongoSchemaQueryFromNameQuery(name), { limit: 1 }).then(results => { - if (results.length === 1) { - return mongoSchemaToParseSchema(results[0]); - } else { - throw undefined; - } - }); + return this._collection + ._rawFind(_mongoSchemaQueryFromNameQuery(name), { limit: 1 }) + .then(results => { + if (results.length === 1) { + return mongoSchemaToParseSchema(results[0]); + } else { + throw undefined; + } + }); } // Atomically find and delete an object based on query. findAndDeleteSchema(name: string) { - return this._collection._mongoCollection.findAndRemove(_mongoSchemaQueryFromNameQuery(name), []); + return this._collection._mongoCollection.findAndRemove( + _mongoSchemaQueryFromNameQuery(name), + [] + ); + } + + insertSchema(schema: any) { + return this._collection + .insertOne(schema) + .then(result => mongoSchemaToParseSchema(result.ops[0])) + .catch(error => { + if (error.code === 11000) { + //Mongo's duplicate key error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Class already exists.' + ); + } else { + throw error; + } + }); } updateSchema(name: string, update) { - return this._collection.updateOne(_mongoSchemaQueryFromNameQuery(name), update); + return this._collection.updateOne( + _mongoSchemaQueryFromNameQuery(name), + update + ); } upsertSchema(name: string, query: string, update) { - return this._collection.upsertOne(_mongoSchemaQueryFromNameQuery(name, query), update); + return this._collection.upsertOne( + _mongoSchemaQueryFromNameQuery(name, query), + update + ); } // Add a field to the schema. If database does not support the field @@ -149,44 +223,79 @@ class MongoSchemaCollection { // Support additional types that Mongo doesn't, like Money, or something. // TODO: don't spend an extra query on finding the schema if the type we are trying to add isn't a GeoPoint. - addFieldIfNotExists(className: string, fieldName: string, type: string) { + addFieldIfNotExists(className: string, fieldName: string, fieldType: string) { return this._fetchOneSchemaFrom_SCHEMA(className) - .then(schema => { - // If a field with this name already exists, it will be handled elsewhere. - if (schema.fields[fieldName] != undefined) { - return; - } - // The schema exists. Check for existing GeoPoints. - if (type.type === 'GeoPoint') { - // Make sure there are not other geopoint fields - if (Object.keys(schema.fields).some(existingField => schema.fields[existingField].type === 'GeoPoint')) { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'MongoDB only supports one GeoPoint field in a class.'); + .then( + schema => { + // If a field with this name already exists, it will be handled elsewhere. + if (schema.fields[fieldName] != undefined) { + return; + } + // The schema exists. Check for existing GeoPoints. + if (fieldType.type === 'GeoPoint') { + // Make sure there are not other geopoint fields + if ( + Object.keys(schema.fields).some( + existingField => + schema.fields[existingField].type === 'GeoPoint' + ) + ) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'MongoDB only supports one GeoPoint field in a class.' + ); + } } - } - return; - }, error => { - // If error is undefined, the schema doesn't exist, and we can create the schema with the field. - // If some other error, reject with it. - if (error === undefined) { return; + }, + error => { + // If error is undefined, the schema doesn't exist, and we can create the schema with the field. + // If some other error, reject with it. + if (error === undefined) { + return; + } + throw error; } - throw error; - }) + ) .then(() => { - // We use $exists and $set to avoid overwriting the field type if it - // already exists. (it could have added inbetween the last query and the update) - return this.upsertSchema( - className, - { [fieldName]: { '$exists': false } }, - { '$set' : { [fieldName]: parseFieldTypeToMongoFieldType(type) } } - ); + const { type, targetClass, ...fieldOptions } = fieldType; + // We use $exists and $set to avoid overwriting the field type if it + // already exists. (it could have added inbetween the last query and the update) + if (fieldOptions && Object.keys(fieldOptions).length > 0) { + return this.upsertSchema( + className, + { [fieldName]: { $exists: false } }, + { + $set: { + [fieldName]: parseFieldTypeToMongoFieldType({ + type, + targetClass, + }), + [`_metadata.fields_options.${fieldName}`]: fieldOptions, + }, + } + ); + } else { + return this.upsertSchema( + className, + { [fieldName]: { $exists: false } }, + { + $set: { + [fieldName]: parseFieldTypeToMongoFieldType({ + type, + targetClass, + }), + }, + } + ); + } }); } } // Exported for testing reasons and because we haven't moved all mongo schema format // related logic into the database adapter yet. -MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema -MongoSchemaCollection.parseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType +MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema; +MongoSchemaCollection.parseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType; -export default MongoSchemaCollection +export default MongoSchemaCollection; diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 3142712a3a..b6902fd764 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -1,5 +1,13 @@ -import MongoCollection from './MongoCollection'; +// @flow +import MongoCollection from './MongoCollection'; import MongoSchemaCollection from './MongoSchemaCollection'; +import { StorageAdapter } from '../StorageAdapter'; +import type { + SchemaType, + QueryType, + StorageClass, + QueryOptions, +} from '../StorageAdapter'; import { parse as parseUrl, format as formatUrl, @@ -10,11 +18,16 @@ import { transformKey, transformWhere, transformUpdate, + transformPointerString, } from './MongoTransform'; -import Parse from 'parse/node'; -import _ from 'lodash'; -import defaults from '../../../defaults'; +// @flow-disable-next +import Parse from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +import defaults from '../../../defaults'; +import logger from '../../../logger'; +// @flow-disable-next const mongodb = require('mongodb'); const MongoClient = mongodb.MongoClient; const ReadPreference = mongodb.ReadPreference; @@ -22,7 +35,8 @@ const ReadPreference = mongodb.ReadPreference; const MongoSchemaCollectionName = '_SCHEMA'; const storageAdapterAllCollections = mongoAdapter => { - return mongoAdapter.connect() + return mongoAdapter + .connect() .then(() => mongoAdapter.database.collections()) .then(collections => { return collections.filter(collection => { @@ -31,12 +45,14 @@ const storageAdapterAllCollections = mongoAdapter => { } // TODO: If you have one app with a collection prefix that happens to be a prefix of another // apps prefix, this will go very very badly. We should fix that somehow. - return (collection.collectionName.indexOf(mongoAdapter._collectionPrefix) == 0); + return ( + collection.collectionName.indexOf(mongoAdapter._collectionPrefix) == 0 + ); }); }); -} +}; -const convertParseSchemaToMongoSchema = ({...schema}) => { +const convertParseSchemaToMongoSchema = ({ ...schema }) => { delete schema.fields._rperm; delete schema.fields._wperm; @@ -49,20 +65,38 @@ const convertParseSchemaToMongoSchema = ({...schema}) => { } return schema; -} +}; // Returns { code, error } if invalid, or { result }, an object // suitable for inserting into _SCHEMA collection, otherwise. -const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions) => { +const mongoSchemaFromFieldsAndClassNameAndCLP = ( + fields, + className, + classLevelPermissions, + indexes +) => { const mongoObject = { _id: className, objectId: 'string', updatedAt: 'string', - createdAt: 'string' + createdAt: 'string', + _metadata: undefined, }; for (const fieldName in fields) { - mongoObject[fieldName] = MongoSchemaCollection.parseFieldTypeToMongoFieldType(fields[fieldName]); + const { type, targetClass, ...fieldOptions } = fields[fieldName]; + mongoObject[ + fieldName + ] = MongoSchemaCollection.parseFieldTypeToMongoFieldType({ + type, + targetClass, + }); + if (fieldOptions && Object.keys(fieldOptions).length > 0) { + mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata.fields_options = + mongoObject._metadata.fields_options || {}; + mongoObject._metadata.fields_options[fieldName] = fieldOptions; + } } if (typeof classLevelPermissions !== 'undefined') { @@ -74,30 +108,49 @@ const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPe } } - return mongoObject; -} + if ( + indexes && + typeof indexes === 'object' && + Object.keys(indexes).length > 0 + ) { + mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata.indexes = indexes; + } + + if (!mongoObject._metadata) { + // cleanup the unused _metadata + delete mongoObject._metadata; + } + return mongoObject; +}; -export class MongoStorageAdapter { +export class MongoStorageAdapter implements StorageAdapter { // Private _uri: string; _collectionPrefix: string; _mongoOptions: Object; // Public - connectionPromise; - database; + connectionPromise: ?Promise; + database: any; + client: MongoClient; + _maxTimeMS: ?number; + canSortOnJoinTables: boolean; constructor({ uri = defaults.DefaultMongoURI, collectionPrefix = '', mongoOptions = {}, - }) { + }: any) { this._uri = uri; this._collectionPrefix = collectionPrefix; this._mongoOptions = mongoOptions; + this._mongoOptions.useNewUrlParser = true; + this._mongoOptions.useUnifiedTopology = true; // MaxTimeMS is not a global MongoDB client option, it is applied per operation. this._maxTimeMS = mongoOptions.maxTimeMS; + this.canSortOnJoinTables = true; delete mongoOptions.maxTimeMS; } @@ -110,103 +163,241 @@ export class MongoStorageAdapter { // encoded const encodedUri = formatUrl(parseUrl(this._uri)); - this.connectionPromise = MongoClient.connect(encodedUri, this._mongoOptions).then(database => { - if (!database) { - delete this.connectionPromise; - return; - } - database.on('error', () => { - delete this.connectionPromise; - }); - database.on('close', () => { + this.connectionPromise = MongoClient.connect(encodedUri, this._mongoOptions) + .then(client => { + // Starting mongoDB 3.0, the MongoClient.connect don't return a DB anymore but a client + // Fortunately, we can get back the options and use them to select the proper DB. + // https://github.com/mongodb/node-mongodb-native/blob/2c35d76f08574225b8db02d7bef687123e6bb018/lib/mongo_client.js#L885 + const options = client.s.options; + const database = client.db(options.dbName); + if (!database) { + delete this.connectionPromise; + return; + } + database.on('error', () => { + delete this.connectionPromise; + }); + database.on('close', () => { + delete this.connectionPromise; + }); + this.client = client; + this.database = database; + }) + .catch(err => { delete this.connectionPromise; + return Promise.reject(err); }); - this.database = database; - }).catch((err) => { - delete this.connectionPromise; - return Promise.reject(err); - }); return this.connectionPromise; } + handleError(error: ?(Error | Parse.Error)): Promise { + if (error && error.code === 13) { + // Unauthorized error + delete this.client; + delete this.database; + delete this.connectionPromise; + logger.error('Received unauthorized error', { error: error }); + } + throw error; + } + handleShutdown() { - if (!this.database) { - return; + if (!this.client) { + return Promise.resolve(); } - this.database.close(false); + return this.client.close(false); } _adaptiveCollection(name: string) { return this.connect() .then(() => this.database.collection(this._collectionPrefix + name)) - .then(rawCollection => new MongoCollection(rawCollection)); + .then(rawCollection => new MongoCollection(rawCollection)) + .catch(err => this.handleError(err)); } - _schemaCollection() { + _schemaCollection(): Promise { return this.connect() .then(() => this._adaptiveCollection(MongoSchemaCollectionName)) .then(collection => new MongoSchemaCollection(collection)); } - classExists(name) { - return this.connect().then(() => { - return this.database.listCollections({ name: this._collectionPrefix + name }).toArray(); - }).then(collections => { - return collections.length > 0; - }); + classExists(name: string) { + return this.connect() + .then(() => { + return this.database + .listCollections({ name: this._collectionPrefix + name }) + .toArray(); + }) + .then(collections => { + return collections.length > 0; + }) + .catch(err => this.handleError(err)); } - setClassLevelPermissions(className, CLPs) { + setClassLevelPermissions(className: string, CLPs: any): Promise { return this._schemaCollection() - .then(schemaCollection => schemaCollection.updateSchema(className, { - $set: { _metadata: { class_permissions: CLPs } } - })); + .then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.class_permissions': CLPs }, + }) + ) + .catch(err => this.handleError(err)); + } + + setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any = {}, + fields: any + ): Promise { + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1 } }; + } + const deletePromises = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} exists, cannot update.` + ); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} does not exist, cannot delete.` + ); + } + if (field.__op === 'Delete') { + const promise = this.dropIndex(className, name); + deletePromises.push(promise); + delete existingIndexes[name]; + } else { + Object.keys(field).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(fields, key)) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Field ${key} does not exist, cannot add index.` + ); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); + } + }); + let insertPromise = Promise.resolve(); + if (insertedIndexes.length > 0) { + insertPromise = this.createIndexes(className, insertedIndexes); + } + return Promise.all(deletePromises) + .then(() => insertPromise) + .then(() => this._schemaCollection()) + .then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.indexes': existingIndexes }, + }) + ) + .catch(err => this.handleError(err)); } - createClass(className, schema) { + setIndexesFromMongo(className: string) { + return this.getIndexes(className) + .then(indexes => { + indexes = indexes.reduce((obj, index) => { + if (index.key._fts) { + delete index.key._fts; + delete index.key._ftsx; + for (const field in index.weights) { + index.key[field] = 'text'; + } + } + obj[index.name] = index.key; + return obj; + }, {}); + return this._schemaCollection().then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.indexes': indexes }, + }) + ); + }) + .catch(err => this.handleError(err)) + .catch(() => { + // Ignore if collection not found + return Promise.resolve(); + }); + } + + createClass(className: string, schema: SchemaType): Promise { schema = convertParseSchemaToMongoSchema(schema); - const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions); + const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP( + schema.fields, + className, + schema.classLevelPermissions, + schema.indexes + ); mongoObject._id = className; - return this._schemaCollection() - .then(schemaCollection => schemaCollection._collection.insertOne(mongoObject)) - .then(result => MongoSchemaCollection._TESTmongoSchemaToParseSchema(result.ops[0])) - .catch(error => { - if (error.code === 11000) { //Mongo's duplicate key error - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Class already exists.'); - } else { - throw error; - } - }) + return this.setIndexesWithSchemaFormat( + className, + schema.indexes, + {}, + schema.fields + ) + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.insertSchema(mongoObject)) + .catch(err => this.handleError(err)); } - addFieldIfNotExists(className, fieldName, type) { + addFieldIfNotExists( + className: string, + fieldName: string, + type: any + ): Promise { return this._schemaCollection() - .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)) - .then(() => this.createIndexesIfNeeded(className, fieldName, type)); + .then(schemaCollection => + schemaCollection.addFieldIfNotExists(className, fieldName, type) + ) + .then(() => this.createIndexesIfNeeded(className, fieldName, type)) + .catch(err => this.handleError(err)); } // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. - deleteClass(className) { - return this._adaptiveCollection(className) - .then(collection => collection.drop()) - .catch(error => { - // 'ns not found' means collection was already gone. Ignore deletion attempt. - if (error.message == 'ns not found') { - return; - } - throw error; - }) - // We've dropped the collection, now remove the _SCHEMA document - .then(() => this._schemaCollection()) - .then(schemaCollection => schemaCollection.findAndDeleteSchema(className)) + deleteClass(className: string) { + return ( + this._adaptiveCollection(className) + .then(collection => collection.drop()) + .catch(error => { + // 'ns not found' means collection was already gone. Ignore deletion attempt. + if (error.message == 'ns not found') { + return; + } + throw error; + }) + // We've dropped the collection, now remove the _SCHEMA document + .then(() => this._schemaCollection()) + .then(schemaCollection => + schemaCollection.findAndDeleteSchema(className) + ) + .catch(err => this.handleError(err)) + ); } - // Delete all data known to this adapter. Used for testing. - deleteAllClasses() { - return storageAdapterAllCollections(this) - .then(collections => Promise.all(collections.map(collection => collection.drop()))); + deleteAllClasses(fast: boolean) { + return storageAdapterAllCollections(this).then(collections => + Promise.all( + collections.map(collection => + fast ? collection.deleteMany({}) : collection.drop() + ) + ) + ); } // Remove the column and all the data. For Relations, the _Join collection is handled @@ -229,59 +420,87 @@ export class MongoStorageAdapter { // may do so. // Returns a Promise. - deleteFields(className, schema, fieldNames) { + deleteFields(className: string, schema: SchemaType, fieldNames: string[]) { const mongoFormatNames = fieldNames.map(fieldName => { if (schema.fields[fieldName].type === 'Pointer') { - return `_p_${fieldName}` + return `_p_${fieldName}`; } else { return fieldName; } }); - const collectionUpdate = { '$unset' : {} }; + const collectionUpdate = { $unset: {} }; mongoFormatNames.forEach(name => { collectionUpdate['$unset'][name] = null; }); - const schemaUpdate = { '$unset' : {} }; + const schemaUpdate = { $unset: {} }; fieldNames.forEach(name => { schemaUpdate['$unset'][name] = null; + schemaUpdate['$unset'][`_metadata.fields_options.${name}`] = null; }); return this._adaptiveCollection(className) .then(collection => collection.updateMany({}, collectionUpdate)) .then(() => this._schemaCollection()) - .then(schemaCollection => schemaCollection.updateSchema(className, schemaUpdate)); + .then(schemaCollection => + schemaCollection.updateSchema(className, schemaUpdate) + ) + .catch(err => this.handleError(err)); } // Return a promise for all schemas known to this adapter, in Parse format. In case the // schemas cannot be retrieved, returns a promise that rejects. Requirements for the // rejection reason are TBD. - getAllClasses() { - return this._schemaCollection().then(schemasCollection => schemasCollection._fetchAllSchemasFrom_SCHEMA()); + getAllClasses(): Promise { + return this._schemaCollection() + .then(schemasCollection => + schemasCollection._fetchAllSchemasFrom_SCHEMA() + ) + .catch(err => this.handleError(err)); } // Return a promise for the schema with the given name, in Parse format. If // this adapter doesn't know about the schema, return a promise that rejects with // undefined as the reason. - getClass(className) { + getClass(className: string): Promise { return this._schemaCollection() - .then(schemasCollection => schemasCollection._fetchOneSchemaFrom_SCHEMA(className)) + .then(schemasCollection => + schemasCollection._fetchOneSchemaFrom_SCHEMA(className) + ) + .catch(err => this.handleError(err)); } // TODO: As yet not particularly well specified. Creates an object. Maybe shouldn't even need the schema, // and should infer from the type. Or maybe does need the schema for validations. Or maybe needs // the schema only for the legacy mongo format. We'll figure that out later. - createObject(className, schema, object) { + createObject( + className: string, + schema: SchemaType, + object: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); - const mongoObject = parseObjectToMongoObjectForCreate(className, object, schema); + const mongoObject = parseObjectToMongoObjectForCreate( + className, + object, + schema + ); return this._adaptiveCollection(className) - .then(collection => collection.insertOne(mongoObject)) + .then(collection => + collection.insertOne(mongoObject, transactionalSession) + ) .catch(error => { - if (error.code === 11000) { // Duplicate value - const err = new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); + if (error.code === 11000) { + // Duplicate value + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); err.underlyingError = error; if (error.message) { - const matches = error.message.match(/index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/); + const matches = error.message.match( + /index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/ + ); if (matches && Array.isArray(matches)) { err.userInfo = { duplicated_field: matches[1] }; } @@ -289,80 +508,214 @@ export class MongoStorageAdapter { throw err; } throw error; - }); + }) + .catch(err => this.handleError(err)); } // Remove all objects that match the given Parse Query. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - deleteObjectsByQuery(className, schema, query) { + deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); return this._adaptiveCollection(className) .then(collection => { const mongoWhere = transformWhere(className, query, schema); - return collection.deleteMany(mongoWhere) + return collection.deleteMany(mongoWhere, transactionalSession); }) - .then(({ result }) => { - if (result.n === 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + .catch(err => this.handleError(err)) + .then( + ({ result }) => { + if (result.n === 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } + return Promise.resolve(); + }, + () => { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Database adapter error' + ); } - return Promise.resolve(); - }, () => { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error'); - }); + ); } // Apply the update to all objects that match the given Parse Query. - updateObjectsByQuery(className, schema, query, update) { + updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection.updateMany(mongoWhere, mongoUpdate)); + .then(collection => + collection.updateMany(mongoWhere, mongoUpdate, transactionalSession) + ) + .catch(err => this.handleError(err)); } // Atomically finds and updates an object based on query. // Return value not currently well specified. - findOneAndUpdate(className, schema, query, update) { + findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection._mongoCollection.findAndModify(mongoWhere, [], mongoUpdate, { new: true })) - .then(result => mongoObjectToParseObject(className, result.value, schema)); + .then(collection => + collection._mongoCollection.findOneAndUpdate(mongoWhere, mongoUpdate, { + returnOriginal: false, + session: transactionalSession || undefined, + }) + ) + .then(result => mongoObjectToParseObject(className, result.value, schema)) + .catch(error => { + if (error.code === 11000) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + } + throw error; + }) + .catch(err => this.handleError(err)); } // Hopefully we can get rid of this. It's only used for config and hooks. - upsertOneObject(className, schema, query, update) { + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection.upsertOne(mongoWhere, mongoUpdate)); + .then(collection => + collection.upsertOne(mongoWhere, mongoUpdate, transactionalSession) + ) + .catch(err => this.handleError(err)); } // Executes a find. Accepts: className, query in Parse format, and { skip, limit, sort }. - find(className, schema, query, { skip, limit, sort, keys, readPreference }) { + find( + className: string, + schema: SchemaType, + query: QueryType, + { + skip, + limit, + sort, + keys, + readPreference, + hint, + caseInsensitive, + explain, + }: QueryOptions + ): Promise { schema = convertParseSchemaToMongoSchema(schema); const mongoWhere = transformWhere(className, query, schema); - const mongoSort = _.mapKeys(sort, (value, fieldName) => transformKey(className, fieldName, schema)); - const mongoKeys = _.reduce(keys, (memo, key) => { - memo[transformKey(className, key, schema)] = 1; - return memo; - }, {}); + const mongoSort = _.mapKeys(sort, (value, fieldName) => + transformKey(className, fieldName, schema) + ); + const mongoKeys = _.reduce( + keys, + (memo, key) => { + if (key === 'ACL') { + memo['_rperm'] = 1; + memo['_wperm'] = 1; + } else { + memo[transformKey(className, key, schema)] = 1; + } + return memo; + }, + {} + ); readPreference = this._parseReadPreference(readPreference); - return this.createTextIndexesIfNeeded(className, query) + return this.createTextIndexesIfNeeded(className, query, schema) .then(() => this._adaptiveCollection(className)) - .then(collection => collection.find(mongoWhere, { - skip, - limit, - sort: mongoSort, - keys: mongoKeys, - maxTimeMS: this._maxTimeMS, - readPreference, - })) - .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))) + .then(collection => + collection.find(mongoWhere, { + skip, + limit, + sort: mongoSort, + keys: mongoKeys, + maxTimeMS: this._maxTimeMS, + readPreference, + hint, + caseInsensitive, + explain, + }) + ) + .then(objects => { + if (explain) { + return objects; + } + return objects.map(object => + mongoObjectToParseObject(className, object, schema) + ); + }) + .catch(err => this.handleError(err)); + } + + ensureIndex( + className: string, + schema: SchemaType, + fieldNames: string[], + indexName: ?string, + caseInsensitive: boolean = false + ): Promise { + schema = convertParseSchemaToMongoSchema(schema); + const indexCreationRequest = {}; + const mongoFieldNames = fieldNames.map(fieldName => + transformKey(className, fieldName, schema) + ); + mongoFieldNames.forEach(fieldName => { + indexCreationRequest[fieldName] = 1; + }); + + const defaultOptions: Object = { background: true, sparse: true }; + const indexNameOptions: Object = indexName ? { name: indexName } : {}; + const caseInsensitiveOptions: Object = caseInsensitive + ? { collation: MongoCollection.caseInsensitiveCollation() } + : {}; + const indexOptions: Object = { + ...defaultOptions, + ...caseInsensitiveOptions, + ...indexNameOptions, + }; + + return this._adaptiveCollection(className) + .then( + collection => + new Promise((resolve, reject) => + collection._mongoCollection.createIndex( + indexCreationRequest, + indexOptions, + error => (error ? reject(error) : resolve()) + ) + ) + ) + .catch(err => this.handleError(err)); } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't @@ -370,44 +723,310 @@ export class MongoStorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - ensureUniqueness(className, schema, fieldNames) { + ensureUniqueness( + className: string, + schema: SchemaType, + fieldNames: string[] + ) { schema = convertParseSchemaToMongoSchema(schema); const indexCreationRequest = {}; - const mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); + const mongoFieldNames = fieldNames.map(fieldName => + transformKey(className, fieldName, schema) + ); mongoFieldNames.forEach(fieldName => { indexCreationRequest[fieldName] = 1; }); return this._adaptiveCollection(className) - .then(collection => collection._ensureSparseUniqueIndexInBackground(indexCreationRequest)) + .then(collection => + collection._ensureSparseUniqueIndexInBackground(indexCreationRequest) + ) .catch(error => { if (error.code === 11000) { - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Tried to ensure field uniqueness for a class that already has duplicates.'); + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); } throw error; - }); + }) + .catch(err => this.handleError(err)); } // Used in tests - _rawFind(className, query) { - return this._adaptiveCollection(className).then(collection => collection.find(query, { - maxTimeMS: this._maxTimeMS, - })); + _rawFind(className: string, query: QueryType) { + return this._adaptiveCollection(className) + .then(collection => + collection.find(query, { + maxTimeMS: this._maxTimeMS, + }) + ) + .catch(err => this.handleError(err)); } // Executes a count. - count(className, schema, query, readPreference) { + count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference: ?string, + hint: ?mixed + ) { schema = convertParseSchemaToMongoSchema(schema); readPreference = this._parseReadPreference(readPreference); return this._adaptiveCollection(className) - .then(collection => collection.count(transformWhere(className, query, schema), { - maxTimeMS: this._maxTimeMS, - readPreference, - })); + .then(collection => + collection.count(transformWhere(className, query, schema, true), { + maxTimeMS: this._maxTimeMS, + readPreference, + hint, + }) + ) + .catch(err => this.handleError(err)); + } + + distinct( + className: string, + schema: SchemaType, + query: QueryType, + fieldName: string + ) { + schema = convertParseSchemaToMongoSchema(schema); + const isPointerField = + schema.fields[fieldName] && schema.fields[fieldName].type === 'Pointer'; + const transformField = transformKey(className, fieldName, schema); + + return this._adaptiveCollection(className) + .then(collection => + collection.distinct( + transformField, + transformWhere(className, query, schema) + ) + ) + .then(objects => { + objects = objects.filter(obj => obj != null); + return objects.map(object => { + if (isPointerField) { + return transformPointerString(schema, fieldName, object); + } + return mongoObjectToParseObject(className, object, schema); + }); + }) + .catch(err => this.handleError(err)); + } + + aggregate( + className: string, + schema: any, + pipeline: any, + readPreference: ?string, + hint: ?mixed, + explain?: boolean + ) { + let isPointerField = false; + pipeline = pipeline.map(stage => { + if (stage.$group) { + stage.$group = this._parseAggregateGroupArgs(schema, stage.$group); + if ( + stage.$group._id && + typeof stage.$group._id === 'string' && + stage.$group._id.indexOf('$_p_') >= 0 + ) { + isPointerField = true; + } + } + if (stage.$match) { + stage.$match = this._parseAggregateArgs(schema, stage.$match); + } + if (stage.$project) { + stage.$project = this._parseAggregateProjectArgs( + schema, + stage.$project + ); + } + return stage; + }); + readPreference = this._parseReadPreference(readPreference); + return this._adaptiveCollection(className) + .then(collection => + collection.aggregate(pipeline, { + readPreference, + maxTimeMS: this._maxTimeMS, + hint, + explain, + }) + ) + .then(results => { + results.forEach(result => { + if (Object.prototype.hasOwnProperty.call(result, '_id')) { + if (isPointerField && result._id) { + result._id = result._id.split('$')[1]; + } + if ( + result._id == null || + result._id == undefined || + (['object', 'string'].includes(typeof result._id) && + _.isEmpty(result._id)) + ) { + result._id = null; + } + result.objectId = result._id; + delete result._id; + } + }); + return results; + }) + .then(objects => + objects.map(object => + mongoObjectToParseObject(className, object, schema) + ) + ) + .catch(err => this.handleError(err)); + } + + // This function will recursively traverse the pipeline and convert any Pointer or Date columns. + // If we detect a pointer column we will rename the column being queried for to match the column + // in the database. We also modify the value to what we expect the value to be in the database + // as well. + // For dates, the driver expects a Date object, but we have a string coming in. So we'll convert + // the string to a Date so the driver can perform the necessary comparison. + // + // The goal of this method is to look for the "leaves" of the pipeline and determine if it needs + // to be converted. The pipeline can have a few different forms. For more details, see: + // https://docs.mongodb.com/manual/reference/operator/aggregation/ + // + // If the pipeline is an array, it means we are probably parsing an '$and' or '$or' operator. In + // that case we need to loop through all of it's children to find the columns being operated on. + // If the pipeline is an object, then we'll loop through the keys checking to see if the key name + // matches one of the schema columns. If it does match a column and the column is a Pointer or + // a Date, then we'll convert the value as described above. + // + // As much as I hate recursion...this seemed like a good fit for it. We're essentially traversing + // down a tree to find a "leaf node" and checking to see if it needs to be converted. + _parseAggregateArgs(schema: any, pipeline: any): any { + if (pipeline === null) { + return null; + } else if (Array.isArray(pipeline)) { + return pipeline.map(value => this._parseAggregateArgs(schema, value)); + } else if (typeof pipeline === 'object') { + const returnValue = {}; + for (const field in pipeline) { + if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + if (typeof pipeline[field] === 'object') { + // Pass objects down to MongoDB...this is more than likely an $exists operator. + returnValue[`_p_${field}`] = pipeline[field]; + } else { + returnValue[ + `_p_${field}` + ] = `${schema.fields[field].targetClass}$${pipeline[field]}`; + } + } else if ( + schema.fields[field] && + schema.fields[field].type === 'Date' + ) { + returnValue[field] = this._convertToDate(pipeline[field]); + } else { + returnValue[field] = this._parseAggregateArgs( + schema, + pipeline[field] + ); + } + + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } + } + return returnValue; + } + return pipeline; + } + + // This function is slightly different than the one above. Rather than trying to combine these + // two functions and making the code even harder to understand, I decided to split it up. The + // difference with this function is we are not transforming the values, only the keys of the + // pipeline. + _parseAggregateProjectArgs(schema: any, pipeline: any): any { + const returnValue = {}; + for (const field in pipeline) { + if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + returnValue[`_p_${field}`] = pipeline[field]; + } else { + returnValue[field] = this._parseAggregateArgs(schema, pipeline[field]); + } + + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } + } + return returnValue; + } + + // This function is slightly different than the two above. MongoDB $group aggregate looks like: + // { $group: { _id: , : { : }, ... } } + // The could be a column name, prefixed with the '$' character. We'll look for + // these and check to see if it is a 'Pointer' or if it's one of createdAt, + // updatedAt or objectId and change it accordingly. + _parseAggregateGroupArgs(schema: any, pipeline: any): any { + if (Array.isArray(pipeline)) { + return pipeline.map(value => + this._parseAggregateGroupArgs(schema, value) + ); + } else if (typeof pipeline === 'object') { + const returnValue = {}; + for (const field in pipeline) { + returnValue[field] = this._parseAggregateGroupArgs( + schema, + pipeline[field] + ); + } + return returnValue; + } else if (typeof pipeline === 'string') { + const field = pipeline.substring(1); + if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + return `$_p_${field}`; + } else if (field == 'createdAt') { + return '$_created_at'; + } else if (field == 'updatedAt') { + return '$_updated_at'; + } + } + return pipeline; } - _parseReadPreference(readPreference) { + // This function will attempt to convert the provided value to a Date object. Since this is part + // of an aggregation pipeline, the value can either be a string or it can be another object with + // an operator in it (like $gt, $lt, etc). Because of this I felt it was easier to make this a + // recursive method to traverse down to the "leaf node" which is going to be the string. + _convertToDate(value: any): any { + if (typeof value === 'string') { + return new Date(value); + } + + const returnValue = {}; + for (const field in value) { + returnValue[field] = this._convertToDate(value[field]); + } + return returnValue; + } + + _parseReadPreference(readPreference: ?string): ?string { if (readPreference) { - switch (readPreference) { + readPreference = readPreference.toUpperCase(); + } + switch (readPreference) { case 'PRIMARY': readPreference = ReadPreference.PRIMARY; break; @@ -423,58 +1042,127 @@ export class MongoStorageAdapter { case 'NEAREST': readPreference = ReadPreference.NEAREST; break; + case undefined: + case null: + case '': + break; default: - throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Not supported read preference.'); - } + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Not supported read preference.' + ); } return readPreference; } - performInitialization() { + performInitialization(): Promise { return Promise.resolve(); } - createIndex(className, index) { + createIndex(className: string, index: any) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.createIndex(index)) + .catch(err => this.handleError(err)); + } + + createIndexes(className: string, indexes: any) { return this._adaptiveCollection(className) - .then(collection => collection._mongoCollection.createIndex(index)); + .then(collection => collection._mongoCollection.createIndexes(indexes)) + .catch(err => this.handleError(err)); } - createIndexesIfNeeded(className, fieldName, type) { + createIndexesIfNeeded(className: string, fieldName: string, type: any) { if (type && type.type === 'Polygon') { const index = { - [fieldName]: '2dsphere' + [fieldName]: '2dsphere', }; return this.createIndex(className, index); } return Promise.resolve(); } - createTextIndexesIfNeeded(className, query) { - for(const fieldName in query) { + createTextIndexesIfNeeded( + className: string, + query: QueryType, + schema: any + ): Promise { + for (const fieldName in query) { if (!query[fieldName] || !query[fieldName].$text) { continue; } - const index = { - [fieldName]: 'text' + const existingIndexes = schema.indexes; + for (const key in existingIndexes) { + const index = existingIndexes[key]; + if (Object.prototype.hasOwnProperty.call(index, fieldName)) { + return Promise.resolve(); + } + } + const indexName = `${fieldName}_text`; + const textIndex = { + [indexName]: { [fieldName]: 'text' }, }; - return this.createIndex(className, index) - .catch((error) => { - if (error.code === 85) { - throw new Parse.Error( - Parse.Error.INTERNAL_SERVER_ERROR, - 'Only one text index is supported, please delete all text indexes to use new field.'); - } - throw error; - }); + return this.setIndexesWithSchemaFormat( + className, + textIndex, + existingIndexes, + schema.fields + ).catch(error => { + if (error.code === 85) { + // Index exist with different options + return this.setIndexesFromMongo(className); + } + throw error; + }); } return Promise.resolve(); } - getIndexes(className) { + getIndexes(className: string) { return this._adaptiveCollection(className) - .then(collection => collection._mongoCollection.indexes()); + .then(collection => collection._mongoCollection.indexes()) + .catch(err => this.handleError(err)); + } + + dropIndex(className: string, index: any) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndex(index)) + .catch(err => this.handleError(err)); + } + + dropAllIndexes(className: string) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndexes()) + .catch(err => this.handleError(err)); + } + + updateSchemaWithIndexes(): Promise { + return this.getAllClasses() + .then(classes => { + const promises = classes.map(schema => { + return this.setIndexesFromMongo(schema.className); + }); + return Promise.all(promises); + }) + .catch(err => this.handleError(err)); + } + + createTransactionalSession(): Promise { + const transactionalSection = this.client.startSession(); + transactionalSection.startTransaction(); + return Promise.resolve(transactionalSection); + } + + commitTransactionalSession(transactionalSection: any): Promise { + return transactionalSection.commitTransaction().then(() => { + transactionalSection.endSession(); + }); + } + + abortTransactionalSession(transactionalSection: any): Promise { + return transactionalSection.abortTransaction().then(() => { + transactionalSection.endSession(); + }); } } export default MongoStorageAdapter; -module.exports = MongoStorageAdapter; // Required for tests diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 9c5c525621..ff025cfd09 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -1,131 +1,199 @@ import log from '../../../logger'; -import _ from 'lodash'; +import _ from 'lodash'; var mongodb = require('mongodb'); var Parse = require('parse/node').Parse; const transformKey = (className, fieldName, schema) => { // Check if the schema is known since it's a built-in field. - switch(fieldName) { - case 'objectId': return '_id'; - case 'createdAt': return '_created_at'; - case 'updatedAt': return '_updated_at'; - case 'sessionToken': return '_session_token'; - case 'lastUsed': return '_last_used'; - case 'timesUsed': return 'times_used'; + switch (fieldName) { + case 'objectId': + return '_id'; + case 'createdAt': + return '_created_at'; + case 'updatedAt': + return '_updated_at'; + case 'sessionToken': + return '_session_token'; + case 'lastUsed': + return '_last_used'; + case 'timesUsed': + return 'times_used'; } - if (schema.fields[fieldName] && schema.fields[fieldName].__type == 'Pointer') { + if ( + schema.fields[fieldName] && + schema.fields[fieldName].__type == 'Pointer' + ) { fieldName = '_p_' + fieldName; - } else if (schema.fields[fieldName] && schema.fields[fieldName].type == 'Pointer') { + } else if ( + schema.fields[fieldName] && + schema.fields[fieldName].type == 'Pointer' + ) { fieldName = '_p_' + fieldName; } return fieldName; -} +}; -const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSchema) => { +const transformKeyValueForUpdate = ( + className, + restKey, + restValue, + parseFormatSchema +) => { // Check if the schema is known since it's a built-in field. var key = restKey; var timeField = false; - switch(key) { - case 'objectId': - case '_id': - if (className === '_GlobalConfig') { - return { - key: key, - value: parseInt(restValue) + switch (key) { + case 'objectId': + case '_id': + if (['_GlobalConfig', '_GraphQLConfig'].includes(className)) { + return { + key: key, + value: parseInt(restValue), + }; } - } - key = '_id'; - break; - case 'createdAt': - case '_created_at': - key = '_created_at'; - timeField = true; - break; - case 'updatedAt': - case '_updated_at': - key = '_updated_at'; - timeField = true; - break; - case 'sessionToken': - case '_session_token': - key = '_session_token'; - break; - case 'expiresAt': - case '_expiresAt': - key = 'expiresAt'; - timeField = true; - break; - case '_email_verify_token_expires_at': - key = '_email_verify_token_expires_at'; - timeField = true; - break; - case '_account_lockout_expires_at': - key = '_account_lockout_expires_at'; - timeField = true; - break; - case '_failed_login_count': - key = '_failed_login_count'; - break; - case '_perishable_token_expires_at': - key = '_perishable_token_expires_at'; - timeField = true; - break; - case '_password_changed_at': - key = '_password_changed_at'; - timeField = true; - break; - case '_rperm': - case '_wperm': - return {key: key, value: restValue}; - case 'lastUsed': - case '_last_used': - key = '_last_used'; - timeField = true; - break; - case 'timesUsed': - case 'times_used': - key = 'times_used'; - timeField = true; - break; + key = '_id'; + break; + case 'createdAt': + case '_created_at': + key = '_created_at'; + timeField = true; + break; + case 'updatedAt': + case '_updated_at': + key = '_updated_at'; + timeField = true; + break; + case 'sessionToken': + case '_session_token': + key = '_session_token'; + break; + case 'expiresAt': + case '_expiresAt': + key = 'expiresAt'; + timeField = true; + break; + case '_email_verify_token_expires_at': + key = '_email_verify_token_expires_at'; + timeField = true; + break; + case '_account_lockout_expires_at': + key = '_account_lockout_expires_at'; + timeField = true; + break; + case '_failed_login_count': + key = '_failed_login_count'; + break; + case '_perishable_token_expires_at': + key = '_perishable_token_expires_at'; + timeField = true; + break; + case '_password_changed_at': + key = '_password_changed_at'; + timeField = true; + break; + case '_rperm': + case '_wperm': + return { key: key, value: restValue }; + case 'lastUsed': + case '_last_used': + key = '_last_used'; + timeField = true; + break; + case 'timesUsed': + case 'times_used': + key = 'times_used'; + timeField = true; + break; } - if ((parseFormatSchema.fields[key] && parseFormatSchema.fields[key].type === 'Pointer') || (!parseFormatSchema.fields[key] && restValue && restValue.__type == 'Pointer')) { + if ( + (parseFormatSchema.fields[key] && + parseFormatSchema.fields[key].type === 'Pointer') || + (!parseFormatSchema.fields[key] && + restValue && + restValue.__type == 'Pointer') + ) { key = '_p_' + key; } // Handle atomic values var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { - if (timeField && (typeof value === 'string')) { + if (timeField && typeof value === 'string') { value = new Date(value); } if (restKey.indexOf('.') > 0) { - return {key, value: restValue} + return { key, value: restValue }; } - return {key, value}; + return { key, value }; } // Handle arrays if (restValue instanceof Array) { value = restValue.map(transformInteriorValue); - return {key, value}; + return { key, value }; } // Handle update operators if (typeof restValue === 'object' && '__op' in restValue) { - return {key, value: transformUpdateOperator(restValue, false)}; + return { key, value: transformUpdateOperator(restValue, false) }; } // Handle normal objects by recursing value = mapValues(restValue, transformInteriorValue); - return {key, value}; -} + return { key, value }; +}; + +const isRegex = value => { + return value && value instanceof RegExp; +}; + +const isStartsWithRegex = value => { + if (!isRegex(value)) { + return false; + } + + const matches = value.toString().match(/\/\^\\Q.*\\E\//); + return !!matches; +}; + +const isAllValuesRegexOrNone = values => { + if (!values || !Array.isArray(values) || values.length === 0) { + return true; + } + + const firstValuesIsRegex = isStartsWithRegex(values[0]); + if (values.length === 1) { + return firstValuesIsRegex; + } + + for (let i = 1, length = values.length; i < length; ++i) { + if (firstValuesIsRegex !== isStartsWithRegex(values[i])) { + return false; + } + } + + return true; +}; + +const isAnyValueRegex = values => { + return values.some(function(value) { + return isRegex(value); + }); +}; const transformInteriorValue = restValue => { - if (restValue !== null && typeof restValue === 'object' && Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + if ( + restValue !== null && + typeof restValue === 'object' && + Object.keys(restValue).some(key => key.includes('$') || key.includes('.')) + ) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); } // Handle atomic values var value = transformInteriorAtom(restValue); @@ -145,7 +213,7 @@ const transformInteriorValue = restValue => { // Handle normal objects by recursing return mapValues(restValue, transformInteriorValue); -} +}; const valueAsDate = value => { if (typeof value === 'string') { @@ -154,179 +222,234 @@ const valueAsDate = value => { return value; } return false; -} +}; -function transformQueryKeyValue(className, key, value, schema) { - switch(key) { - case 'createdAt': - if (valueAsDate(value)) { - return {key: '_created_at', value: valueAsDate(value)} - } - key = '_created_at'; - break; - case 'updatedAt': - if (valueAsDate(value)) { - return {key: '_updated_at', value: valueAsDate(value)} - } - key = '_updated_at'; - break; - case 'expiresAt': - if (valueAsDate(value)) { - return {key: 'expiresAt', value: valueAsDate(value)} - } - break; - case '_email_verify_token_expires_at': - if (valueAsDate(value)) { - return {key: '_email_verify_token_expires_at', value: valueAsDate(value)} - } - break; - case 'objectId': { - if (className === '_GlobalConfig') { - value = parseInt(value); - } - return {key: '_id', value} - } - case '_account_lockout_expires_at': - if (valueAsDate(value)) { - return {key: '_account_lockout_expires_at', value: valueAsDate(value)} - } - break; - case '_failed_login_count': - return {key, value}; - case 'sessionToken': return {key: '_session_token', value} - case '_perishable_token_expires_at': - if (valueAsDate(value)) { - return { key: '_perishable_token_expires_at', value: valueAsDate(value) } - } - break; - case '_password_changed_at': - if (valueAsDate(value)) { - return { key: '_password_changed_at', value: valueAsDate(value) } - } - break; - case '_rperm': - case '_wperm': - case '_perishable_token': - case '_email_verify_token': return {key, value} - case '$or': - return {key: '$or', value: value.map(subQuery => transformWhere(className, subQuery, schema))}; - case '$and': - return {key: '$and', value: value.map(subQuery => transformWhere(className, subQuery, schema))}; - case 'lastUsed': - if (valueAsDate(value)) { - return {key: '_last_used', value: valueAsDate(value)} - } - key = '_last_used'; - break; - case 'timesUsed': - return {key: 'times_used', value: value}; - default: { - // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); - if (authDataMatch) { - const provider = authDataMatch[1]; - // Special-case auth data. - return {key: `_auth_data_${provider}.id`, value}; +function transformQueryKeyValue(className, key, value, schema, count = false) { + switch (key) { + case 'createdAt': + if (valueAsDate(value)) { + return { key: '_created_at', value: valueAsDate(value) }; + } + key = '_created_at'; + break; + case 'updatedAt': + if (valueAsDate(value)) { + return { key: '_updated_at', value: valueAsDate(value) }; + } + key = '_updated_at'; + break; + case 'expiresAt': + if (valueAsDate(value)) { + return { key: 'expiresAt', value: valueAsDate(value) }; + } + break; + case '_email_verify_token_expires_at': + if (valueAsDate(value)) { + return { + key: '_email_verify_token_expires_at', + value: valueAsDate(value), + }; + } + break; + case 'objectId': { + if (['_GlobalConfig', '_GraphQLConfig'].includes(className)) { + value = parseInt(value); + } + return { key: '_id', value }; + } + case '_account_lockout_expires_at': + if (valueAsDate(value)) { + return { + key: '_account_lockout_expires_at', + value: valueAsDate(value), + }; + } + break; + case '_failed_login_count': + return { key, value }; + case 'sessionToken': + return { key: '_session_token', value }; + case '_perishable_token_expires_at': + if (valueAsDate(value)) { + return { + key: '_perishable_token_expires_at', + value: valueAsDate(value), + }; + } + break; + case '_password_changed_at': + if (valueAsDate(value)) { + return { key: '_password_changed_at', value: valueAsDate(value) }; + } + break; + case '_rperm': + case '_wperm': + case '_perishable_token': + case '_email_verify_token': + return { key, value }; + case '$or': + case '$and': + case '$nor': + return { + key: key, + value: value.map(subQuery => + transformWhere(className, subQuery, schema, count) + ), + }; + case 'lastUsed': + if (valueAsDate(value)) { + return { key: '_last_used', value: valueAsDate(value) }; + } + key = '_last_used'; + break; + case 'timesUsed': + return { key: 'times_used', value: value }; + default: { + // Other auth data + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + if (authDataMatch) { + const provider = authDataMatch[1]; + // Special-case auth data. + return { key: `_auth_data_${provider}.id`, value }; + } } } - } const expectedTypeIsArray = - schema && - schema.fields[key] && - schema.fields[key].type === 'Array'; + schema && schema.fields[key] && schema.fields[key].type === 'Array'; const expectedTypeIsPointer = - schema && - schema.fields[key] && - schema.fields[key].type === 'Pointer'; + schema && schema.fields[key] && schema.fields[key].type === 'Pointer'; const field = schema && schema.fields[key]; - if (expectedTypeIsPointer || !schema && value && value.__type === 'Pointer') { + if ( + expectedTypeIsPointer || + (!schema && value && value.__type === 'Pointer') + ) { key = '_p_' + key; } // Handle query constraints - const transformedConstraint = transformConstraint(value, field); + const transformedConstraint = transformConstraint(value, field, count); if (transformedConstraint !== CannotTransform) { if (transformedConstraint.$text) { - return {key: '$text', value: transformedConstraint.$text}; + return { key: '$text', value: transformedConstraint.$text }; } - return {key, value: transformedConstraint}; + if (transformedConstraint.$elemMatch) { + return { key: '$nor', value: [{ [key]: transformedConstraint }] }; + } + return { key, value: transformedConstraint }; } if (expectedTypeIsArray && !(value instanceof Array)) { - return {key, value: { '$all' : [transformInteriorAtom(value)] }}; + return { key, value: { $all: [transformInteriorAtom(value)] } }; } // Handle atomic values if (transformTopLevelAtom(value) !== CannotTransform) { - return {key, value: transformTopLevelAtom(value)}; + return { key, value: transformTopLevelAtom(value) }; } else { - throw new Parse.Error(Parse.Error.INVALID_JSON, `You cannot use ${value} as a query parameter.`); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `You cannot use ${value} as a query parameter.` + ); } } // Main exposed method to help run queries. // restWhere is the "where" clause in REST API form. // Returns the mongo form of the query. -function transformWhere(className, restWhere, schema) { +function transformWhere(className, restWhere, schema, count = false) { const mongoWhere = {}; for (const restKey in restWhere) { - const out = transformQueryKeyValue(className, restKey, restWhere[restKey], schema); + const out = transformQueryKeyValue( + className, + restKey, + restWhere[restKey], + schema, + count + ); mongoWhere[out.key] = out.value; } return mongoWhere; } -const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => { +const parseObjectKeyValueToMongoObjectKeyValue = ( + restKey, + restValue, + schema +) => { // Check if the schema is known since it's a built-in field. let transformedValue; let coercedToDate; - switch(restKey) { - case 'objectId': return {key: '_id', value: restValue}; - case 'expiresAt': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: 'expiresAt', value: coercedToDate}; - case '_email_verify_token_expires_at': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: '_email_verify_token_expires_at', value: coercedToDate}; - case '_account_lockout_expires_at': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: '_account_lockout_expires_at', value: coercedToDate}; - case '_perishable_token_expires_at': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return { key: '_perishable_token_expires_at', value: coercedToDate }; - case '_password_changed_at': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return { key: '_password_changed_at', value: coercedToDate }; - case '_failed_login_count': - case '_rperm': - case '_wperm': - case '_email_verify_token': - case '_hashed_password': - case '_perishable_token': return {key: restKey, value: restValue}; - case 'sessionToken': return {key: '_session_token', value: restValue}; - default: - // Auth data should have been transformed already - if (restKey.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'can only query on ' + restKey); - } - // Trust that the auth data has been transformed and save it directly - if (restKey.match(/^_auth_data_[a-zA-Z0-9_]+$/)) { - return {key: restKey, value: restValue}; - } + switch (restKey) { + case 'objectId': + return { key: '_id', value: restValue }; + case 'expiresAt': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' + ? new Date(transformedValue) + : transformedValue; + return { key: 'expiresAt', value: coercedToDate }; + case '_email_verify_token_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' + ? new Date(transformedValue) + : transformedValue; + return { key: '_email_verify_token_expires_at', value: coercedToDate }; + case '_account_lockout_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' + ? new Date(transformedValue) + : transformedValue; + return { key: '_account_lockout_expires_at', value: coercedToDate }; + case '_perishable_token_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' + ? new Date(transformedValue) + : transformedValue; + return { key: '_perishable_token_expires_at', value: coercedToDate }; + case '_password_changed_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' + ? new Date(transformedValue) + : transformedValue; + return { key: '_password_changed_at', value: coercedToDate }; + case '_failed_login_count': + case '_rperm': + case '_wperm': + case '_email_verify_token': + case '_hashed_password': + case '_perishable_token': + return { key: restKey, value: restValue }; + case 'sessionToken': + return { key: '_session_token', value: restValue }; + default: + // Auth data should have been transformed already + if (restKey.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + restKey + ); + } + // Trust that the auth data has been transformed and save it directly + if (restKey.match(/^_auth_data_[a-zA-Z0-9_]+$/)) { + return { key: restKey, value: restValue }; + } } //skip straight to transformTopLevelAtom for Bytes, they don't show up in the schema for some reason if (restValue && restValue.__type !== 'Bytes') { //Note: We may not know the type of a field here, as the user could be saving (null) to a field //That never existed before, meaning we can't infer the type. - if (schema.fields[restKey] && schema.fields[restKey].type == 'Pointer' || restValue.__type == 'Pointer') { + if ( + (schema.fields[restKey] && schema.fields[restKey].type == 'Pointer') || + restValue.__type == 'Pointer' + ) { restKey = '_p_' + restKey; } } @@ -334,7 +457,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => // Handle atomic values var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { - return {key: restKey, value: value}; + return { key: restKey, value: value }; } // ACLs are handled before this method is called @@ -346,20 +469,25 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => // Handle arrays if (restValue instanceof Array) { value = restValue.map(transformInteriorValue); - return {key: restKey, value: value}; + return { key: restKey, value: value }; } // Handle normal objects by recursing - if (Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + if ( + Object.keys(restValue).some(key => key.includes('$') || key.includes('.')) + ) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); } value = mapValues(restValue, transformInteriorValue); - return {key: restKey, value}; -} + return { key: restKey, value }; +}; const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { restCreate = addLegacyACL(restCreate); - const mongoCreate = {} + const mongoCreate = {}; for (const restKey in restCreate) { if (restCreate[restKey] && restCreate[restKey].__type === 'Relation') { continue; @@ -376,16 +504,20 @@ const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { // Use the legacy mongo format for createdAt and updatedAt if (mongoCreate.createdAt) { - mongoCreate._created_at = new Date(mongoCreate.createdAt.iso || mongoCreate.createdAt); + mongoCreate._created_at = new Date( + mongoCreate.createdAt.iso || mongoCreate.createdAt + ); delete mongoCreate.createdAt; } if (mongoCreate.updatedAt) { - mongoCreate._updated_at = new Date(mongoCreate.updatedAt.iso || mongoCreate.updatedAt); + mongoCreate._updated_at = new Date( + mongoCreate.updatedAt.iso || mongoCreate.updatedAt + ); delete mongoCreate.updatedAt; } return mongoCreate; -} +}; // Main exposed method to help update old objects. const transformUpdate = (className, restUpdate, parseFormatSchema) => { @@ -407,7 +539,12 @@ const transformUpdate = (className, restUpdate, parseFormatSchema) => { if (restUpdate[restKey] && restUpdate[restKey].__type === 'Relation') { continue; } - var out = transformKeyValueForUpdate(className, restKey, restUpdate[restKey], parseFormatSchema); + var out = transformKeyValueForUpdate( + className, + restKey, + restUpdate[restKey], + parseFormatSchema + ); // If the output value is an object with any $ keys, it's an // operator that needs to be lifted onto the top level update @@ -422,11 +559,11 @@ const transformUpdate = (className, restUpdate, parseFormatSchema) => { } return mongoUpdate; -} +}; // Add the legacy _acl format. const addLegacyACL = restObject => { - const restObjectCopy = {...restObject}; + const restObjectCopy = { ...restObject }; const _acl = {}; if (restObject._wperm) { @@ -448,31 +585,40 @@ const addLegacyACL = restObject => { } return restObjectCopy; -} - +}; // A sentinel value that helper transformations return when they // cannot perform a transformation function CannotTransform() {} -const transformInteriorAtom = (atom) => { +const transformInteriorAtom = atom => { // TODO: check validity harder for the __type-defined types - if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { + if ( + typeof atom === 'object' && + atom && + !(atom instanceof Date) && + atom.__type === 'Pointer' + ) { return { __type: 'Pointer', className: atom.className, - objectId: atom.objectId + objectId: atom.objectId, }; } else if (typeof atom === 'function' || typeof atom === 'symbol') { - throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `cannot transform value: ${atom}` + ); } else if (DateCoder.isValidJSON(atom)) { return DateCoder.JSONToDatabase(atom); } else if (BytesCoder.isValidJSON(atom)) { return BytesCoder.JSONToDatabase(atom); + } else if (typeof atom === 'object' && atom && atom.$regex !== undefined) { + return new RegExp(atom.$regex); } else { return atom; } -} +}; // Helper function to transform an atom from REST format to Mongo format. // An atom is anything that can't contain other expressions. So it @@ -482,54 +628,60 @@ const transformInteriorAtom = (atom) => { // Raises an error if this cannot possibly be valid REST format. // Returns CannotTransform if it's just not an atom function transformTopLevelAtom(atom, field) { - switch(typeof atom) { - case 'number': - case 'boolean': - case 'undefined': - return atom; - case 'string': - if (field && field.type === 'Pointer') { - return `${field.targetClass}$${atom}`; - } - return atom; - case 'symbol': - case 'function': - throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); - case 'object': - if (atom instanceof Date) { - // Technically dates are not rest format, but, it seems pretty - // clear what they should be transformed to, so let's just do it. + switch (typeof atom) { + case 'number': + case 'boolean': + case 'undefined': return atom; - } - - if (atom === null) { + case 'string': + if (field && field.type === 'Pointer') { + return `${field.targetClass}$${atom}`; + } return atom; - } + case 'symbol': + case 'function': + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `cannot transform value: ${atom}` + ); + case 'object': + if (atom instanceof Date) { + // Technically dates are not rest format, but, it seems pretty + // clear what they should be transformed to, so let's just do it. + return atom; + } - // TODO: check validity harder for the __type-defined types - if (atom.__type == 'Pointer') { - return `${atom.className}$${atom.objectId}`; - } - if (DateCoder.isValidJSON(atom)) { - return DateCoder.JSONToDatabase(atom); - } - if (BytesCoder.isValidJSON(atom)) { - return BytesCoder.JSONToDatabase(atom); - } - if (GeoPointCoder.isValidJSON(atom)) { - return GeoPointCoder.JSONToDatabase(atom); - } - if (PolygonCoder.isValidJSON(atom)) { - return PolygonCoder.JSONToDatabase(atom); - } - if (FileCoder.isValidJSON(atom)) { - return FileCoder.JSONToDatabase(atom); - } - return CannotTransform; + if (atom === null) { + return atom; + } - default: - // I don't think typeof can ever let us get here - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, `really did not expect value: ${atom}`); + // TODO: check validity harder for the __type-defined types + if (atom.__type == 'Pointer') { + return `${atom.className}$${atom.objectId}`; + } + if (DateCoder.isValidJSON(atom)) { + return DateCoder.JSONToDatabase(atom); + } + if (BytesCoder.isValidJSON(atom)) { + return BytesCoder.JSONToDatabase(atom); + } + if (GeoPointCoder.isValidJSON(atom)) { + return GeoPointCoder.JSONToDatabase(atom); + } + if (PolygonCoder.isValidJSON(atom)) { + return PolygonCoder.JSONToDatabase(atom); + } + if (FileCoder.isValidJSON(atom)) { + return FileCoder.JSONToDatabase(atom); + } + return CannotTransform; + + default: + // I don't think typeof can ever let us get here + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + `really did not expect value: ${atom}` + ); } } @@ -539,13 +691,16 @@ function relativeTimeToDate(text, now = new Date()) { let parts = text.split(' '); // Filter out whitespace - parts = parts.filter((part) => part !== ''); + parts = parts.filter(part => part !== ''); const future = parts[0] === 'in'; const past = parts[parts.length - 1] === 'ago'; - if (!future && !past) { - return { status: 'error', info: "Time should either start with 'in' or end with 'ago'" }; + if (!future && !past && text !== 'now') { + return { + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }; } if (future && past) { @@ -558,11 +713,12 @@ function relativeTimeToDate(text, now = new Date()) { // strip the 'ago' or 'in' if (future) { parts = parts.slice(1); - } else { // past + } else { + // past parts = parts.slice(0, parts.length - 1); } - if (parts.length % 2 !== 0) { + if (parts.length % 2 !== 0 && text !== 'now') { return { status: 'error', info: 'Invalid time string. Dangling unit or number.', @@ -570,8 +726,8 @@ function relativeTimeToDate(text, now = new Date()) { } const pairs = []; - while(parts.length) { - pairs.push([ parts.shift(), parts.shift() ]); + while (parts.length) { + pairs.push([parts.shift(), parts.shift()]); } let seconds = 0; @@ -584,38 +740,53 @@ function relativeTimeToDate(text, now = new Date()) { }; } - switch(interval) { - case 'day': - case 'days': - seconds += val * 86400; // 24 * 60 * 60 - break; + switch (interval) { + case 'yr': + case 'yrs': + case 'year': + case 'years': + seconds += val * 31536000; // 365 * 24 * 60 * 60 + break; - case 'hr': - case 'hrs': - case 'hour': - case 'hours': - seconds += val * 3600; // 60 * 60 - break; + case 'wk': + case 'wks': + case 'week': + case 'weeks': + seconds += val * 604800; // 7 * 24 * 60 * 60 + break; - case 'min': - case 'mins': - case 'minute': - case 'minutes': - seconds += val * 60; - break; + case 'd': + case 'day': + case 'days': + seconds += val * 86400; // 24 * 60 * 60 + break; - case 'sec': - case 'secs': - case 'second': - case 'seconds': - seconds += val; - break; + case 'hr': + case 'hrs': + case 'hour': + case 'hours': + seconds += val * 3600; // 60 * 60 + break; - default: - return { - status: 'error', - info: `Invalid interval: '${interval}'`, - }; + case 'min': + case 'mins': + case 'minute': + case 'minutes': + seconds += val * 60; + break; + + case 'sec': + case 'secs': + case 'second': + case 'seconds': + seconds += val; + break; + + default: + return { + status: 'error', + info: `Invalid interval: '${interval}'`, + }; } } @@ -624,14 +795,19 @@ function relativeTimeToDate(text, now = new Date()) { return { status: 'success', info: 'future', - result: new Date(now.valueOf() + milliseconds) + result: new Date(now.valueOf() + milliseconds), }; - } - if (past) { + } else if (past) { return { status: 'success', info: 'past', - result: new Date(now.valueOf() - milliseconds) + result: new Date(now.valueOf() - milliseconds), + }; + } else { + return { + status: 'success', + info: 'present', + result: new Date(now.valueOf()), }; } } @@ -641,237 +817,351 @@ function relativeTimeToDate(text, now = new Date()) { // If it is not a valid constraint but it could be a valid something // else, return CannotTransform. // inArray is whether this is an array field. -function transformConstraint(constraint, field) { +function transformConstraint(constraint, field, count = false) { const inArray = field && field.type && field.type === 'Array'; if (typeof constraint !== 'object' || !constraint) { return CannotTransform; } - const transformFunction = inArray ? transformInteriorAtom : transformTopLevelAtom; - const transformer = (atom) => { + const transformFunction = inArray + ? transformInteriorAtom + : transformTopLevelAtom; + const transformer = atom => { const result = transformFunction(atom, field); if (result === CannotTransform) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${JSON.stringify(atom)}`); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad atom: ${JSON.stringify(atom)}` + ); } return result; - } + }; // keys is the constraints in reverse alphabetical order. // This is a hack so that: // $regex is handled before $options // $nearSphere is handled before $maxDistance - var keys = Object.keys(constraint).sort().reverse(); + var keys = Object.keys(constraint) + .sort() + .reverse(); var answer = {}; for (var key of keys) { - switch(key) { - case '$lt': - case '$lte': - case '$gt': - case '$gte': - case '$exists': - case '$ne': - case '$eq': { - const val = constraint[key]; - if (val && typeof val === 'object' && val.$relativeTime) { - if (field && field.type !== 'Date') { - throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with Date field'); - } + switch (key) { + case '$lt': + case '$lte': + case '$gt': + case '$gte': + case '$exists': + case '$ne': + case '$eq': { + const val = constraint[key]; + if (val && typeof val === 'object' && val.$relativeTime) { + if (field && field.type !== 'Date') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with Date field' + ); + } - switch (key) { - case '$exists': - case '$ne': - case '$eq': - throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators'); - } + switch (key) { + case '$exists': + case '$ne': + case '$eq': + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } - const parserResult = relativeTimeToDate(val.$relativeTime); - if (parserResult.status === 'success') { - answer[key] = parserResult.result; - break; + const parserResult = relativeTimeToDate(val.$relativeTime); + if (parserResult.status === 'success') { + answer[key] = parserResult.result; + break; + } + + log.info('Error while parsing relative date', parserResult); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $relativeTime (${key}) value. ${parserResult.info}` + ); } - log.info('Error while parsing relative date', parserResult); - throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $relativeTime (${key}) value. ${parserResult.info}`); + answer[key] = transformer(val); + break; } - answer[key] = transformer(val); - break; - } - - case '$in': - case '$nin': { - const arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); - } - answer[key] = _.flatMap(arr, value => { - return ((atom) => { - if (Array.isArray(atom)) { - return value.map(transformer); - } else { - return transformer(atom); - } - })(value); - }); - break; - } - case '$all': { - const arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad ' + key + ' value'); - } - answer[key] = arr.map(transformInteriorAtom); - break; - } - case '$regex': - var s = constraint[key]; - if (typeof s !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); + case '$in': + case '$nin': { + const arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad ' + key + ' value' + ); + } + answer[key] = _.flatMap(arr, value => { + return (atom => { + if (Array.isArray(atom)) { + return value.map(transformer); + } else { + return transformer(atom); + } + })(value); + }); + break; } - answer[key] = s; - break; + case '$all': { + const arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad ' + key + ' value' + ); + } + answer[key] = arr.map(transformInteriorAtom); + + const values = answer[key]; + if (isAnyValueRegex(values) && !isAllValuesRegexOrNone(values)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'All $all values must be of regex type or none: ' + values + ); + } - case '$options': - answer[key] = constraint[key]; - break; + break; + } + case '$regex': + var s = constraint[key]; + if (typeof s !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); + } + answer[key] = s; + break; - case '$text': { - const search = constraint[key].$search; - if (typeof search !== 'object') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $search, should be object` - ); + case '$containedBy': { + const arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $containedBy: should be an array` + ); + } + answer.$elemMatch = { + $nin: arr.map(transformer), + }; + break; } - if (!search.$term || typeof search.$term !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $term, should be string` - ); - } else { - answer[key] = { - '$search': search.$term + case '$options': + answer[key] = constraint[key]; + break; + + case '$text': { + const search = constraint[key].$search; + if (typeof search !== 'object') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $search, should be object` + ); + } + if (!search.$term || typeof search.$term !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $term, should be string` + ); + } else { + answer[key] = { + $search: search.$term, + }; } + if (search.$language && typeof search.$language !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $language, should be string` + ); + } else if (search.$language) { + answer[key].$language = search.$language; + } + if ( + search.$caseSensitive && + typeof search.$caseSensitive !== 'boolean' + ) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $caseSensitive, should be boolean` + ); + } else if (search.$caseSensitive) { + answer[key].$caseSensitive = search.$caseSensitive; + } + if ( + search.$diacriticSensitive && + typeof search.$diacriticSensitive !== 'boolean' + ) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $diacriticSensitive, should be boolean` + ); + } else if (search.$diacriticSensitive) { + answer[key].$diacriticSensitive = search.$diacriticSensitive; + } + break; } - if (search.$language && typeof search.$language !== 'string') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $language, should be string` - ); - } else if (search.$language) { - answer[key].$language = search.$language; + case '$nearSphere': { + const point = constraint[key]; + if (count) { + answer.$geoWithin = { + $centerSphere: [ + [point.longitude, point.latitude], + constraint.$maxDistance, + ], + }; + } else { + answer[key] = [point.longitude, point.latitude]; + } + break; } - if (search.$caseSensitive && typeof search.$caseSensitive !== 'boolean') { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $caseSensitive, should be boolean` - ); - } else if (search.$caseSensitive) { - answer[key].$caseSensitive = search.$caseSensitive; + case '$maxDistance': { + if (count) { + break; + } + answer[key] = constraint[key]; + break; } - if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { + // The SDKs don't seem to use these but they are documented in the + // REST API docs. + case '$maxDistanceInRadians': + answer['$maxDistance'] = constraint[key]; + break; + case '$maxDistanceInMiles': + answer['$maxDistance'] = constraint[key] / 3959; + break; + case '$maxDistanceInKilometers': + answer['$maxDistance'] = constraint[key] / 6371; + break; + + case '$select': + case '$dontSelect': throw new Parse.Error( - Parse.Error.INVALID_JSON, - `bad $text: $diacriticSensitive, should be boolean` + Parse.Error.COMMAND_UNAVAILABLE, + 'the ' + key + ' constraint is not supported yet' ); - } else if (search.$diacriticSensitive) { - answer[key].$diacriticSensitive = search.$diacriticSensitive; - } - break; - } - case '$nearSphere': - var point = constraint[key]; - answer[key] = [point.longitude, point.latitude]; - break; - case '$maxDistance': - answer[key] = constraint[key]; - break; - - // The SDKs don't seem to use these but they are documented in the - // REST API docs. - case '$maxDistanceInRadians': - answer['$maxDistance'] = constraint[key]; - break; - case '$maxDistanceInMiles': - answer['$maxDistance'] = constraint[key] / 3959; - break; - case '$maxDistanceInKilometers': - answer['$maxDistance'] = constraint[key] / 6371; - break; - - case '$select': - case '$dontSelect': - throw new Parse.Error( - Parse.Error.COMMAND_UNAVAILABLE, - 'the ' + key + ' constraint is not supported yet'); - - case '$within': - var box = constraint[key]['$box']; - if (!box || box.length != 2) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'malformatted $within arg'); - } - answer[key] = { - '$box': [ - [box[0].longitude, box[0].latitude], - [box[1].longitude, box[1].latitude] - ] - }; - break; + case '$within': + var box = constraint[key]['$box']; + if (!box || box.length != 2) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'malformatted $within arg' + ); + } + answer[key] = { + $box: [ + [box[0].longitude, box[0].latitude], + [box[1].longitude, box[1].latitude], + ], + }; + break; - case '$geoWithin': { - const polygon = constraint[key]['$polygon']; - if (!(polygon instanceof Array)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' - ); - } - if (polygon.length < 3) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' - ); + case '$geoWithin': { + const polygon = constraint[key]['$polygon']; + const centerSphere = constraint[key]['$centerSphere']; + if (polygon !== undefined) { + let points; + if (typeof polygon === 'object' && polygon.__type === 'Polygon') { + if (!polygon.coordinates || polygon.coordinates.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; Polygon.coordinates should contain at least 3 lon/lat pairs' + ); + } + points = polygon.coordinates; + } else if (polygon instanceof Array) { + if (polygon.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + ); + } + points = polygon; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + "bad $geoWithin value; $polygon should be Polygon object or Array of Parse.GeoPoint's" + ); + } + points = points.map(point => { + if (point instanceof Array && point.length === 2) { + Parse.GeoPoint._validate(point[1], point[0]); + return point; + } + if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + return [point.longitude, point.latitude]; + }); + answer[key] = { + $polygon: points, + }; + } else if (centerSphere !== undefined) { + if (!(centerSphere instanceof Array) || centerSphere.length < 2) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' + ); + } + // Get point, convert to geo point if necessary and validate + let point = centerSphere[0]; + if (point instanceof Array && point.length === 2) { + point = new Parse.GeoPoint(point[1], point[0]); + } else if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere geo point invalid' + ); + } + Parse.GeoPoint._validate(point.latitude, point.longitude); + // Get distance and validate + const distance = centerSphere[1]; + if (isNaN(distance) || distance < 0) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere distance invalid' + ); + } + answer[key] = { + $centerSphere: [[point.longitude, point.latitude], distance], + }; + } + break; } - const points = polygon.map((point) => { + case '$geoIntersects': { + const point = constraint[key]['$point']; if (!GeoPointCoder.isValidJSON(point)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoIntersect value; $point should be GeoPoint' + ); } else { Parse.GeoPoint._validate(point.latitude, point.longitude); } - return [point.longitude, point.latitude]; - }); - answer[key] = { - '$polygon': points - }; - break; - } - case '$geoIntersects': { - const point = constraint[key]['$point']; - if (!GeoPointCoder.isValidJSON(point)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad $geoIntersect value; $point should be GeoPoint' - ); - } else { - Parse.GeoPoint._validate(point.latitude, point.longitude); + answer[key] = { + $geometry: { + type: 'Point', + coordinates: [point.longitude, point.latitude], + }, + }; + break; } - answer[key] = { - $geometry: { - type: 'Point', - coordinates: [point.longitude, point.latitude] + default: + if (key.match(/^\$+/)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad constraint: ' + key + ); } - }; - break; - } - default: - if (key.match(/^\$+/)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad constraint: ' + key); - } - return CannotTransform; + return CannotTransform; } } return answer; @@ -887,275 +1177,340 @@ function transformConstraint(constraint, field) { // The output for a flattened operator is just a value. // Returns undefined if this should be a no-op. -function transformUpdateOperator({ - __op, - amount, - objects, -}, flatten) { - switch(__op) { - case 'Delete': - if (flatten) { - return undefined; - } else { - return {__op: '$unset', arg: ''}; - } +function transformUpdateOperator({ __op, amount, objects }, flatten) { + switch (__op) { + case 'Delete': + if (flatten) { + return undefined; + } else { + return { __op: '$unset', arg: '' }; + } - case 'Increment': - if (typeof amount !== 'number') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); - } - if (flatten) { - return amount; - } else { - return {__op: '$inc', arg: amount}; - } + case 'Increment': + if (typeof amount !== 'number') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'incrementing must provide a number' + ); + } + if (flatten) { + return amount; + } else { + return { __op: '$inc', arg: amount }; + } - case 'Add': - case 'AddUnique': - if (!(objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - var toAdd = objects.map(transformInteriorAtom); - if (flatten) { - return toAdd; - } else { - var mongoOp = { - Add: '$push', - AddUnique: '$addToSet' - }[__op]; - return {__op: mongoOp, arg: {'$each': toAdd}}; - } + case 'Add': + case 'AddUnique': + if (!(objects instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to add must be an array' + ); + } + var toAdd = objects.map(transformInteriorAtom); + if (flatten) { + return toAdd; + } else { + var mongoOp = { + Add: '$push', + AddUnique: '$addToSet', + }[__op]; + return { __op: mongoOp, arg: { $each: toAdd } }; + } - case 'Remove': - if (!(objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); - } - var toRemove = objects.map(transformInteriorAtom); - if (flatten) { - return []; - } else { - return {__op: '$pullAll', arg: toRemove}; - } + case 'Remove': + if (!(objects instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to remove must be an array' + ); + } + var toRemove = objects.map(transformInteriorAtom); + if (flatten) { + return []; + } else { + return { __op: '$pullAll', arg: toRemove }; + } - default: - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, `The ${__op} operator is not supported yet.`); + default: + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + `The ${__op} operator is not supported yet.` + ); } } function mapValues(object, iterator) { const result = {}; - Object.keys(object).forEach((key) => { + Object.keys(object).forEach(key => { result[key] = iterator(object[key]); }); return result; } const nestedMongoObjectToNestedParseObject = mongoObject => { - switch(typeof mongoObject) { - case 'string': - case 'number': - case 'boolean': - return mongoObject; - case 'undefined': - case 'symbol': - case 'function': - throw 'bad value in mongoObjectToParseObject'; - case 'object': - if (mongoObject === null) { - return null; - } - if (mongoObject instanceof Array) { - return mongoObject.map(nestedMongoObjectToNestedParseObject); - } + switch (typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + return mongoObject; + case 'symbol': + case 'function': + throw 'bad value in nestedMongoObjectToNestedParseObject'; + case 'object': + if (mongoObject === null) { + return null; + } + if (mongoObject instanceof Array) { + return mongoObject.map(nestedMongoObjectToNestedParseObject); + } - if (mongoObject instanceof Date) { - return Parse._encode(mongoObject); - } + if (mongoObject instanceof Date) { + return Parse._encode(mongoObject); + } - if (mongoObject instanceof mongodb.Long) { - return mongoObject.toNumber(); - } + if (mongoObject instanceof mongodb.Long) { + return mongoObject.toNumber(); + } - if (mongoObject instanceof mongodb.Double) { - return mongoObject.value; - } + if (mongoObject instanceof mongodb.Double) { + return mongoObject.value; + } - if (BytesCoder.isValidDatabaseObject(mongoObject)) { - return BytesCoder.databaseToJSON(mongoObject); - } + if (BytesCoder.isValidDatabaseObject(mongoObject)) { + return BytesCoder.databaseToJSON(mongoObject); + } - if (mongoObject.hasOwnProperty('__type') && mongoObject.__type == 'Date' && mongoObject.iso instanceof Date) { - mongoObject.iso = mongoObject.iso.toJSON(); - return mongoObject; - } + if ( + Object.prototype.hasOwnProperty.call(mongoObject, '__type') && + mongoObject.__type == 'Date' && + mongoObject.iso instanceof Date + ) { + mongoObject.iso = mongoObject.iso.toJSON(); + return mongoObject; + } - return mapValues(mongoObject, nestedMongoObjectToNestedParseObject); - default: - throw 'unknown js type'; + return mapValues(mongoObject, nestedMongoObjectToNestedParseObject); + default: + throw 'unknown js type'; } -} +}; + +const transformPointerString = (schema, field, pointerString) => { + const objData = pointerString.split('$'); + if (objData[0] !== schema.fields[field].targetClass) { + throw 'pointer to incorrect className'; + } + return { + __type: 'Pointer', + className: objData[0], + objectId: objData[1], + }; +}; // Converts from a mongo-format object to a REST-format object. // Does not strip out anything based on a lack of authentication. const mongoObjectToParseObject = (className, mongoObject, schema) => { - switch(typeof mongoObject) { - case 'string': - case 'number': - case 'boolean': - return mongoObject; - case 'undefined': - case 'symbol': - case 'function': - throw 'bad value in mongoObjectToParseObject'; - case 'object': { - if (mongoObject === null) { - return null; - } - if (mongoObject instanceof Array) { - return mongoObject.map(nestedMongoObjectToNestedParseObject); - } - - if (mongoObject instanceof Date) { - return Parse._encode(mongoObject); - } + switch (typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + return mongoObject; + case 'symbol': + case 'function': + throw 'bad value in mongoObjectToParseObject'; + case 'object': { + if (mongoObject === null) { + return null; + } + if (mongoObject instanceof Array) { + return mongoObject.map(nestedMongoObjectToNestedParseObject); + } - if (mongoObject instanceof mongodb.Long) { - return mongoObject.toNumber(); - } + if (mongoObject instanceof Date) { + return Parse._encode(mongoObject); + } - if (mongoObject instanceof mongodb.Double) { - return mongoObject.value; - } + if (mongoObject instanceof mongodb.Long) { + return mongoObject.toNumber(); + } - if (BytesCoder.isValidDatabaseObject(mongoObject)) { - return BytesCoder.databaseToJSON(mongoObject); - } + if (mongoObject instanceof mongodb.Double) { + return mongoObject.value; + } - const restObject = {}; - if (mongoObject._rperm || mongoObject._wperm) { - restObject._rperm = mongoObject._rperm || []; - restObject._wperm = mongoObject._wperm || []; - delete mongoObject._rperm; - delete mongoObject._wperm; - } + if (BytesCoder.isValidDatabaseObject(mongoObject)) { + return BytesCoder.databaseToJSON(mongoObject); + } - for (var key in mongoObject) { - switch(key) { - case '_id': - restObject['objectId'] = '' + mongoObject[key]; - break; - case '_hashed_password': - restObject._hashed_password = mongoObject[key]; - break; - case '_acl': - break; - case '_email_verify_token': - case '_perishable_token': - case '_perishable_token_expires_at': - case '_password_changed_at': - case '_tombstone': - case '_email_verify_token_expires_at': - case '_account_lockout_expires_at': - case '_failed_login_count': - case '_password_history': - // Those keys will be deleted if needed in the DB Controller - restObject[key] = mongoObject[key]; - break; - case '_session_token': - restObject['sessionToken'] = mongoObject[key]; - break; - case 'updatedAt': - case '_updated_at': - restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'createdAt': - case '_created_at': - restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'expiresAt': - case '_expiresAt': - restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); - break; - case 'lastUsed': - case '_last_used': - restObject['lastUsed'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'timesUsed': - case 'times_used': - restObject['timesUsed'] = mongoObject[key]; - break; - default: - // Check other auth data keys - var authDataMatch = key.match(/^_auth_data_([a-zA-Z0-9_]+)$/); - if (authDataMatch) { - var provider = authDataMatch[1]; - restObject['authData'] = restObject['authData'] || {}; - restObject['authData'][provider] = mongoObject[key]; - break; - } + const restObject = {}; + if (mongoObject._rperm || mongoObject._wperm) { + restObject._rperm = mongoObject._rperm || []; + restObject._wperm = mongoObject._wperm || []; + delete mongoObject._rperm; + delete mongoObject._wperm; + } - if (key.indexOf('_p_') == 0) { - var newKey = key.substring(3); - if (!schema.fields[newKey]) { - log.info('transform.js', 'Found a pointer column not in the schema, dropping it.', className, newKey); + for (var key in mongoObject) { + switch (key) { + case '_id': + restObject['objectId'] = '' + mongoObject[key]; break; - } - if (schema.fields[newKey].type !== 'Pointer') { - log.info('transform.js', 'Found a pointer in a non-pointer column, dropping it.', className, key); + case '_hashed_password': + restObject._hashed_password = mongoObject[key]; break; - } - if (mongoObject[key] === null) { + case '_acl': break; - } - var objData = mongoObject[key].split('$'); - if (objData[0] !== schema.fields[newKey].targetClass) { - throw 'pointer to incorrect className'; - } - restObject[newKey] = { - __type: 'Pointer', - className: objData[0], - objectId: objData[1] - }; - break; - } else if (key[0] == '_' && key != '__type') { - throw ('bad key in untransform: ' + key); - } else { - var value = mongoObject[key]; - if (schema.fields[key] && schema.fields[key].type === 'File' && FileCoder.isValidDatabaseObject(value)) { - restObject[key] = FileCoder.databaseToJSON(value); + case '_email_verify_token': + case '_perishable_token': + case '_perishable_token_expires_at': + case '_password_changed_at': + case '_tombstone': + case '_email_verify_token_expires_at': + case '_account_lockout_expires_at': + case '_failed_login_count': + case '_password_history': + // Those keys will be deleted if needed in the DB Controller + restObject[key] = mongoObject[key]; break; - } - if (schema.fields[key] && schema.fields[key].type === 'GeoPoint' && GeoPointCoder.isValidDatabaseObject(value)) { - restObject[key] = GeoPointCoder.databaseToJSON(value); + case '_session_token': + restObject['sessionToken'] = mongoObject[key]; break; - } - if (schema.fields[key] && schema.fields[key].type === 'Polygon' && PolygonCoder.isValidDatabaseObject(value)) { - restObject[key] = PolygonCoder.databaseToJSON(value); + case 'updatedAt': + case '_updated_at': + restObject['updatedAt'] = Parse._encode( + new Date(mongoObject[key]) + ).iso; break; - } - if (schema.fields[key] && schema.fields[key].type === 'Bytes' && BytesCoder.isValidDatabaseObject(value)) { - restObject[key] = BytesCoder.databaseToJSON(value); + case 'createdAt': + case '_created_at': + restObject['createdAt'] = Parse._encode( + new Date(mongoObject[key]) + ).iso; break; - } + case 'expiresAt': + case '_expiresAt': + restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); + break; + case 'lastUsed': + case '_last_used': + restObject['lastUsed'] = Parse._encode( + new Date(mongoObject[key]) + ).iso; + break; + case 'timesUsed': + case 'times_used': + restObject['timesUsed'] = mongoObject[key]; + break; + case 'authData': + if (className === '_User') { + log.warn( + 'ignoring authData in _User as this key is reserved to be synthesized of `_auth_data_*` keys' + ); + } else { + restObject['authData'] = mongoObject[key]; + } + break; + default: + // Check other auth data keys + var authDataMatch = key.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch && className === '_User') { + var provider = authDataMatch[1]; + restObject['authData'] = restObject['authData'] || {}; + restObject['authData'][provider] = mongoObject[key]; + break; + } + + if (key.indexOf('_p_') == 0) { + var newKey = key.substring(3); + if (!schema.fields[newKey]) { + log.info( + 'transform.js', + 'Found a pointer column not in the schema, dropping it.', + className, + newKey + ); + break; + } + if (schema.fields[newKey].type !== 'Pointer') { + log.info( + 'transform.js', + 'Found a pointer in a non-pointer column, dropping it.', + className, + key + ); + break; + } + if (mongoObject[key] === null) { + break; + } + restObject[newKey] = transformPointerString( + schema, + newKey, + mongoObject[key] + ); + break; + } else if (key[0] == '_' && key != '__type') { + throw 'bad key in untransform: ' + key; + } else { + var value = mongoObject[key]; + if ( + schema.fields[key] && + schema.fields[key].type === 'File' && + FileCoder.isValidDatabaseObject(value) + ) { + restObject[key] = FileCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'GeoPoint' && + GeoPointCoder.isValidDatabaseObject(value) + ) { + restObject[key] = GeoPointCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'Polygon' && + PolygonCoder.isValidDatabaseObject(value) + ) { + restObject[key] = PolygonCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'Bytes' && + BytesCoder.isValidDatabaseObject(value) + ) { + restObject[key] = BytesCoder.databaseToJSON(value); + break; + } + } + restObject[key] = nestedMongoObjectToNestedParseObject( + mongoObject[key] + ); } - restObject[key] = nestedMongoObjectToNestedParseObject(mongoObject[key]); } - } - const relationFieldNames = Object.keys(schema.fields).filter(fieldName => schema.fields[fieldName].type === 'Relation'); - const relationFields = {}; - relationFieldNames.forEach(relationFieldName => { - relationFields[relationFieldName] = { - __type: 'Relation', - className: schema.fields[relationFieldName].targetClass, - } - }); + const relationFieldNames = Object.keys(schema.fields).filter( + fieldName => schema.fields[fieldName].type === 'Relation' + ); + const relationFields = {}; + relationFieldNames.forEach(relationFieldName => { + relationFields[relationFieldName] = { + __type: 'Relation', + className: schema.fields[relationFieldName].targetClass, + }; + }); - return { ...restObject, ...relationFields }; - } - default: - throw 'unknown js type'; + return { ...restObject, ...relationFields }; + } + default: + throw 'unknown js type'; } -} +}; var DateCoder = { JSONToDatabase(json) { @@ -1163,15 +1518,16 @@ var DateCoder = { }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'Date' + return ( + typeof value === 'object' && value !== null && value.__type === 'Date' ); - } + }, }; var BytesCoder = { - base64Pattern: new RegExp("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"), + base64Pattern: new RegExp( + '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + ), isBase64Value(object) { if (typeof object !== 'string') { return false; @@ -1188,24 +1544,23 @@ var BytesCoder = { } return { __type: 'Bytes', - base64: value + base64: value, }; }, isValidDatabaseObject(object) { - return (object instanceof mongodb.Binary) || this.isBase64Value(object); + return object instanceof mongodb.Binary || this.isBase64Value(object); }, JSONToDatabase(json) { - return new mongodb.Binary(new Buffer(json.base64, 'base64')); + return new mongodb.Binary(Buffer.from(json.base64, 'base64')); }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'Bytes' + return ( + typeof value === 'object' && value !== null && value.__type === 'Bytes' ); - } + }, }; var GeoPointCoder = { @@ -1213,34 +1568,35 @@ var GeoPointCoder = { return { __type: 'GeoPoint', latitude: object[1], - longitude: object[0] - } + longitude: object[0], + }; }, isValidDatabaseObject(object) { - return (object instanceof Array && - object.length == 2 - ); + return object instanceof Array && object.length == 2; }, JSONToDatabase(json) { - return [ json.longitude, json.latitude ]; + return [json.longitude, json.latitude]; }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'GeoPoint' + return ( + typeof value === 'object' && value !== null && value.__type === 'GeoPoint' ); - } + }, }; var PolygonCoder = { databaseToJSON(object) { + // Convert lng/lat -> lat/lng + const coords = object.coordinates[0].map(coord => { + return [coord[1], coord[0]]; + }); return { __type: 'Polygon', - coordinates: object['coordinates'][0] - } + coordinates: coords, + }; }, isValidDatabaseObject(object) { @@ -1259,17 +1615,19 @@ var PolygonCoder = { }, JSONToDatabase(json) { - const coords = json.coordinates; - if (coords[0][0] !== coords[coords.length - 1][0] || - coords[0][1] !== coords[coords.length - 1][1]) { + let coords = json.coordinates; + // Add first point to the end to close polygon + if ( + coords[0][0] !== coords[coords.length - 1][0] || + coords[0][1] !== coords[coords.length - 1][1] + ) { coords.push(coords[0]); } const unique = coords.filter((item, index, ar) => { let foundIndex = -1; for (let i = 0; i < ar.length; i += 1) { const pt = ar[i]; - if (pt[0] === item[0] && - pt[1] === item[1]) { + if (pt[0] === item[0] && pt[1] === item[1]) { foundIndex = i; break; } @@ -1282,27 +1640,30 @@ var PolygonCoder = { 'GeoJSON: Loop must have at least 3 different vertices' ); } + // Convert lat/long -> long/lat + coords = coords.map(coord => { + return [coord[1], coord[0]]; + }); return { type: 'Polygon', coordinates: [coords] }; }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'Polygon' + return ( + typeof value === 'object' && value !== null && value.__type === 'Polygon' ); - } + }, }; var FileCoder = { databaseToJSON(object) { return { __type: 'File', - name: object - } + name: object, + }; }, isValidDatabaseObject(object) { - return (typeof object === 'string'); + return typeof object === 'string'; }, JSONToDatabase(json) { @@ -1310,11 +1671,10 @@ var FileCoder = { }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'File' + return ( + typeof value === 'object' && value !== null && value.__type === 'File' ); - } + }, }; module.exports = { @@ -1325,4 +1685,5 @@ module.exports = { mongoObjectToParseObject, relativeTimeToDate, transformConstraint, + transformPointerString, }; diff --git a/src/Adapters/Storage/Postgres/PostgresClient.js b/src/Adapters/Storage/Postgres/PostgresClient.js index 47cafbefa7..86fe178510 100644 --- a/src/Adapters/Storage/Postgres/PostgresClient.js +++ b/src/Adapters/Storage/Postgres/PostgresClient.js @@ -1,4 +1,3 @@ - const parser = require('./PostgresConfigParser'); export function createClient(uri, databaseOptions) { @@ -14,6 +13,8 @@ export function createClient(uri, databaseOptions) { } const initOptions = dbOptions.initOptions || {}; + initOptions.noWarnings = process && process.env.TESTING; + const pgp = require('pg-promise')(initOptions); const client = pgp(dbOptions); diff --git a/src/Adapters/Storage/Postgres/PostgresConfigParser.js b/src/Adapters/Storage/Postgres/PostgresConfigParser.js index 6bcce676a3..bc95e71cea 100644 --- a/src/Adapters/Storage/Postgres/PostgresConfigParser.js +++ b/src/Adapters/Storage/Postgres/PostgresConfigParser.js @@ -19,11 +19,14 @@ function getDatabaseOptionsFromURI(uri) { databaseOptions.ssl = queryParams.ssl && queryParams.ssl.toLowerCase() === 'true' ? true : false; databaseOptions.binary = - queryParams.binary && queryParams.binary.toLowerCase() === 'true' ? true : false; + queryParams.binary && queryParams.binary.toLowerCase() === 'true' + ? true + : false; databaseOptions.client_encoding = queryParams.client_encoding; databaseOptions.application_name = queryParams.application_name; - databaseOptions.fallback_application_name = queryParams.fallback_application_name; + databaseOptions.fallback_application_name = + queryParams.fallback_application_name; if (queryParams.poolSize) { databaseOptions.poolSize = parseInt(queryParams.poolSize) || 10; @@ -35,19 +38,15 @@ function getDatabaseOptionsFromURI(uri) { function parseQueryParams(queryString) { queryString = queryString || ''; - return queryString - .split('&') - .reduce((p, c) => { - const parts = c.split('='); - p[decodeURIComponent(parts[0])] = - parts.length > 1 - ? decodeURIComponent(parts.slice(1).join('=')) - : ''; - return p; - }, {}); + return queryString.split('&').reduce((p, c) => { + const parts = c.split('='); + p[decodeURIComponent(parts[0])] = + parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : ''; + return p; + }, {}); } module.exports = { parseQueryParams: parseQueryParams, - getDatabaseOptionsFromURI: getDatabaseOptionsFromURI + getDatabaseOptionsFromURI: getDatabaseOptionsFromURI, }; diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index f2aec54788..31602afc0d 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1,51 +1,83 @@ +// @flow import { createClient } from './PostgresClient'; -import Parse from 'parse/node'; -import _ from 'lodash'; -import sql from './sql'; +// @flow-disable-next +import Parse from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +import sql from './sql'; const PostgresRelationDoesNotExistError = '42P01'; const PostgresDuplicateRelationError = '42P07'; const PostgresDuplicateColumnError = '42701'; +const PostgresMissingColumnError = '42703'; const PostgresDuplicateObjectError = '42710'; const PostgresUniqueIndexViolationError = '23505'; const PostgresTransactionAbortedError = '25P02'; const logger = require('../../../logger'); -const debug = function(){ - let args = [...arguments]; +const debug = function(...args: any) { args = ['PG: ' + arguments[0]].concat(args.slice(1, args.length)); const log = logger.getLogger(); log.debug.apply(log, args); -} +}; + +import { StorageAdapter } from '../StorageAdapter'; +import type { SchemaType, QueryType, QueryOptions } from '../StorageAdapter'; const parseTypeToPostgresType = type => { switch (type.type) { - case 'String': return 'text'; - case 'Date': return 'timestamp with time zone'; - case 'Object': return 'jsonb'; - case 'File': return 'text'; - case 'Boolean': return 'boolean'; - case 'Pointer': return 'char(10)'; - case 'Number': return 'double precision'; - case 'GeoPoint': return 'point'; - case 'Bytes': return 'jsonb'; - case 'Polygon': return 'polygon'; - case 'Array': - if (type.contents && type.contents.type === 'String') { - return 'text[]'; - } else { + case 'String': + return 'text'; + case 'Date': + return 'timestamp with time zone'; + case 'Object': return 'jsonb'; - } - default: throw `no type for ${JSON.stringify(type)} yet`; + case 'File': + return 'text'; + case 'Boolean': + return 'boolean'; + case 'Pointer': + return 'char(10)'; + case 'Number': + return 'double precision'; + case 'GeoPoint': + return 'point'; + case 'Bytes': + return 'jsonb'; + case 'Polygon': + return 'polygon'; + case 'Array': + if (type.contents && type.contents.type === 'String') { + return 'text[]'; + } else { + return 'jsonb'; + } + default: + throw `no type for ${JSON.stringify(type)} yet`; } }; const ParseToPosgresComparator = { - '$gt': '>', - '$lt': '<', - '$gte': '>=', - '$lte': '<=' -} + $gt: '>', + $lt: '<', + $gte: '>=', + $lte: '<=', +}; + +const mongoAggregateToPostgres = { + $dayOfMonth: 'DAY', + $dayOfWeek: 'DOW', + $dayOfYear: 'DOY', + $isoDayOfWeek: 'ISODOW', + $isoWeekYear: 'ISOYEAR', + $hour: 'HOUR', + $minute: 'MINUTE', + $second: 'SECOND', + $millisecond: 'MILLISECONDS', + $month: 'MONTH', + $week: 'WEEK', + $year: 'YEAR', +}; const toPostgresValue = value => { if (typeof value === 'object') { @@ -57,36 +89,39 @@ const toPostgresValue = value => { } } return value; -} +}; const transformValue = value => { - if (typeof value === 'object' && - value.__type === 'Pointer') { + if (typeof value === 'object' && value.__type === 'Pointer') { return value.objectId; } return value; -} +}; // Duplicate from then mongo adapter... const emptyCLPS = Object.freeze({ find: {}, get: {}, + count: {}, create: {}, update: {}, delete: {}, addField: {}, + protectedFields: {}, }); const defaultCLPS = Object.freeze({ - find: {'*': true}, - get: {'*': true}, - create: {'*': true}, - update: {'*': true}, - delete: {'*': true}, - addField: {'*': true}, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, }); -const toParseSchema = (schema) => { +const toParseSchema = schema => { if (schema.className === '_User') { delete schema.fields._hashed_password; } @@ -96,30 +131,35 @@ const toParseSchema = (schema) => { } let clps = defaultCLPS; if (schema.classLevelPermissions) { - clps = {...emptyCLPS, ...schema.classLevelPermissions}; + clps = { ...emptyCLPS, ...schema.classLevelPermissions }; + } + let indexes = {}; + if (schema.indexes) { + indexes = { ...schema.indexes }; } return { className: schema.className, fields: schema.fields, classLevelPermissions: clps, + indexes, }; -} +}; -const toPostgresSchema = (schema) => { +const toPostgresSchema = schema => { if (!schema) { return schema; } schema.fields = schema.fields || {}; - schema.fields._wperm = {type: 'Array', contents: {type: 'String'}} - schema.fields._rperm = {type: 'Array', contents: {type: 'String'}} + schema.fields._wperm = { type: 'Array', contents: { type: 'String' } }; + schema.fields._rperm = { type: 'Array', contents: { type: 'String' } }; if (schema.className === '_User') { - schema.fields._hashed_password = {type: 'String'}; - schema.fields._password_history = {type: 'Array'}; + schema.fields._hashed_password = { type: 'String' }; + schema.fields._password_history = { type: 'Array' }; } return schema; -} +}; -const handleDotFields = (object) => { +const handleDotFields = object => { Object.keys(object).forEach(fieldName => { if (fieldName.indexOf('.') > -1) { const components = fieldName.split('.'); @@ -132,8 +172,8 @@ const handleDotFields = (object) => { value = undefined; } /* eslint-disable no-cond-assign */ - while(next = components.shift()) { - /* eslint-enable no-cond-assign */ + while ((next = components.shift())) { + /* eslint-enable no-cond-assign */ currentObj[next] = currentObj[next] || {}; if (components.length === 0) { currentObj[next] = value; @@ -144,18 +184,18 @@ const handleDotFields = (object) => { } }); return object; -} +}; -const transformDotFieldToComponents = (fieldName) => { +const transformDotFieldToComponents = fieldName => { return fieldName.split('.').map((cmpt, index) => { if (index === 0) { return `"${cmpt}"`; } return `'${cmpt}'`; }); -} +}; -const transformDotField = (fieldName) => { +const transformDotField = fieldName => { if (fieldName.indexOf('.') === -1) { return `"${fieldName}"`; } @@ -163,49 +203,77 @@ const transformDotField = (fieldName) => { let name = components.slice(0, components.length - 1).join('->'); name += '->>' + components[components.length - 1]; return name; -} +}; + +const transformAggregateField = fieldName => { + if (typeof fieldName !== 'string') { + return fieldName; + } + if (fieldName === '$_created_at') { + return 'createdAt'; + } + if (fieldName === '$_updated_at') { + return 'updatedAt'; + } + return fieldName.substr(1); +}; -const validateKeys = (object) => { +const validateKeys = object => { if (typeof object == 'object') { for (const key in object) { if (typeof object[key] == 'object') { validateKeys(object[key]); } - if(key.includes('$') || key.includes('.')){ - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + if (key.includes('$') || key.includes('.')) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); } } } -} +}; // Returns the list of join tables on a schema -const joinTablesForSchema = (schema) => { +const joinTablesForSchema = schema => { const list = []; if (schema) { - Object.keys(schema.fields).forEach((field) => { + Object.keys(schema.fields).forEach(field => { if (schema.fields[field].type === 'Relation') { list.push(`_Join:${field}:${schema.className}`); } }); } return list; +}; + +interface WhereClause { + pattern: string; + values: Array; + sorts: Array; } -const buildWhereClause = ({ schema, query, index }) => { +const buildWhereClause = ({ + schema, + query, + index, + caseInsensitive, +}): WhereClause => { const patterns = []; let values = []; const sorts = []; schema = toPostgresSchema(schema); for (const fieldName in query) { - const isArrayField = schema.fields - && schema.fields[fieldName] - && schema.fields[fieldName].type === 'Array'; + const isArrayField = + schema.fields && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Array'; const initialPatternsLength = patterns.length; const fieldValue = query[fieldName]; - // nothingin the schema, it's gonna blow up + // nothing in the schema, it's gonna blow up if (!schema.fields[fieldName]) { // as it won't exist if (fieldValue && fieldValue.$exists === false) { @@ -213,26 +281,36 @@ const buildWhereClause = ({ schema, query, index }) => { } } - if (fieldName.indexOf('.') >= 0) { + const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch) { + // TODO: Handle querying by _auth_data_provider, authData is stored in authData field + continue; + } else if ( + caseInsensitive && + (fieldName === 'username' || fieldName === 'email') + ) { + patterns.push(`LOWER($${index}:name) = LOWER($${index + 1})`); + values.push(fieldName, fieldValue); + index += 2; + } else if (fieldName.indexOf('.') >= 0) { let name = transformDotField(fieldName); if (fieldValue === null) { - patterns.push(`${name} IS NULL`); + patterns.push(`$${index}:raw IS NULL`); + values.push(name); + index += 1; + continue; } else { if (fieldValue.$in) { - const inPatterns = []; name = transformDotFieldToComponents(fieldName).join('->'); - fieldValue.$in.forEach((listElem) => { - if (typeof listElem === 'string') { - inPatterns.push(`"${listElem}"`); - } else { - inPatterns.push(`${listElem}`); - } - }); - patterns.push(`(${name})::jsonb @> '[${inPatterns.join(',')}]'::jsonb`); + patterns.push(`($${index}:raw)::jsonb @> $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue.$in)); + index += 2; } else if (fieldValue.$regex) { // Handle later - } else { - patterns.push(`${name} = '${fieldValue}'`); + } else if (typeof fieldValue !== 'object') { + patterns.push(`$${index}:raw = $${index + 1}::text`); + values.push(name, fieldValue); + index += 2; } } } else if (fieldValue === null || fieldValue === undefined) { @@ -246,25 +324,43 @@ const buildWhereClause = ({ schema, query, index }) => { index += 2; } else if (typeof fieldValue === 'boolean') { patterns.push(`$${index}:name = $${index + 1}`); - values.push(fieldName, fieldValue); + // Can't cast boolean to double precision + if ( + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Number' + ) { + // Should always return zero results + const MAX_INT_PLUS_ONE = 9223372036854775808; + values.push(fieldName, MAX_INT_PLUS_ONE); + } else { + values.push(fieldName, fieldValue); + } index += 2; } else if (typeof fieldValue === 'number') { patterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; - } else if (fieldName === '$or' || fieldName === '$and') { + } else if (['$or', '$nor', '$and'].includes(fieldName)) { const clauses = []; const clauseValues = []; - fieldValue.forEach((subQuery) => { - const clause = buildWhereClause({ schema, query: subQuery, index }); + fieldValue.forEach(subQuery => { + const clause = buildWhereClause({ + schema, + query: subQuery, + index, + caseInsensitive, + }); if (clause.pattern.length > 0) { clauses.push(clause.pattern); clauseValues.push(...clause.values); index += clause.values.length; } }); - const orOrAnd = fieldName === '$or' ? ' OR ' : ' AND '; - patterns.push(`(${clauses.join(orOrAnd)})`); + + const orOrAnd = fieldName === '$and' ? ' AND ' : ' OR '; + const not = fieldName === '$nor' ? ' NOT ' : ''; + + patterns.push(`${not}(${clauses.join(orOrAnd)})`); values.push(...clauseValues); } @@ -280,25 +376,59 @@ const buildWhereClause = ({ schema, query, index }) => { continue; } else { // if not null, we need to manually exclude null - patterns.push(`($${index}:name <> $${index + 1} OR $${index}:name IS NULL)`); + if (fieldValue.$ne.__type === 'GeoPoint') { + patterns.push( + `($${index}:name <> POINT($${index + 1}, $${index + + 2}) OR $${index}:name IS NULL)` + ); + } else { + if (fieldName.indexOf('.') >= 0) { + const constraintFieldName = transformDotField(fieldName); + patterns.push( + `(${constraintFieldName} <> $${index} OR ${constraintFieldName} IS NULL)` + ); + } else { + patterns.push( + `($${index}:name <> $${index + 1} OR $${index}:name IS NULL)` + ); + } + } } } - - // TODO: support arrays - values.push(fieldName, fieldValue.$ne); - index += 2; + if (fieldValue.$ne.__type === 'GeoPoint') { + const point = fieldValue.$ne; + values.push(fieldName, point.longitude, point.latitude); + index += 3; + } else { + // TODO: support arrays + values.push(fieldName, fieldValue.$ne); + index += 2; + } } - - if (fieldValue.$eq) { - patterns.push(`$${index}:name = $${index + 1}`); - values.push(fieldName, fieldValue.$eq); - index += 2; + if (fieldValue.$eq !== undefined) { + if (fieldValue.$eq === null) { + patterns.push(`$${index}:name IS NULL`); + values.push(fieldName); + index += 1; + } else { + if (fieldName.indexOf('.') >= 0) { + values.push(fieldValue.$eq); + patterns.push(`${transformDotField(fieldName)} = $${index++}`); + } else { + values.push(fieldName, fieldValue.$eq); + patterns.push(`$${index}:name = $${index + 1}`); + index += 2; + } + } } - const isInOrNin = Array.isArray(fieldValue.$in) || Array.isArray(fieldValue.$nin); - if (Array.isArray(fieldValue.$in) && - isArrayField && - schema.fields[fieldName].contents && - schema.fields[fieldName].contents.type === 'String') { + const isInOrNin = + Array.isArray(fieldValue.$in) || Array.isArray(fieldValue.$nin); + if ( + Array.isArray(fieldValue.$in) && + isArrayField && + schema.fields[fieldName].contents && + schema.fields[fieldName].contents.type === 'String' + ) { const inPatterns = []; let allowNull = false; values.push(fieldName); @@ -311,17 +441,21 @@ const buildWhereClause = ({ schema, query, index }) => { } }); if (allowNull) { - patterns.push(`($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join(',')}])`); + patterns.push( + `($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join()}])` + ); } else { - patterns.push(`$${index}:name && ARRAY[${inPatterns.join(',')}]`); + patterns.push(`$${index}:name && ARRAY[${inPatterns.join()}]`); } index = index + 1 + inPatterns.length; } else if (isInOrNin) { var createConstraint = (baseArray, notIn) => { + const not = notIn ? ' NOT ' : ''; if (baseArray.length > 0) { - const not = notIn ? ' NOT ' : ''; if (isArrayField) { - patterns.push(`${not} array_contains($${index}:name, $${index + 1})`); + patterns.push( + `${not} array_contains($${index}:name, $${index + 1})` + ); values.push(fieldName, JSON.stringify(baseArray)); index += 2; } else { @@ -332,32 +466,74 @@ const buildWhereClause = ({ schema, query, index }) => { const inPatterns = []; values.push(fieldName); baseArray.forEach((listElem, listIndex) => { - if (listElem !== null) { + if (listElem != null) { values.push(listElem); inPatterns.push(`$${index + 1 + listIndex}`); } }); - patterns.push(`$${index}:name ${not} IN (${inPatterns.join(',')})`); + patterns.push(`$${index}:name ${not} IN (${inPatterns.join()})`); index = index + 1 + inPatterns.length; } } else if (!notIn) { values.push(fieldName); patterns.push(`$${index}:name IS NULL`); index = index + 1; + } else { + // Handle empty array + if (notIn) { + patterns.push('1 = 1'); // Return all values + } else { + patterns.push('1 = 2'); // Return no values + } } - } + }; if (fieldValue.$in) { - createConstraint(_.flatMap(fieldValue.$in, elt => elt), false); + createConstraint( + _.flatMap(fieldValue.$in, elt => elt), + false + ); } if (fieldValue.$nin) { - createConstraint(_.flatMap(fieldValue.$nin, elt => elt), true); + createConstraint( + _.flatMap(fieldValue.$nin, elt => elt), + true + ); } + } else if (typeof fieldValue.$in !== 'undefined') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $in value'); + } else if (typeof fieldValue.$nin !== 'undefined') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $nin value'); } if (Array.isArray(fieldValue.$all) && isArrayField) { - patterns.push(`array_contains_all($${index}:name, $${index + 1}::jsonb)`); + if (isAnyValueRegexStartsWith(fieldValue.$all)) { + if (!isAllValuesRegexOrNone(fieldValue.$all)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'All $all values must be of regex type or none: ' + fieldValue.$all + ); + } + + for (let i = 0; i < fieldValue.$all.length; i += 1) { + const value = processRegexPattern(fieldValue.$all[i].$regex); + fieldValue.$all[i] = value.substring(1) + '%'; + } + patterns.push( + `array_contains_all_regex($${index}:name, $${index + 1}::jsonb)` + ); + } else { + patterns.push( + `array_contains_all($${index}:name, $${index + 1}::jsonb)` + ); + } values.push(fieldName, JSON.stringify(fieldValue.$all)); index += 2; + } else if (Array.isArray(fieldValue.$all)) { + if (fieldValue.$all.length === 1) { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue.$all[0].objectId); + index += 2; + } } if (typeof fieldValue.$exists !== 'undefined') { @@ -370,6 +546,20 @@ const buildWhereClause = ({ schema, query, index }) => { index += 1; } + if (fieldValue.$containedBy) { + const arr = fieldValue.$containedBy; + if (!(arr instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $containedBy: should be an array` + ); + } + + patterns.push(`$${index}:name <@ $${index + 1}::jsonb`); + values.push(fieldName, JSON.stringify(arr)); + index += 2; + } + if (fieldValue.$text) { const search = fieldValue.$text.$search; let language = 'english'; @@ -404,7 +594,10 @@ const buildWhereClause = ({ schema, query, index }) => { `bad $text: $caseSensitive not supported, please use $regex or create a separate lower case column.` ); } - if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { + if ( + search.$diacriticSensitive && + typeof search.$diacriticSensitive !== 'boolean' + ) { throw new Parse.Error( Parse.Error.INVALID_JSON, `bad $text: $diacriticSensitive, should be boolean` @@ -415,7 +608,10 @@ const buildWhereClause = ({ schema, query, index }) => { `bad $text: $diacriticSensitive - false not supported, install Postgres Unaccent Extension` ); } - patterns.push(`to_tsvector($${index}, $${index + 1}:name) @@ to_tsquery($${index + 2}, $${index + 3})`); + patterns.push( + `to_tsvector($${index}, $${index + 1}:name) @@ to_tsquery($${index + + 2}, $${index + 3})` + ); values.push(language, fieldName, language, search.$term); index += 4; } @@ -424,8 +620,14 @@ const buildWhereClause = ({ schema, query, index }) => { const point = fieldValue.$nearSphere; const distance = fieldValue.$maxDistance; const distanceInKM = distance * 6371 * 1000; - patterns.push(`ST_distance_sphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2})::geometry) <= $${index + 3}`); - sorts.push(`ST_distance_sphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2})::geometry) ASC`) + patterns.push( + `ST_distance_sphere($${index}:name::geometry, POINT($${index + + 1}, $${index + 2})::geometry) <= $${index + 3}` + ); + sorts.push( + `ST_distance_sphere($${index}:name::geometry, POINT($${index + + 1}, $${index + 2})::geometry) ASC` + ); values.push(fieldName, point.longitude, point.latitude, distanceInKM); index += 4; } @@ -442,28 +644,84 @@ const buildWhereClause = ({ schema, query, index }) => { index += 2; } - if (fieldValue.$geoWithin && fieldValue.$geoWithin.$polygon) { - const polygon = fieldValue.$geoWithin.$polygon; - if (!(polygon instanceof Array)) { + if (fieldValue.$geoWithin && fieldValue.$geoWithin.$centerSphere) { + const centerSphere = fieldValue.$geoWithin.$centerSphere; + if (!(centerSphere instanceof Array) || centerSphere.length < 2) { throw new Parse.Error( Parse.Error.INVALID_JSON, - 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' ); } - if (polygon.length < 3) { + // Get point, convert to geo point if necessary and validate + let point = centerSphere[0]; + if (point instanceof Array && point.length === 2) { + point = new Parse.GeoPoint(point[1], point[0]); + } else if (!GeoPointCoder.isValidJSON(point)) { throw new Parse.Error( Parse.Error.INVALID_JSON, - 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + 'bad $geoWithin value; $centerSphere geo point invalid' ); } - const points = polygon.map((point) => { - if (typeof point !== 'object' || point.__type !== 'GeoPoint') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); - } else { - Parse.GeoPoint._validate(point.latitude, point.longitude); + Parse.GeoPoint._validate(point.latitude, point.longitude); + // Get distance and validate + const distance = centerSphere[1]; + if (isNaN(distance) || distance < 0) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere distance invalid' + ); + } + const distanceInKM = distance * 6371 * 1000; + patterns.push( + `ST_distance_sphere($${index}:name::geometry, POINT($${index + + 1}, $${index + 2})::geometry) <= $${index + 3}` + ); + values.push(fieldName, point.longitude, point.latitude, distanceInKM); + index += 4; + } + + if (fieldValue.$geoWithin && fieldValue.$geoWithin.$polygon) { + const polygon = fieldValue.$geoWithin.$polygon; + let points; + if (typeof polygon === 'object' && polygon.__type === 'Polygon') { + if (!polygon.coordinates || polygon.coordinates.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; Polygon.coordinates should contain at least 3 lon/lat pairs' + ); + } + points = polygon.coordinates; + } else if (polygon instanceof Array) { + if (polygon.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + ); } - return `(${point.longitude}, ${point.latitude})`; - }).join(', '); + points = polygon; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + "bad $geoWithin value; $polygon should be Polygon object or Array of Parse.GeoPoint's" + ); + } + points = points + .map(point => { + if (point instanceof Array && point.length === 2) { + Parse.GeoPoint._validate(point[1], point[0]); + return `(${point[0]}, ${point[1]})`; + } + if (typeof point !== 'object' || point.__type !== 'GeoPoint') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + return `(${point.longitude}, ${point.latitude})`; + }) + .join(', '); patterns.push(`$${index}:name::point <@ $${index + 1}::polygon`); values.push(fieldName, `(${points})`); @@ -524,7 +782,7 @@ const buildWhereClause = ({ schema, query, index }) => { } if (fieldValue.__type === 'GeoPoint') { - patterns.push('$' + index + ':name ~= POINT($' + (index + 1) + ', $' + (index + 2) + ')'); + patterns.push(`$${index}:name ~= POINT($${index + 1}, $${index + 2})`); values.push(fieldName, fieldValue.longitude, fieldValue.latitude); index += 3; } @@ -537,113 +795,245 @@ const buildWhereClause = ({ schema, query, index }) => { } Object.keys(ParseToPosgresComparator).forEach(cmp => { - if (fieldValue[cmp]) { + if (fieldValue[cmp] || fieldValue[cmp] === 0) { const pgComparator = ParseToPosgresComparator[cmp]; - patterns.push(`$${index}:name ${pgComparator} $${index + 1}`); - values.push(fieldName, toPostgresValue(fieldValue[cmp])); - index += 2; + const postgresValue = toPostgresValue(fieldValue[cmp]); + let constraintFieldName; + if (fieldName.indexOf('.') >= 0) { + let castType; + switch (typeof postgresValue) { + case 'number': + castType = 'double precision'; + break; + case 'boolean': + castType = 'boolean'; + break; + default: + castType = undefined; + } + constraintFieldName = castType + ? `CAST ((${transformDotField(fieldName)}) AS ${castType})` + : transformDotField(fieldName); + } else { + constraintFieldName = `$${index++}:name`; + values.push(fieldName); + } + values.push(postgresValue); + patterns.push(`${constraintFieldName} ${pgComparator} $${index++}`); } }); if (initialPatternsLength === patterns.length) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support this query type yet ${JSON.stringify(fieldValue)}`); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Postgres doesn't support this query type yet ${JSON.stringify( + fieldValue + )}` + ); } } values = values.map(transformValue); return { pattern: patterns.join(' AND '), values, sorts }; -} +}; + +export class PostgresStorageAdapter implements StorageAdapter { + canSortOnJoinTables: boolean; -export class PostgresStorageAdapter { // Private _collectionPrefix: string; - _client; - _pgp; - - constructor({ - uri, - collectionPrefix = '', - databaseOptions - }) { + _client: any; + _pgp: any; + + constructor({ uri, collectionPrefix = '', databaseOptions }: any) { this._collectionPrefix = collectionPrefix; const { client, pgp } = createClient(uri, databaseOptions); this._client = client; this._pgp = pgp; + this.canSortOnJoinTables = false; + } + + handleShutdown() { + if (!this._client) { + return; + } + this._client.$pool.end(); } - _ensureSchemaCollectionExists(conn) { + async _ensureSchemaCollectionExists(conn: any) { conn = conn || this._client; - return conn.none('CREATE TABLE IF NOT EXISTS "_SCHEMA" ( "className" varChar(120), "schema" jsonb, "isParseClass" bool, PRIMARY KEY ("className") )') + await conn + .none( + 'CREATE TABLE IF NOT EXISTS "_SCHEMA" ( "className" varChar(120), "schema" jsonb, "isParseClass" bool, PRIMARY KEY ("className") )' + ) .catch(error => { - if (error.code === PostgresDuplicateRelationError - || error.code === PostgresUniqueIndexViolationError - || error.code === PostgresDuplicateObjectError) { - // Table already exists, must have been created by a different request. Ignore error. + if ( + error.code === PostgresDuplicateRelationError || + error.code === PostgresUniqueIndexViolationError || + error.code === PostgresDuplicateObjectError + ) { + // Table already exists, must have been created by a different request. Ignore error. } else { throw error; } }); } - classExists(name) { - return this._client.one(`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)`, [name]).then((res) => { - return res.exists; - }); + async classExists(name: string) { + return this._client.one( + 'SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)', + [name], + a => a.exists + ); } - setClassLevelPermissions(className, CLPs) { - return this._ensureSchemaCollectionExists().then(() => { - const values = [className, 'schema', 'classLevelPermissions', JSON.stringify(CLPs)] - return this._client.none(`UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className"=$1 `, values); + async setClassLevelPermissions(className: string, CLPs: any) { + const self = this; + await this._client.task('set-class-level-permissions', async t => { + await self._ensureSchemaCollectionExists(t); + const values = [ + className, + 'schema', + 'classLevelPermissions', + JSON.stringify(CLPs), + ]; + await t.none( + `UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className" = $1`, + values + ); }); } - createClass(className, schema) { - return this._client.tx(t => { - const q1 = this.createTable(className, schema, t); - const q2 = t.none('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', { className, schema }); + async setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any = {}, + fields: any, + conn: ?any + ): Promise { + conn = conn || this._client; + const self = this; + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1 } }; + } + const deletedIndexes = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} exists, cannot update.` + ); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} does not exist, cannot delete.` + ); + } + if (field.__op === 'Delete') { + deletedIndexes.push(name); + delete existingIndexes[name]; + } else { + Object.keys(field).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(fields, key)) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Field ${key} does not exist, cannot add index.` + ); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); + } + }); + await conn.tx('set-indexes-with-schema-format', async t => { + if (insertedIndexes.length > 0) { + await self.createIndexes(className, insertedIndexes, t); + } + if (deletedIndexes.length > 0) { + await self.dropIndexes(className, deletedIndexes, t); + } + await self._ensureSchemaCollectionExists(t); + await t.none( + 'UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className" = $1', + [className, 'schema', 'indexes', JSON.stringify(existingIndexes)] + ); + }); + } - return t.batch([q1, q2]); - }) + async createClass(className: string, schema: SchemaType, conn: ?any) { + conn = conn || this._client; + return conn + .tx('create-class', async t => { + const q1 = this.createTable(className, schema, t); + const q2 = t.none( + 'INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', + { className, schema } + ); + const q3 = this.setIndexesWithSchemaFormat( + className, + schema.indexes, + {}, + schema.fields, + t + ); + // TODO: The test should not verify the returned value, and then + // the method can be simplified, to avoid returning useless stuff. + return t.batch([q1, q2, q3]); + }) .then(() => { - return toParseSchema(schema) + return toParseSchema(schema); }) - .catch((err) => { - if (Array.isArray(err.data) && err.data.length > 1 && err.data[0].result.code === PostgresTransactionAbortedError) { + .catch(err => { + if (err.data[0].result.code === PostgresTransactionAbortedError) { err = err.data[1].result; } - - if (err.code === PostgresUniqueIndexViolationError && err.detail.includes(className)) { - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, `Class ${className} already exists.`) + if ( + err.code === PostgresUniqueIndexViolationError && + err.detail.includes(className) + ) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + `Class ${className} already exists.` + ); } throw err; - }) + }); } // Just create a table, do not insert in schema - createTable(className, schema, conn) { + async createTable(className: string, schema: SchemaType, conn: any) { conn = conn || this._client; + const self = this; debug('createTable', className, schema); const valuesArray = []; const patternsArray = []; const fields = Object.assign({}, schema.fields); if (className === '_User') { - fields._email_verify_token_expires_at = {type: 'Date'}; - fields._email_verify_token = {type: 'String'}; - fields._account_lockout_expires_at = {type: 'Date'}; - fields._failed_login_count = {type: 'Number'}; - fields._perishable_token = {type: 'String'}; - fields._perishable_token_expires_at = {type: 'Date'}; - fields._password_changed_at = {type: 'Date'}; - fields._password_history = { type: 'Array'}; + fields._email_verify_token_expires_at = { type: 'Date' }; + fields._email_verify_token = { type: 'String' }; + fields._account_lockout_expires_at = { type: 'Date' }; + fields._failed_login_count = { type: 'Number' }; + fields._perishable_token = { type: 'String' }; + fields._perishable_token_expires_at = { type: 'Date' }; + fields._password_changed_at = { type: 'Date' }; + fields._password_history = { type: 'Array' }; } let index = 2; const relations = []; - Object.keys(fields).forEach((fieldName) => { + Object.keys(fields).forEach(fieldName => { const parseType = fields[fieldName]; // Skip when it's a relation // We'll create the tables later if (parseType.type === 'Relation') { - relations.push(fieldName) + relations.push(fieldName); return; } if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { @@ -653,100 +1043,174 @@ export class PostgresStorageAdapter { valuesArray.push(parseTypeToPostgresType(parseType)); patternsArray.push(`$${index}:name $${index + 1}:raw`); if (fieldName === 'objectId') { - patternsArray.push(`PRIMARY KEY ($${index}:name)`) + patternsArray.push(`PRIMARY KEY ($${index}:name)`); } index = index + 2; }); - const qs = `CREATE TABLE IF NOT EXISTS $1:name (${patternsArray.join(',')})`; + const qs = `CREATE TABLE IF NOT EXISTS $1:name (${patternsArray.join()})`; const values = [className, ...valuesArray]; - return this._ensureSchemaCollectionExists(conn) - .then(() => conn.none(qs, values)) - .catch(error => { - if (error.code === PostgresDuplicateRelationError) { - // Table already exists, must have been created by a different request. Ignore error. - } else { + + debug(qs, values); + return conn.task('create-table', async t => { + try { + await self._ensureSchemaCollectionExists(t); + await t.none(qs, values); + } catch (error) { + if (error.code !== PostgresDuplicateRelationError) { throw error; } - }).then(() => { - return conn.tx('create-relation-tables', t => { - const queries = relations.map((fieldName) => { - return t.none('CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', {joinTable: `_Join:${fieldName}:${className}`}); - }); - return t.batch(queries); - }); + // ELSE: Table already exists, must have been created by a different request. Ignore the error. + } + await t.tx('create-table-tx', tx => { + return tx.batch( + relations.map(fieldName => { + return tx.none( + 'CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', + { joinTable: `_Join:${fieldName}:${className}` } + ); + }) + ); }); + }); } - addFieldIfNotExists(className, fieldName, type) { + async schemaUpgrade(className: string, schema: SchemaType, conn: any) { + debug('schemaUpgrade', { className, schema }); + conn = conn || this._client; + const self = this; + + await conn.tx('schema-upgrade', async t => { + const columns = await t.map( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $', + { className }, + a => a.column_name + ); + const newColumns = Object.keys(schema.fields) + .filter(item => columns.indexOf(item) === -1) + .map(fieldName => + self.addFieldIfNotExists( + className, + fieldName, + schema.fields[fieldName], + t + ) + ); + + await t.batch(newColumns); + }); + } + + async addFieldIfNotExists( + className: string, + fieldName: string, + type: any, + conn: any + ) { // TODO: Must be revised for invalid logic... - debug('addFieldIfNotExists', {className, fieldName, type}); - return this._client.tx("addFieldIfNotExists", t=> { - let promise = Promise.resolve(); + debug('addFieldIfNotExists', { className, fieldName, type }); + conn = conn || this._client; + const self = this; + await conn.tx('add-field-if-not-exists', async t => { if (type.type !== 'Relation') { - promise = t.none('ALTER TABLE $ ADD COLUMN $ $', { - className, - fieldName, - postgresType: parseTypeToPostgresType(type) - }) - .catch(error => { - if (error.code === PostgresRelationDoesNotExistError) { - return this.createClass(className, {fields: {[fieldName]: type}}) - } else if (error.code === PostgresDuplicateColumnError) { - // Column already exists, created by other request. Carry on to - // See if it's the right type. - } else { - throw error; + try { + await t.none( + 'ALTER TABLE $ ADD COLUMN $ $', + { + className, + fieldName, + postgresType: parseTypeToPostgresType(type), } - }) - } else { - promise = t.none('CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', {joinTable: `_Join:${fieldName}:${className}`}) - } - return promise.then(() => { - return t.any('SELECT "schema" FROM "_SCHEMA" WHERE "className" = $ and ("schema"::json->\'fields\'->$) is not null', {className, fieldName}); - }).then(result => { - if (result[0]) { - throw "Attempted to add a field that already exists"; - } else { - const path = `{fields,${fieldName}}`; - return t.none( - 'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $, $) WHERE "className"=$', - { path, type, className } ); + } catch (error) { + if (error.code === PostgresRelationDoesNotExistError) { + return self.createClass( + className, + { fields: { [fieldName]: type } }, + t + ); + } + if (error.code !== PostgresDuplicateColumnError) { + throw error; + } + // Column already exists, created by other request. Carry on to see if it's the right type. } - }); + } else { + await t.none( + 'CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', + { joinTable: `_Join:${fieldName}:${className}` } + ); + } + + const result = await t.any( + 'SELECT "schema" FROM "_SCHEMA" WHERE "className" = $ and ("schema"::json->\'fields\'->$) is not null', + { className, fieldName } + ); + + if (result[0]) { + throw 'Attempted to add a field that already exists'; + } else { + const path = `{fields,${fieldName}}`; + await t.none( + 'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $, $) WHERE "className"=$', + { path, type, className } + ); + } }); } // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. - deleteClass(className) { + async deleteClass(className: string) { const operations = [ - {query: `DROP TABLE IF EXISTS $1:name`, values: [className]}, - {query: `DELETE FROM "_SCHEMA" WHERE "className" = $1`, values: [className]} + { query: `DROP TABLE IF EXISTS $1:name`, values: [className] }, + { + query: `DELETE FROM "_SCHEMA" WHERE "className" = $1`, + values: [className], + }, ]; - return this._client.tx(t => t.none(this._pgp.helpers.concat(operations))) + return this._client + .tx(t => t.none(this._pgp.helpers.concat(operations))) .then(() => className.indexOf('_Join:') != 0); // resolves with false when _Join table } // Delete all data known to this adapter. Used for testing. - deleteAllClasses() { + async deleteAllClasses() { const now = new Date().getTime(); + const helpers = this._pgp.helpers; debug('deleteAllClasses'); - return this._client.any('SELECT * FROM "_SCHEMA"') - .then(results => { - const joins = results.reduce((list, schema) => { - return list.concat(joinTablesForSchema(schema.schema)); - }, []); - 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) { + + await this._client + .task('delete-all-classes', async t => { + try { + const results = await t.any('SELECT * FROM "_SCHEMA"'); + const joins = results.reduce((list: Array, schema: any) => { + return list.concat(joinTablesForSchema(schema.schema)); + }, []); + const classes = [ + '_SCHEMA', + '_PushStatus', + '_JobStatus', + '_JobSchedule', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_Audience', + ...results.map(result => result.className), + ...joins, + ]; + const queries = classes.map(className => ({ + query: 'DROP TABLE IF EXISTS $', + values: { className }, + })); + await t.tx(tx => tx.none(helpers.concat(queries))); + } catch (error) { + if (error.code !== PostgresRelationDoesNotExistError) { + throw error; + } // No _SCHEMA collection. Don't delete anything. - return; - } else { - throw error; } - }).then(() => { + }) + .then(() => { debug(`deleteAllClasses done in ${new Date().getTime() - now}`); }); } @@ -764,65 +1228,77 @@ export class PostgresStorageAdapter { // may do so. // Returns a Promise. - deleteFields(className, schema, fieldNames) { + async deleteFields( + className: string, + schema: SchemaType, + fieldNames: string[] + ): Promise { debug('deleteFields', className, fieldNames); - return Promise.resolve() - .then(() => { - fieldNames = fieldNames.reduce((list, fieldName) => { - const field = schema.fields[fieldName] - if (field.type !== 'Relation') { - list.push(fieldName); - } - delete schema.fields[fieldName]; - return list; - }, []); - - const values = [className, ...fieldNames]; - const columns = fieldNames.map((name, idx) => { - return `$${idx + 2}:name`; - }).join(', DROP COLUMN'); - - const doBatch = (t) => { - const batch = [ - t.none('UPDATE "_SCHEMA" SET "schema"=$ WHERE "className"=$', {schema, className}) - ]; - if (values.length > 1) { - batch.push(t.none(`ALTER TABLE $1:name DROP COLUMN ${columns}`, values)); - } - return batch; - } - return this._client.tx((t) => { - return t.batch(doBatch(t)); - }); - }); + fieldNames = fieldNames.reduce((list: Array, fieldName: string) => { + const field = schema.fields[fieldName]; + if (field.type !== 'Relation') { + list.push(fieldName); + } + delete schema.fields[fieldName]; + return list; + }, []); + + const values = [className, ...fieldNames]; + const columns = fieldNames + .map((name, idx) => { + return `$${idx + 2}:name`; + }) + .join(', DROP COLUMN'); + + await this._client.tx('delete-fields', async t => { + await t.none( + 'UPDATE "_SCHEMA" SET "schema" = $ WHERE "className" = $', + { schema, className } + ); + if (values.length > 1) { + await t.none(`ALTER TABLE $1:name DROP COLUMN ${columns}`, values); + } + }); } // Return a promise for all schemas known to this adapter, in Parse format. In case the // schemas cannot be retrieved, returns a promise that rejects. Requirements for the // rejection reason are TBD. - getAllClasses() { - return this._ensureSchemaCollectionExists() - .then(() => this._client.map('SELECT * FROM "_SCHEMA"', null, row => ({ className: row.className, ...row.schema }))) - .then(res => res.map(toParseSchema)) + async getAllClasses() { + const self = this; + return this._client.task('get-all-classes', async t => { + await self._ensureSchemaCollectionExists(t); + return await t.map('SELECT * FROM "_SCHEMA"', null, row => + toParseSchema({ className: row.className, ...row.schema }) + ); + }); } // Return a promise for the schema with the given name, in Parse format. If // this adapter doesn't know about the schema, return a promise that rejects with // undefined as the reason. - getClass(className) { + async getClass(className: string) { debug('getClass', className); - return this._client.any('SELECT * FROM "_SCHEMA" WHERE "className"=$', { className }) + return this._client + .any('SELECT * FROM "_SCHEMA" WHERE "className" = $', { + className, + }) .then(result => { - if (result.length === 1) { - return result[0].schema; - } else { + if (result.length !== 1) { throw undefined; } - }).then(toParseSchema); + return result[0].schema; + }) + .then(toParseSchema); } // TODO: remove the mongo format dependency in the return value - createObject(className, schema, object) { + async createObject( + className: string, + schema: SchemaType, + object: any, + transactionalSession: ?any + ) { debug('createObject', className, object); let columnsArray = []; const valuesArray = []; @@ -848,10 +1324,12 @@ export class PostgresStorageAdapter { columnsArray.push(fieldName); if (!schema.fields[fieldName] && className === '_User') { - if (fieldName === '_email_verify_token' || - fieldName === '_failed_login_count' || - fieldName === '_perishable_token' || - fieldName === '_password_history'){ + if ( + fieldName === '_email_verify_token' || + fieldName === '_failed_login_count' || + fieldName === '_perishable_token' || + fieldName === '_password_history' + ) { valuesArray.push(object[fieldName]); } @@ -863,9 +1341,11 @@ export class PostgresStorageAdapter { } } - if (fieldName === '_account_lockout_expires_at' || - fieldName === '_perishable_token_expires_at' || - fieldName === '_password_changed_at') { + if ( + fieldName === '_account_lockout_expires_at' || + fieldName === '_perishable_token_expires_at' || + fieldName === '_password_changed_at' + ) { if (object[fieldName]) { valuesArray.push(object[fieldName].iso); } else { @@ -875,45 +1355,45 @@ export class PostgresStorageAdapter { return; } switch (schema.fields[fieldName].type) { - case 'Date': - if (object[fieldName]) { - valuesArray.push(object[fieldName].iso); - } else { - valuesArray.push(null); - } - break; - case 'Pointer': - valuesArray.push(object[fieldName].objectId); - break; - case 'Array': - if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { + case 'Date': + if (object[fieldName]) { + valuesArray.push(object[fieldName].iso); + } else { + valuesArray.push(null); + } + break; + case 'Pointer': + valuesArray.push(object[fieldName].objectId); + break; + case 'Array': + if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { + valuesArray.push(object[fieldName]); + } else { + valuesArray.push(JSON.stringify(object[fieldName])); + } + break; + case 'Object': + case 'Bytes': + case 'String': + case 'Number': + case 'Boolean': valuesArray.push(object[fieldName]); - } else { - valuesArray.push(JSON.stringify(object[fieldName])); + break; + case 'File': + valuesArray.push(object[fieldName].name); + break; + case 'Polygon': { + const value = convertPolygonToSQL(object[fieldName].coordinates); + valuesArray.push(value); + break; } - break; - case 'Object': - case 'Bytes': - case 'String': - case 'Number': - case 'Boolean': - valuesArray.push(object[fieldName]); - break; - case 'File': - valuesArray.push(object[fieldName].name); - break; - case 'Polygon': { - const value = convertPolygonToSQL(object[fieldName].coordinates); - valuesArray.push(value); - break; - } - case 'GeoPoint': - // pop the point and process later - geoPoints[fieldName] = object[fieldName]; - columnsArray.pop(); - break; - default: - throw `Type ${schema.fields[fieldName].type} not supported yet`; + case 'GeoPoint': + // pop the point and process later + geoPoints[fieldName] = object[fieldName]; + columnsArray.pop(); + break; + default: + throw `Type ${schema.fields[fieldName].type} not supported yet`; } }); @@ -921,31 +1401,43 @@ export class PostgresStorageAdapter { const initialValues = valuesArray.map((val, index) => { let termination = ''; const fieldName = columnsArray[index]; - if (['_rperm','_wperm'].indexOf(fieldName) >= 0) { + if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { termination = '::text[]'; - } else if (schema.fields[fieldName] && schema.fields[fieldName].type === 'Array') { + } else if ( + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Array' + ) { termination = '::jsonb'; } return `$${index + 2 + columnsArray.length}${termination}`; }); - const geoPointsInjects = Object.keys(geoPoints).map((key) => { + const geoPointsInjects = Object.keys(geoPoints).map(key => { const value = geoPoints[key]; valuesArray.push(value.longitude, value.latitude); const l = valuesArray.length + columnsArray.length; return `POINT($${l}, $${l + 1})`; }); - const columnsPattern = columnsArray.map((col, index) => `$${index + 2}:name`).join(','); - const valuesPattern = initialValues.concat(geoPointsInjects).join(',') + const columnsPattern = columnsArray + .map((col, index) => `$${index + 2}:name`) + .join(); + const valuesPattern = initialValues.concat(geoPointsInjects).join(); - const qs = `INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})` - const values = [className, ...columnsArray, ...valuesArray] + const qs = `INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})`; + const values = [className, ...columnsArray, ...valuesArray]; debug(qs, values); - return this._client.none(qs, values) + const promise = (transactionalSession + ? transactionalSession.t + : this._client + ) + .none(qs, values) .then(() => ({ ops: [object] })) .catch(error => { if (error.code === PostgresUniqueIndexViolationError) { - const err = new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); err.underlyingError = error; if (error.constraint) { const matches = error.constraint.match(/unique_([a-zA-Z]+)/); @@ -953,57 +1445,111 @@ export class PostgresStorageAdapter { err.userInfo = { duplicated_field: matches[1] }; } } - throw err; - } else { - throw error; + error = err; } - }) + throw error; + }); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } // Remove all objects that match the given Parse Query. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - deleteObjectsByQuery(className, schema, query) { + async deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ) { debug('deleteObjectsByQuery', className, query); const values = [className]; const index = 2; - const where = buildWhereClause({ schema, index, query }) + const where = buildWhereClause({ + schema, + index, + query, + caseInsensitive: false, + }); values.push(...where.values); if (Object.keys(query).length === 0) { where.pattern = 'TRUE'; } const qs = `WITH deleted AS (DELETE FROM $1:name WHERE ${where.pattern} RETURNING *) SELECT count(*) FROM deleted`; debug(qs, values); - return this._client.one(qs, values , a => +a.count) + const promise = (transactionalSession + ? transactionalSession.t + : this._client + ) + .one(qs, values, a => +a.count) .then(count => { if (count === 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); } else { return count; } - }).catch((error) => { - if (error.code === PostgresRelationDoesNotExistError) { - // Don't delete anything if doesn't exist - } else { + }) + .catch(error => { + if (error.code !== PostgresRelationDoesNotExistError) { throw error; } + // ELSE: Don't delete anything if doesn't exist }); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } // Return value not currently well specified. - findOneAndUpdate(className, schema, query, update) { + async findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise { debug('findOneAndUpdate', className, query, update); - return this.updateObjectsByQuery(className, schema, query, update).then((val) => val[0]); + return this.updateObjectsByQuery( + className, + schema, + query, + update, + transactionalSession + ).then(val => val[0]); } // Apply the update to all objects that match the given Parse Query. - updateObjectsByQuery(className, schema, query, update) { + async updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise<[any]> { debug('updateObjectsByQuery', className, query, update); const updatePatterns = []; - const values = [className] + const values = [className]; let index = 2; schema = toPostgresSchema(schema); - const originalUpdate = {...update}; + const originalUpdate = { ...update }; + + // Set flag for dot notation fields + const dotNotationOptions = {}; + Object.keys(update).forEach(fieldName => { + if (fieldName.indexOf('.') > -1) { + const components = fieldName.split('.'); + const first = components.shift(); + dotNotationOptions[first] = true; + } else { + dotNotationOptions[fieldName] = false; + } + }); update = handleDotFields(update); // Resolve authData first, // So we don't end up with multiple key updates @@ -1020,57 +1566,79 @@ export class PostgresStorageAdapter { for (const fieldName in update) { const fieldValue = update[fieldName]; - if (fieldValue === null) { + // Drop any undefined values. + if (typeof fieldValue === 'undefined') { + delete update[fieldName]; + } else if (fieldValue === null) { updatePatterns.push(`$${index}:name = NULL`); values.push(fieldName); index += 1; } else if (fieldName == 'authData') { // This recursively sets the json_object // Only 1 level deep - const generate = (jsonb, key, value) => { + const generate = (jsonb: string, key: string, value: any) => { return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`; - } + }; const lastKey = `$${index}:name`; const fieldNameIndex = index; index += 1; values.push(fieldName); - const update = Object.keys(fieldValue).reduce((lastKey, key) => { - const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`) - index += 2; - let value = fieldValue[key]; - if (value) { - if (value.__op === 'Delete') { - value = null; - } else { - value = JSON.stringify(value) + const update = Object.keys(fieldValue).reduce( + (lastKey: string, key: string) => { + const str = generate( + lastKey, + `$${index}::text`, + `$${index + 1}::jsonb` + ); + index += 2; + let value = fieldValue[key]; + if (value) { + if (value.__op === 'Delete') { + value = null; + } else { + value = JSON.stringify(value); + } } - } - values.push(key, value); - return str; - }, lastKey); + values.push(key, value); + return str; + }, + lastKey + ); updatePatterns.push(`$${fieldNameIndex}:name = ${update}`); } else if (fieldValue.__op === 'Increment') { - updatePatterns.push(`$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}`); + updatePatterns.push( + `$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}` + ); values.push(fieldName, fieldValue.amount); index += 2; } else if (fieldValue.__op === 'Add') { - updatePatterns.push(`$${index}:name = array_add(COALESCE($${index}:name, '[]'::jsonb), $${index + 1}::jsonb)`); + updatePatterns.push( + `$${index}:name = array_add(COALESCE($${index}:name, '[]'::jsonb), $${index + + 1}::jsonb)` + ); values.push(fieldName, JSON.stringify(fieldValue.objects)); index += 2; } else if (fieldValue.__op === 'Delete') { - updatePatterns.push(`$${index}:name = $${index + 1}`) + updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, null); index += 2; } else if (fieldValue.__op === 'Remove') { - updatePatterns.push(`$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${index + 1}::jsonb)`) + updatePatterns.push( + `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${index + + 1}::jsonb)` + ); values.push(fieldName, JSON.stringify(fieldValue.objects)); index += 2; } else if (fieldValue.__op === 'AddUnique') { - updatePatterns.push(`$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${index + 1}::jsonb)`); + updatePatterns.push( + `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${index + + 1}::jsonb)` + ); values.push(fieldName, JSON.stringify(fieldValue.objects)); index += 2; - } else if (fieldName === 'updatedAt') { //TODO: stop special casing this. It should check for __type === 'Date' and use .iso - updatePatterns.push(`$${index}:name = $${index + 1}`) + } else if (fieldName === 'updatedAt') { + //TODO: stop special casing this. It should check for __type === 'Date' and use .iso + updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; } else if (typeof fieldValue === 'string') { @@ -1098,7 +1666,9 @@ export class PostgresStorageAdapter { values.push(fieldName, toPostgresValue(fieldValue)); index += 2; } else if (fieldValue.__type === 'GeoPoint') { - updatePatterns.push(`$${index}:name = POINT($${index + 1}, $${index + 2})`); + updatePatterns.push( + `$${index}:name = POINT($${index + 1}, $${index + 2})` + ); values.push(fieldName, fieldValue.longitude, fieldValue.latitude); index += 3; } else if (fieldValue.__type === 'Polygon') { @@ -1112,95 +1682,181 @@ export class PostgresStorageAdapter { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; - } else if (typeof fieldValue === 'object' - && schema.fields[fieldName] - && schema.fields[fieldName].type === 'Object') { + } else if ( + typeof fieldValue === 'object' && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Object' + ) { // Gather keys to increment - const keysToIncrement = Object.keys(originalUpdate).filter(k => { - // choose top level fields that have a delete operation set - return originalUpdate[k].__op === 'Increment' && k.split('.').length === 2 && k.split(".")[0] === fieldName; - }).map(k => k.split('.')[1]); + const keysToIncrement = Object.keys(originalUpdate) + .filter(k => { + // choose top level fields that have a delete operation set + // Note that Object.keys is iterating over the **original** update object + // and that some of the keys of the original update could be null or undefined: + // (See the above check `if (fieldValue === null || typeof fieldValue == "undefined")`) + const value = originalUpdate[k]; + return ( + value && + value.__op === 'Increment' && + k.split('.').length === 2 && + k.split('.')[0] === fieldName + ); + }) + .map(k => k.split('.')[1]); let incrementPatterns = ''; if (keysToIncrement.length > 0) { - incrementPatterns = ' || ' + keysToIncrement.map((c) => { - const amount = fieldValue[c].amount; - return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`; - }).join(' || '); + incrementPatterns = + ' || ' + + keysToIncrement + .map(c => { + const amount = fieldValue[c].amount; + return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`; + }) + .join(' || '); // Strip the keys - keysToIncrement.forEach((key) => { + keysToIncrement.forEach(key => { delete fieldValue[key]; }); } - const keysToDelete = Object.keys(originalUpdate).filter(k => { - // choose top level fields that have a delete operation set - return originalUpdate[k].__op === 'Delete' && k.split('.').length === 2 && k.split(".")[0] === fieldName; - }).map(k => k.split('.')[1]); - - const deletePatterns = keysToDelete.reduce((p, c, i) => { - return p + ` - '$${index + 1 + i}:value'`; - }, ''); + const keysToDelete: Array = Object.keys(originalUpdate) + .filter(k => { + // choose top level fields that have a delete operation set. + const value = originalUpdate[k]; + return ( + value && + value.__op === 'Delete' && + k.split('.').length === 2 && + k.split('.')[0] === fieldName + ); + }) + .map(k => k.split('.')[1]); - updatePatterns.push(`$${index}:name = ( COALESCE($${index}:name, '{}'::jsonb) ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length}::jsonb )`); + const deletePatterns = keysToDelete.reduce( + (p: string, c: string, i: number) => { + return p + ` - '$${index + 1 + i}:value'`; + }, + '' + ); + // Override Object + let updateObject = "'{}'::jsonb"; + if (dotNotationOptions[fieldName]) { + // Merge Object + updateObject = `COALESCE($${index}:name, '{}'::jsonb)`; + } + updatePatterns.push( + `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + + 1 + + keysToDelete.length}::jsonb )` + ); values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue)); index += 2 + keysToDelete.length; - } else if (Array.isArray(fieldValue) - && schema.fields[fieldName] - && schema.fields[fieldName].type === 'Array') { + } else if ( + Array.isArray(fieldValue) && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Array' + ) { const expectedType = parseTypeToPostgresType(schema.fields[fieldName]); if (expectedType === 'text[]') { updatePatterns.push(`$${index}:name = $${index + 1}::text[]`); + values.push(fieldName, fieldValue); + index += 2; } else { - let type = 'text'; - for (const elt of fieldValue) { - if (typeof elt == 'object') { - type = 'json'; - break; - } - } - updatePatterns.push(`$${index}:name = array_to_json($${index + 1}::${type}[])::jsonb`); + updatePatterns.push(`$${index}:name = $${index + 1}::jsonb`); + values.push(fieldName, JSON.stringify(fieldValue)); + index += 2; } - values.push(fieldName, fieldValue); - index += 2; } else { debug('Not supported update', fieldName, fieldValue); - return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support update ${JSON.stringify(fieldValue)} yet`)); + return Promise.reject( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Postgres doesn't support update ${JSON.stringify(fieldValue)} yet` + ) + ); } } - const where = buildWhereClause({ schema, index, query }) + const where = buildWhereClause({ + schema, + index, + query, + caseInsensitive: false, + }); values.push(...where.values); - const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; - const qs = `UPDATE $1:name SET ${updatePatterns.join(',')} ${whereClause} RETURNING *`; + const whereClause = + where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`; debug('update: ', qs, values); - return this._client.any(qs, values); + const promise = (transactionalSession + ? transactionalSession.t + : this._client + ).any(qs, values); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } // Hopefully, we can get rid of this. It's only used for config and hooks. - upsertOneObject(className, schema, query, update) { - debug('upsertOneObject', {className, query, update}); + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { + debug('upsertOneObject', { className, query, update }); const createValue = Object.assign({}, query, update); - return this.createObject(className, schema, createValue).catch((err) => { + return this.createObject( + className, + schema, + createValue, + transactionalSession + ).catch(error => { // ignore duplicate value errors as it's upsert - if (err.code === Parse.Error.DUPLICATE_VALUE) { - return this.findOneAndUpdate(className, schema, query, update); + if (error.code !== Parse.Error.DUPLICATE_VALUE) { + throw error; } - throw err; + return this.findOneAndUpdate( + className, + schema, + query, + update, + transactionalSession + ); }); } - find(className, schema, query, { skip, limit, sort, keys }) { - debug('find', className, query, {skip, limit, sort, keys }); + find( + className: string, + schema: SchemaType, + query: QueryType, + { skip, limit, sort, keys, caseInsensitive }: QueryOptions + ) { + debug('find', className, query, { + skip, + limit, + sort, + keys, + caseInsensitive, + }); const hasLimit = limit !== undefined; const hasSkip = skip !== undefined; let values = [className]; - const where = buildWhereClause({ schema, query, index: 2 }) + const where = buildWhereClause({ + schema, + query, + index: 2, + caseInsensitive, + }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const wherePattern = + where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; if (hasLimit) { values.push(limit); @@ -1212,117 +1868,164 @@ export class PostgresStorageAdapter { let sortPattern = ''; if (sort) { - const sorting = Object.keys(sort).map((key) => { - // Using $idx pattern gives: non-integer constant in ORDER BY - if (sort[key] === 1) { - return `"${key}" ASC`; - } - return `"${key}" DESC`; - }).join(','); - sortPattern = sort !== undefined && Object.keys(sort).length > 0 ? `ORDER BY ${sorting}` : ''; + const sortCopy: any = sort; + const sorting = Object.keys(sort) + .map(key => { + const transformKey = transformDotFieldToComponents(key).join('->'); + // Using $idx pattern gives: non-integer constant in ORDER BY + if (sortCopy[key] === 1) { + return `${transformKey} ASC`; + } + return `${transformKey} DESC`; + }) + .join(); + sortPattern = + sort !== undefined && Object.keys(sort).length > 0 + ? `ORDER BY ${sorting}` + : ''; } - if (where.sorts && Object.keys(where.sorts).length > 0) { - sortPattern = `ORDER BY ${where.sorts.join(',')}`; + if (where.sorts && Object.keys((where.sorts: any)).length > 0) { + sortPattern = `ORDER BY ${where.sorts.join()}`; } let columns = '*'; if (keys) { // Exclude empty keys - keys = keys.filter((key) => { - return key.length > 0; - }); - columns = keys.map((key, index) => { - if (key === '$score') { - return `ts_rank_cd(to_tsvector($${2}, $${3}:name), to_tsquery($${4}, $${5}), 32) as score`; + // Replace ACL by it's keys + keys = keys.reduce((memo, key) => { + if (key === 'ACL') { + memo.push('_rperm'); + memo.push('_wperm'); + } else if (key.length > 0) { + memo.push(key); } - return `$${index + values.length + 1}:name`; - }).join(','); + return memo; + }, []); + columns = keys + .map((key, index) => { + if (key === '$score') { + return `ts_rank_cd(to_tsvector($${2}, $${3}:name), to_tsquery($${4}, $${5}), 32) as score`; + } + return `$${index + values.length + 1}:name`; + }) + .join(); values = values.concat(keys); } const qs = `SELECT ${columns} FROM $1:name ${wherePattern} ${sortPattern} ${limitPattern} ${skipPattern}`; debug(qs, values); - return this._client.any(qs, values) - .catch((err) => { - // Query on non existing table, don't crash - if (err.code === PostgresRelationDoesNotExistError) { - return []; + return this._client + .any(qs, values) + .catch(error => { + // Query on non existing table, don't crash + if (error.code !== PostgresRelationDoesNotExistError) { + throw error; } - return Promise.reject(err); + return []; }) - .then(results => results.map(object => { - Object.keys(schema.fields).forEach(fieldName => { - if (schema.fields[fieldName].type === 'Pointer' && object[fieldName]) { - object[fieldName] = { objectId: object[fieldName], __type: 'Pointer', className: schema.fields[fieldName].targetClass }; - } - if (schema.fields[fieldName].type === 'Relation') { - object[fieldName] = { - __type: "Relation", - className: schema.fields[fieldName].targetClass - } - } - if (object[fieldName] && schema.fields[fieldName].type === 'GeoPoint') { - object[fieldName] = { - __type: "GeoPoint", - latitude: object[fieldName].y, - longitude: object[fieldName].x - } - } - if (object[fieldName] && schema.fields[fieldName].type === 'Polygon') { - let coords = object[fieldName]; - coords = coords.substr(2, coords.length - 4).split('),('); - coords = coords.map((point) => { - return [ - parseFloat(point.split(',')[1]), - parseFloat(point.split(',')[0]) - ]; - }); - object[fieldName] = { - __type: "Polygon", - coordinates: coords - } - } - if (object[fieldName] && schema.fields[fieldName].type === 'File') { - object[fieldName] = { - __type: 'File', - name: object[fieldName] - } - } + .then(results => + results.map(object => + this.postgresObjectToParseObject(className, object, schema) + ) + ); + } + + // Converts from a postgres-format object to a REST-format object. + // Does not strip out anything based on a lack of authentication. + postgresObjectToParseObject(className: string, object: any, schema: any) { + Object.keys(schema.fields).forEach(fieldName => { + if (schema.fields[fieldName].type === 'Pointer' && object[fieldName]) { + object[fieldName] = { + objectId: object[fieldName], + __type: 'Pointer', + className: schema.fields[fieldName].targetClass, + }; + } + if (schema.fields[fieldName].type === 'Relation') { + object[fieldName] = { + __type: 'Relation', + className: schema.fields[fieldName].targetClass, + }; + } + if (object[fieldName] && schema.fields[fieldName].type === 'GeoPoint') { + object[fieldName] = { + __type: 'GeoPoint', + latitude: object[fieldName].y, + longitude: object[fieldName].x, + }; + } + if (object[fieldName] && schema.fields[fieldName].type === 'Polygon') { + let coords = object[fieldName]; + coords = coords.substr(2, coords.length - 4).split('),('); + coords = coords.map(point => { + return [ + parseFloat(point.split(',')[1]), + parseFloat(point.split(',')[0]), + ]; }); - //TODO: remove this reliance on the mongo format. DB adapter shouldn't know there is a difference between created at and any other date field. - if (object.createdAt) { - object.createdAt = object.createdAt.toISOString(); - } - if (object.updatedAt) { - object.updatedAt = object.updatedAt.toISOString(); - } - if (object.expiresAt) { - object.expiresAt = { __type: 'Date', iso: object.expiresAt.toISOString() }; - } - if (object._email_verify_token_expires_at) { - object._email_verify_token_expires_at = { __type: 'Date', iso: object._email_verify_token_expires_at.toISOString() }; - } - if (object._account_lockout_expires_at) { - object._account_lockout_expires_at = { __type: 'Date', iso: object._account_lockout_expires_at.toISOString() }; - } - if (object._perishable_token_expires_at) { - object._perishable_token_expires_at = { __type: 'Date', iso: object._perishable_token_expires_at.toISOString() }; - } - if (object._password_changed_at) { - object._password_changed_at = { __type: 'Date', iso: object._password_changed_at.toISOString() }; - } + object[fieldName] = { + __type: 'Polygon', + coordinates: coords, + }; + } + if (object[fieldName] && schema.fields[fieldName].type === 'File') { + object[fieldName] = { + __type: 'File', + name: object[fieldName], + }; + } + }); + //TODO: remove this reliance on the mongo format. DB adapter shouldn't know there is a difference between created at and any other date field. + if (object.createdAt) { + object.createdAt = object.createdAt.toISOString(); + } + if (object.updatedAt) { + object.updatedAt = object.updatedAt.toISOString(); + } + if (object.expiresAt) { + object.expiresAt = { + __type: 'Date', + iso: object.expiresAt.toISOString(), + }; + } + if (object._email_verify_token_expires_at) { + object._email_verify_token_expires_at = { + __type: 'Date', + iso: object._email_verify_token_expires_at.toISOString(), + }; + } + if (object._account_lockout_expires_at) { + object._account_lockout_expires_at = { + __type: 'Date', + iso: object._account_lockout_expires_at.toISOString(), + }; + } + if (object._perishable_token_expires_at) { + object._perishable_token_expires_at = { + __type: 'Date', + iso: object._perishable_token_expires_at.toISOString(), + }; + } + if (object._password_changed_at) { + object._password_changed_at = { + __type: 'Date', + iso: object._password_changed_at.toISOString(), + }; + } - for (const fieldName in object) { - if (object[fieldName] === null) { - delete object[fieldName]; - } - if (object[fieldName] instanceof Date) { - object[fieldName] = { __type: 'Date', iso: object[fieldName].toISOString() }; - } - } + for (const fieldName in object) { + if (object[fieldName] === null) { + delete object[fieldName]; + } + if (object[fieldName] instanceof Date) { + object[fieldName] = { + __type: 'Date', + iso: object[fieldName].toISOString(), + }; + } + } - return object; - })); + return object; } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't @@ -1330,19 +2033,35 @@ export class PostgresStorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - ensureUniqueness(className, schema, fieldNames) { + async ensureUniqueness( + className: string, + schema: SchemaType, + fieldNames: string[] + ) { // Use the same name for every ensureUniqueness attempt, because postgres // Will happily create the same index with multiple names. const constraintName = `unique_${fieldNames.sort().join('_')}`; - const constraintPatterns = fieldNames.map((fieldName, index) => `$${index + 3}:name`); - const qs = `ALTER TABLE $1:name ADD CONSTRAINT $2:name UNIQUE (${constraintPatterns.join(',')})`; - return this._client.none(qs,[className, constraintName, ...fieldNames]) + const constraintPatterns = fieldNames.map( + (fieldName, index) => `$${index + 3}:name` + ); + const qs = `ALTER TABLE $1:name ADD CONSTRAINT $2:name UNIQUE (${constraintPatterns.join()})`; + return this._client + .none(qs, [className, constraintName, ...fieldNames]) .catch(error => { - if (error.code === PostgresDuplicateRelationError && error.message.includes(constraintName)) { - // Index already exists. Ignore error. - } else if (error.code === PostgresUniqueIndexViolationError && error.message.includes(constraintName)) { - // Cast the error into the proper parse error - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(constraintName) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(constraintName) + ) { + // Cast the error into the proper parse error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); } else { throw error; } @@ -1350,42 +2069,360 @@ export class PostgresStorageAdapter { } // Executes a count. - count(className, schema, query) { - debug('count', className, query); + async count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference?: string, + estimate?: boolean = true + ) { + debug('count', className, query, readPreference, estimate); const values = [className]; - const where = buildWhereClause({ schema, query, index: 2 }); + const where = buildWhereClause({ + schema, + query, + index: 2, + caseInsensitive: false, + }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; - const qs = `SELECT count(*) FROM $1:name ${wherePattern}`; - return this._client.one(qs, values, a => +a.count).catch((err) => { - if (err.code === PostgresRelationDoesNotExistError) { + const wherePattern = + where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + let qs = ''; + + if (where.pattern.length > 0 || !estimate) { + qs = `SELECT count(*) FROM $1:name ${wherePattern}`; + } else { + qs = + 'SELECT reltuples AS approximate_row_count FROM pg_class WHERE relname = $1'; + } + + return this._client + .one(qs, values, a => { + if (a.approximate_row_count != null) { + return +a.approximate_row_count; + } else { + return +a.count; + } + }) + .catch(error => { + if (error.code !== PostgresRelationDoesNotExistError) { + throw error; + } return 0; - } - throw err; + }); + } + + async distinct( + className: string, + schema: SchemaType, + query: QueryType, + fieldName: string + ) { + debug('distinct', className, query); + let field = fieldName; + let column = fieldName; + const isNested = fieldName.indexOf('.') >= 0; + if (isNested) { + field = transformDotFieldToComponents(fieldName).join('->'); + column = fieldName.split('.')[0]; + } + const isArrayField = + schema.fields && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Array'; + const isPointerField = + schema.fields && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Pointer'; + const values = [field, column, className]; + const where = buildWhereClause({ + schema, + query, + index: 4, + caseInsensitive: false, }); + values.push(...where.values); + + const wherePattern = + where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const transformer = isArrayField ? 'jsonb_array_elements' : 'ON'; + let qs = `SELECT DISTINCT ${transformer}($1:name) $2:name FROM $3:name ${wherePattern}`; + if (isNested) { + qs = `SELECT DISTINCT ${transformer}($1:raw) $2:raw FROM $3:name ${wherePattern}`; + } + debug(qs, values); + return this._client + .any(qs, values) + .catch(error => { + if (error.code === PostgresMissingColumnError) { + return []; + } + throw error; + }) + .then(results => { + if (!isNested) { + results = results.filter(object => object[field] !== null); + return results.map(object => { + if (!isPointerField) { + return object[field]; + } + return { + __type: 'Pointer', + className: schema.fields[fieldName].targetClass, + objectId: object[field], + }; + }); + } + const child = fieldName.split('.')[1]; + return results.map(object => object[column][child]); + }) + .then(results => + results.map(object => + this.postgresObjectToParseObject(className, object, schema) + ) + ); } - performInitialization({ VolatileClassesSchemas }) { - debug('performInitialization'); - const promises = VolatileClassesSchemas.map((schema) => { - return this.createTable(schema.className, schema).catch((err) => { - if (err.code === PostgresDuplicateRelationError || err.code === Parse.Error.INVALID_CLASS_NAME) { - return Promise.resolve(); + async aggregate(className: string, schema: any, pipeline: any) { + debug('aggregate', className, pipeline); + const values = [className]; + let index: number = 2; + let columns: string[] = []; + let countField = null; + let groupValues = null; + let wherePattern = ''; + let limitPattern = ''; + let skipPattern = ''; + let sortPattern = ''; + let groupPattern = ''; + for (let i = 0; i < pipeline.length; i += 1) { + const stage = pipeline[i]; + if (stage.$group) { + for (const field in stage.$group) { + const value = stage.$group[field]; + if (value === null || value === undefined) { + continue; + } + if (field === '_id' && typeof value === 'string' && value !== '') { + columns.push(`$${index}:name AS "objectId"`); + groupPattern = `GROUP BY $${index}:name`; + values.push(transformAggregateField(value)); + index += 1; + continue; + } + if ( + field === '_id' && + typeof value === 'object' && + Object.keys(value).length !== 0 + ) { + groupValues = value; + const groupByFields = []; + for (const alias in value) { + if (typeof value[alias] === 'string' && value[alias]) { + const source = transformAggregateField(value[alias]); + if (!groupByFields.includes(`"${source}"`)) { + groupByFields.push(`"${source}"`); + } + values.push(source, alias); + columns.push(`$${index}:name AS $${index + 1}:name`); + index += 2; + } else { + const operation = Object.keys(value[alias])[0]; + const source = transformAggregateField(value[alias][operation]); + if (mongoAggregateToPostgres[operation]) { + if (!groupByFields.includes(`"${source}"`)) { + groupByFields.push(`"${source}"`); + } + columns.push( + `EXTRACT(${ + mongoAggregateToPostgres[operation] + } FROM $${index}:name AT TIME ZONE 'UTC') AS $${index + + 1}:name` + ); + values.push(source, alias); + index += 2; + } + } + } + groupPattern = `GROUP BY $${index}:raw`; + values.push(groupByFields.join()); + index += 1; + continue; + } + if (typeof value === 'object') { + if (value.$sum) { + if (typeof value.$sum === 'string') { + columns.push(`SUM($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$sum), field); + index += 2; + } else { + countField = field; + columns.push(`COUNT(*) AS $${index}:name`); + values.push(field); + index += 1; + } + } + if (value.$max) { + columns.push(`MAX($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$max), field); + index += 2; + } + if (value.$min) { + columns.push(`MIN($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$min), field); + index += 2; + } + if (value.$avg) { + columns.push(`AVG($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$avg), field); + index += 2; + } + } } - throw err; + } else { + columns.push('*'); + } + if (stage.$project) { + if (columns.includes('*')) { + columns = []; + } + for (const field in stage.$project) { + const value = stage.$project[field]; + if (value === 1 || value === true) { + columns.push(`$${index}:name`); + values.push(field); + index += 1; + } + } + } + if (stage.$match) { + const patterns = []; + const orOrAnd = Object.prototype.hasOwnProperty.call( + stage.$match, + '$or' + ) + ? ' OR ' + : ' AND '; + + if (stage.$match.$or) { + const collapse = {}; + stage.$match.$or.forEach(element => { + for (const key in element) { + collapse[key] = element[key]; + } + }); + stage.$match = collapse; + } + for (const field in stage.$match) { + const value = stage.$match[field]; + const matchPatterns = []; + Object.keys(ParseToPosgresComparator).forEach(cmp => { + if (value[cmp]) { + const pgComparator = ParseToPosgresComparator[cmp]; + matchPatterns.push( + `$${index}:name ${pgComparator} $${index + 1}` + ); + values.push(field, toPostgresValue(value[cmp])); + index += 2; + } + }); + if (matchPatterns.length > 0) { + patterns.push(`(${matchPatterns.join(' AND ')})`); + } + if ( + schema.fields[field] && + schema.fields[field].type && + matchPatterns.length === 0 + ) { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(field, value); + index += 2; + } + } + wherePattern = + patterns.length > 0 ? `WHERE ${patterns.join(` ${orOrAnd} `)}` : ''; + } + if (stage.$limit) { + limitPattern = `LIMIT $${index}`; + values.push(stage.$limit); + index += 1; + } + if (stage.$skip) { + skipPattern = `OFFSET $${index}`; + values.push(stage.$skip); + index += 1; + } + if (stage.$sort) { + const sort = stage.$sort; + const keys = Object.keys(sort); + const sorting = keys + .map(key => { + const transformer = sort[key] === 1 ? 'ASC' : 'DESC'; + const order = `$${index}:name ${transformer}`; + index += 1; + return order; + }) + .join(); + values.push(...keys); + sortPattern = + sort !== undefined && sorting.length > 0 ? `ORDER BY ${sorting}` : ''; + } + } + + const qs = `SELECT ${columns.join()} FROM $1:name ${wherePattern} ${sortPattern} ${limitPattern} ${skipPattern} ${groupPattern}`; + debug(qs, values); + return this._client + .map(qs, values, a => + this.postgresObjectToParseObject(className, a, schema) + ) + .then(results => { + results.forEach(result => { + if (!Object.prototype.hasOwnProperty.call(result, 'objectId')) { + result.objectId = null; + } + if (groupValues) { + result.objectId = {}; + for (const key in groupValues) { + result.objectId[key] = result[key]; + delete result[key]; + } + } + if (countField) { + result[countField] = parseInt(result[countField], 10); + } + }); + return results; }); + } + + async performInitialization({ VolatileClassesSchemas }: any) { + // TODO: This method needs to be rewritten to make proper use of connections (@vitaly-t) + debug('performInitialization'); + const promises = VolatileClassesSchemas.map(schema => { + return this.createTable(schema.className, schema) + .catch(err => { + if ( + err.code === PostgresDuplicateRelationError || + err.code === Parse.Error.INVALID_CLASS_NAME + ) { + return Promise.resolve(); + } + throw err; + }) + .then(() => this.schemaUpgrade(schema.className, schema)); }); return Promise.all(promises) .then(() => { - return this._client.tx(t => { + return this._client.tx('perform-initialization', t => { return t.batch([ t.none(sql.misc.jsonObjectSetKeys), t.none(sql.array.add), t.none(sql.array.addUnique), t.none(sql.array.remove), t.none(sql.array.containsAll), - t.none(sql.array.contains) + t.none(sql.array.containsAllRegex), + t.none(sql.array.contains), ]); }); }) @@ -1397,6 +2434,99 @@ export class PostgresStorageAdapter { console.error(error); }); } + + async createIndexes( + className: string, + indexes: any, + conn: ?any + ): Promise { + return (conn || this._client).tx(t => + t.batch( + indexes.map(i => { + return t.none('CREATE INDEX $1:name ON $2:name ($3:name)', [ + i.name, + className, + i.key, + ]); + }) + ) + ); + } + + async createIndexesIfNeeded( + className: string, + fieldName: string, + type: any, + conn: ?any + ): Promise { + await ( + conn || this._client + ).none('CREATE INDEX $1:name ON $2:name ($3:name)', [ + fieldName, + className, + type, + ]); + } + + async dropIndexes(className: string, indexes: any, conn: any): Promise { + const queries = indexes.map(i => ({ + query: 'DROP INDEX $1:name', + values: i, + })); + await (conn || this._client).tx(t => + t.none(this._pgp.helpers.concat(queries)) + ); + } + + async getIndexes(className: string) { + const qs = 'SELECT * FROM pg_indexes WHERE tablename = ${className}'; + return this._client.any(qs, { className }); + } + + async updateSchemaWithIndexes(): Promise { + return Promise.resolve(); + } + + // Used for testing purposes + async updateEstimatedCount(className: string) { + return this._client.none('ANALYZE $1:name', [className]); + } + + async createTransactionalSession(): Promise { + return new Promise(resolve => { + const transactionalSession = {}; + transactionalSession.result = this._client.tx(t => { + transactionalSession.t = t; + transactionalSession.promise = new Promise(resolve => { + transactionalSession.resolve = resolve; + }); + transactionalSession.batch = []; + resolve(transactionalSession); + return transactionalSession.promise; + }); + }); + } + + commitTransactionalSession(transactionalSession: any): Promise { + transactionalSession.resolve( + transactionalSession.t.batch(transactionalSession.batch) + ); + return transactionalSession.result; + } + + abortTransactionalSession(transactionalSession: any): Promise { + const result = transactionalSession.result.catch(); + transactionalSession.batch.push(Promise.reject()); + transactionalSession.resolve( + transactionalSession.t.batch(transactionalSession.batch) + ); + return result; + } + + // TODO: implement? + ensureIndex(): Promise { + return Promise.resolve(); + } } function convertPolygonToSQL(polygon) { @@ -1406,16 +2536,17 @@ function convertPolygonToSQL(polygon) { `Polygon must have at least 3 values` ); } - if (polygon[0][0] !== polygon[polygon.length - 1][0] || - polygon[0][1] !== polygon[polygon.length - 1][1]) { + if ( + polygon[0][0] !== polygon[polygon.length - 1][0] || + polygon[0][1] !== polygon[polygon.length - 1][1] + ) { polygon.push(polygon[0]); } const unique = polygon.filter((item, index, ar) => { let foundIndex = -1; for (let i = 0; i < ar.length; i += 1) { const pt = ar[i]; - if (pt[0] === item[0] && - pt[1] === item[1]) { + if (pt[0] === item[0] && pt[1] === item[1]) { foundIndex = i; break; } @@ -1428,34 +2559,38 @@ function convertPolygonToSQL(polygon) { 'GeoJSON: Loop must have at least 3 different vertices' ); } - const points = polygon.map((point) => { - Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); - return `(${point[1]}, ${point[0]})`; - }).join(', '); + const points = polygon + .map(point => { + Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); + return `(${point[1]}, ${point[0]})`; + }) + .join(', '); return `(${points})`; } function removeWhiteSpace(regex) { - if (!regex.endsWith('\n')){ + if (!regex.endsWith('\n')) { regex += '\n'; } // remove non escaped comments - return regex.replace(/([^\\])#.*\n/gmi, '$1') - // remove lines starting with a comment - .replace(/^#.*\n/gmi, '') - // remove non escaped whitespace - .replace(/([^\\])\s+/gmi, '$1') - // remove whitespace at the beginning of a line - .replace(/^\s+/, '') - .trim(); + return ( + regex + .replace(/([^\\])#.*\n/gim, '$1') + // remove lines starting with a comment + .replace(/^#.*\n/gim, '') + // remove non escaped whitespace + .replace(/([^\\])\s+/gim, '$1') + // remove whitespace at the beginning of a line + .replace(/^\s+/, '') + .trim() + ); } function processRegexPattern(s) { - if (s && s.startsWith('^')){ + if (s && s.startsWith('^')) { // regex for startsWith return '^' + literalizeRegexPart(s.slice(1)); - } else if (s && s.endsWith('$')) { // regex for endsWith return literalizeRegexPart(s.slice(0, s.length - 1)) + '$'; @@ -1465,21 +2600,59 @@ function processRegexPattern(s) { return literalizeRegexPart(s); } -function createLiteralRegex(remaining) { - return remaining.split('').map(c => { - if (c.match(/[0-9a-zA-Z]/) !== null) { - // don't escape alphanumeric characters - return c; +function isStartsWithRegex(value) { + if (!value || typeof value !== 'string' || !value.startsWith('^')) { + return false; + } + + const matches = value.match(/\^\\Q.*\\E/); + return !!matches; +} + +function isAllValuesRegexOrNone(values) { + if (!values || !Array.isArray(values) || values.length === 0) { + return true; + } + + const firstValuesIsRegex = isStartsWithRegex(values[0].$regex); + if (values.length === 1) { + return firstValuesIsRegex; + } + + for (let i = 1, length = values.length; i < length; ++i) { + if (firstValuesIsRegex !== isStartsWithRegex(values[i].$regex)) { + return false; } - // escape everything else (single quotes with single quotes, everything else with a backslash) - return c === `'` ? `''` : `\\${c}`; - }).join(''); + } + + return true; +} + +function isAnyValueRegexStartsWith(values) { + return values.some(function(value) { + return isStartsWithRegex(value.$regex); + }); } -function literalizeRegexPart(s) { - const matcher1 = /\\Q((?!\\E).*)\\E$/ - const result1 = s.match(matcher1); - if(result1 && result1.length > 1 && result1.index > -1){ +function createLiteralRegex(remaining) { + return remaining + .split('') + .map(c => { + const regex = RegExp('[0-9 ]|\\p{L}', 'u'); // Support all unicode letter chars + if (c.match(regex) !== null) { + // don't escape alphanumeric characters + return c; + } + // escape everything else (single quotes with single quotes, everything else with a backslash) + return c === `'` ? `''` : `\\${c}`; + }) + .join(''); +} + +function literalizeRegexPart(s: string) { + const matcher1 = /\\Q((?!\\E).*)\\E$/; + const result1: any = s.match(matcher1); + if (result1 && result1.length > 1 && result1.index > -1) { // process regex that has a beginning and an end specified for the literal text const prefix = s.substr(0, result1.index); const remaining = result1[1]; @@ -1488,9 +2661,9 @@ function literalizeRegexPart(s) { } // process regex that has a beginning specified for the literal text - const matcher2 = /\\Q((?!\\E).*)$/ - const result2 = s.match(matcher2); - if(result2 && result2.length > 1 && result2.index > -1){ + const matcher2 = /\\Q((?!\\E).*)$/; + const result2: any = s.match(matcher2); + if (result2 && result2.length > 1 && result2.index > -1) { const prefix = s.substr(0, result2.index); const remaining = result2[1]; @@ -1498,15 +2671,21 @@ function literalizeRegexPart(s) { } // remove all instances of \Q and \E from the remaining text & escape single quotes - return ( - s.replace(/([^\\])(\\E)/, '$1') - .replace(/([^\\])(\\Q)/, '$1') - .replace(/^\\E/, '') - .replace(/^\\Q/, '') - .replace(/([^'])'/, `$1''`) - .replace(/^'([^'])/, `''$1`) - ); + return s + .replace(/([^\\])(\\E)/, '$1') + .replace(/([^\\])(\\Q)/, '$1') + .replace(/^\\E/, '') + .replace(/^\\Q/, '') + .replace(/([^'])'/, `$1''`) + .replace(/^'([^'])/, `''$1`); } +var GeoPointCoder = { + isValidJSON(value) { + return ( + typeof value === 'object' && value !== null && value.__type === 'GeoPoint' + ); + }, +}; + export default PostgresStorageAdapter; -module.exports = PostgresStorageAdapter; // Required for tests diff --git a/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql b/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql new file mode 100644 index 0000000000..7ca5853a9f --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql @@ -0,0 +1,14 @@ +CREATE OR REPLACE FUNCTION array_contains_all_regex( + "array" jsonb, + "values" jsonb +) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT CASE + WHEN 0 = jsonb_array_length("values") THEN true = false + ELSE (SELECT RES.CNT = jsonb_array_length("values") FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements_text("array") as elt WHERE elt LIKE ANY (SELECT jsonb_array_elements_text("values"))) as RES) + END; +$function$; \ No newline at end of file diff --git a/src/Adapters/Storage/Postgres/sql/array/contains-all.sql b/src/Adapters/Storage/Postgres/sql/array/contains-all.sql index 24355bc732..8db1ca0e7b 100644 --- a/src/Adapters/Storage/Postgres/sql/array/contains-all.sql +++ b/src/Adapters/Storage/Postgres/sql/array/contains-all.sql @@ -7,5 +7,8 @@ CREATE OR REPLACE FUNCTION array_contains_all( IMMUTABLE STRICT AS $function$ - SELECT RES.CNT = jsonb_array_length("values") FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements("array") as elt WHERE elt IN (SELECT jsonb_array_elements("values"))) as RES; + SELECT CASE + WHEN 0 = jsonb_array_length("values") THEN true = false + ELSE (SELECT RES.CNT = jsonb_array_length("values") FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements_text("array") as elt WHERE elt IN (SELECT jsonb_array_elements_text("values"))) as RES) + END; $function$; diff --git a/src/Adapters/Storage/Postgres/sql/index.js b/src/Adapters/Storage/Postgres/sql/index.js index 5ddfb036cf..ad151f2170 100644 --- a/src/Adapters/Storage/Postgres/sql/index.js +++ b/src/Adapters/Storage/Postgres/sql/index.js @@ -9,20 +9,20 @@ module.exports = { addUnique: sql('array/add-unique.sql'), contains: sql('array/contains.sql'), containsAll: sql('array/contains-all.sql'), - remove: sql('array/remove.sql') + containsAllRegex: sql('array/contains-all-regex.sql'), + remove: sql('array/remove.sql'), }, misc: { - jsonObjectSetKeys: sql('misc/json-object-set-keys.sql') - } + jsonObjectSetKeys: sql('misc/json-object-set-keys.sql'), + }, }; /////////////////////////////////////////////// // Helper for linking to external query files; function sql(file) { - var fullPath = path.join(__dirname, file); // generating full path; - var qf = new QueryFile(fullPath, {minify: true}); + var qf = new QueryFile(fullPath, { minify: true }); if (qf.error) { throw qf.error; diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js new file mode 100644 index 0000000000..ca4b406b37 --- /dev/null +++ b/src/Adapters/Storage/StorageAdapter.js @@ -0,0 +1,140 @@ +// @flow +export type SchemaType = any; +export type StorageClass = any; +export type QueryType = any; + +export type QueryOptions = { + skip?: number, + limit?: number, + acl?: string[], + sort?: { [string]: number }, + count?: boolean | number, + keys?: string[], + op?: string, + distinct?: boolean, + pipeline?: any, + readPreference?: ?string, + hint?: ?mixed, + explain?: Boolean, + caseInsensitive?: boolean, + action?: string, + addsField?: boolean, +}; + +export type UpdateQueryOptions = { + many?: boolean, + upsert?: boolean, +}; + +export type FullQueryOptions = QueryOptions & UpdateQueryOptions; + +export interface StorageAdapter { + canSortOnJoinTables: boolean; + + classExists(className: string): Promise; + setClassLevelPermissions(className: string, clps: any): Promise; + createClass(className: string, schema: SchemaType): Promise; + addFieldIfNotExists( + className: string, + fieldName: string, + type: any + ): Promise; + deleteClass(className: string): Promise; + deleteAllClasses(fast: boolean): Promise; + deleteFields( + className: string, + schema: SchemaType, + fieldNames: Array + ): Promise; + getAllClasses(): Promise; + getClass(className: string): Promise; + createObject( + className: string, + schema: SchemaType, + object: any, + transactionalSession: ?any + ): Promise; + deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ): Promise; + updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise<[any]>; + findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise; + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise; + find( + className: string, + schema: SchemaType, + query: QueryType, + options: QueryOptions + ): Promise<[any]>; + ensureIndex( + className: string, + schema: SchemaType, + fieldNames: string[], + indexName?: string, + caseSensitive?: boolean + ): Promise; + ensureUniqueness( + className: string, + schema: SchemaType, + fieldNames: Array + ): Promise; + count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference?: string, + estimate?: boolean, + hint?: mixed + ): Promise; + distinct( + className: string, + schema: SchemaType, + query: QueryType, + fieldName: string + ): Promise; + aggregate( + className: string, + schema: any, + pipeline: any, + readPreference: ?string, + hint: ?mixed, + explain?: boolean + ): Promise; + performInitialization(options: ?any): Promise; + + // Indexing + createIndexes(className: string, indexes: any, conn: ?any): Promise; + getIndexes(className: string, connection: ?any): Promise; + updateSchemaWithIndexes(): Promise; + setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any, + fields: any, + conn: ?any + ): Promise; + createTransactionalSession(): Promise; + commitTransactionalSession(transactionalSession: any): Promise; + abortTransactionalSession(transactionalSession: any): Promise; +} diff --git a/src/Adapters/WebSocketServer/WSAdapter.js b/src/Adapters/WebSocketServer/WSAdapter.js new file mode 100644 index 0000000000..5522dad365 --- /dev/null +++ b/src/Adapters/WebSocketServer/WSAdapter.js @@ -0,0 +1,26 @@ +/*eslint no-unused-vars: "off"*/ +import { WSSAdapter } from './WSSAdapter'; +const WebSocketServer = require('ws').Server; + +/** + * Wrapper for ws node module + */ +export class WSAdapter extends WSSAdapter { + constructor(options: any) { + super(options); + this.options = options; + } + + onListen() {} + onConnection(ws) {} + onError(error) {} + start() { + const wss = new WebSocketServer({ server: this.options.server }); + wss.on('listening', this.onListen); + wss.on('connection', this.onConnection); + wss.on('error', this.onError); + } + close() {} +} + +export default WSAdapter; diff --git a/src/Adapters/WebSocketServer/WSSAdapter.js b/src/Adapters/WebSocketServer/WSSAdapter.js new file mode 100644 index 0000000000..007cacdf76 --- /dev/null +++ b/src/Adapters/WebSocketServer/WSSAdapter.js @@ -0,0 +1,61 @@ +/*eslint no-unused-vars: "off"*/ +// WebSocketServer Adapter +// +// Adapter classes must implement the following functions: +// * onListen() +// * onConnection(ws) +// * onError(error) +// * start() +// * close() +// +// Default is WSAdapter. The above functions will be binded. + +/** + * @module Adapters + */ +/** + * @interface WSSAdapter + */ +export class WSSAdapter { + /** + * @param {Object} options - {http.Server|https.Server} server + */ + constructor(options) { + this.onListen = () => {}; + this.onConnection = () => {}; + this.onError = () => {}; + } + + // /** + // * Emitted when the underlying server has been bound. + // */ + // onListen() {} + + // /** + // * Emitted when the handshake is complete. + // * + // * @param {WebSocket} ws - RFC 6455 WebSocket. + // */ + // onConnection(ws) {} + + // /** + // * Emitted when error event is called. + // * + // * @param {Error} error - WebSocketServer error + // */ + // onError(error) {} + + /** + * Initialize Connection. + * + * @param {Object} options + */ + start(options) {} + + /** + * Closes server. + */ + close() {} +} + +export default WSSAdapter; diff --git a/src/Auth.js b/src/Auth.js index 60273b9638..ebb5debfd2 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,11 +1,20 @@ -var Parse = require('parse/node').Parse; -var RestQuery = require('./RestQuery'); +const cryptoUtils = require('./cryptoUtils'); +const RestQuery = require('./RestQuery'); +const Parse = require('parse/node'); // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. -function Auth({ config, isMaster = false, isReadOnly = false, user, installationId } = {}) { +function Auth({ + config, + cacheController = undefined, + isMaster = false, + isReadOnly = false, + user, + installationId, +}) { this.config = config; + this.cacheController = cacheController || (config && config.cacheController); this.installationId = installationId; this.isMaster = isMaster; this.user = user; @@ -20,14 +29,14 @@ function Auth({ config, isMaster = false, isReadOnly = false, user, installation // Whether this auth could possibly modify the given user id. // It still could be forbidden via ACLs even if this returns true. -Auth.prototype.couldUpdateUserId = function(userId) { +Auth.prototype.isUnauthenticated = function() { if (this.isMaster) { - return true; + return false; } - if (this.user && this.user.id === userId) { - return true; + if (this.user) { + return false; } - return false; + return true; }; // A helper to get a master-level Auth object @@ -45,60 +54,120 @@ function nobody(config) { return new Auth({ config, isMaster: false }); } - // Returns a promise that resolves to an Auth object -var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) { - return config.cacheController.user.get(sessionToken).then((userJSON) => { +const getAuthForSessionToken = async function({ + config, + cacheController, + sessionToken, + installationId, +}) { + cacheController = cacheController || (config && config.cacheController); + if (cacheController) { + const userJSON = await cacheController.user.get(sessionToken); if (userJSON) { const cachedUser = Parse.Object.fromJSON(userJSON); - return Promise.resolve(new Auth({config, isMaster: false, installationId, user: cachedUser})); + return Promise.resolve( + new Auth({ + config, + cacheController, + isMaster: false, + installationId, + user: cachedUser, + }) + ); } + } - var restOptions = { + let results; + if (config) { + const restOptions = { limit: 1, - include: 'user' + include: 'user', }; - var query = new RestQuery(config, master(config), '_Session', {sessionToken}, restOptions); - return query.execute().then((response) => { - var results = response.results; - if (results.length !== 1 || !results[0]['user']) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); - } + const query = new RestQuery( + config, + master(config), + '_Session', + { sessionToken }, + restOptions + ); + results = (await query.execute()).results; + } else { + results = (await new Parse.Query(Parse.Session) + .limit(1) + .include('user') + .equalTo('sessionToken', sessionToken) + .find({ useMasterKey: true })).map(obj => obj.toJSON()); + } - var now = new Date(), - expiresAt = results[0].expiresAt ? new Date(results[0].expiresAt.iso) : undefined; - if (expiresAt < now) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token is expired.'); - } - var obj = results[0]['user']; - delete obj.password; - obj['className'] = '_User'; - obj['sessionToken'] = sessionToken; - config.cacheController.user.put(sessionToken, obj); - const userObject = Parse.Object.fromJSON(obj); - return new Auth({config, isMaster: false, installationId, user: userObject}); - }); + if (results.length !== 1 || !results[0]['user']) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); + } + const now = new Date(), + expiresAt = results[0].expiresAt + ? new Date(results[0].expiresAt.iso) + : undefined; + if (expiresAt < now) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Session token is expired.' + ); + } + const obj = results[0]['user']; + delete obj.password; + obj['className'] = '_User'; + obj['sessionToken'] = sessionToken; + if (cacheController) { + cacheController.user.put(sessionToken, obj); + } + const userObject = Parse.Object.fromJSON(obj); + return new Auth({ + config, + cacheController, + isMaster: false, + installationId, + user: userObject, }); }; -var getAuthForLegacySessionToken = function({config, sessionToken, installationId } = {}) { +var getAuthForLegacySessionToken = function({ + config, + sessionToken, + installationId, +}) { var restOptions = { - limit: 1 + limit: 1, }; - var query = new RestQuery(config, master(config), '_User', { sessionToken: sessionToken}, restOptions); - return query.execute().then((response) => { + var query = new RestQuery( + config, + master(config), + '_User', + { sessionToken }, + restOptions + ); + return query.execute().then(response => { var results = response.results; if (results.length !== 1) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid legacy session token'); + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'invalid legacy session token' + ); } const obj = results[0]; obj.className = '_User'; const userObject = Parse.Object.fromJSON(obj); - return new Auth({config, isMaster: false, installationId, user: userObject}); + return new Auth({ + config, + isMaster: false, + installationId, + user: userObject, + }); }); -} +}; // Returns a promise that resolves to an array of role names Auth.prototype.getUserRoles = function() { @@ -115,102 +184,203 @@ Auth.prototype.getUserRoles = function() { return this.rolePromise; }; -// Iterates through the role tree and compiles a users roles -Auth.prototype._loadRoles = function() { - var cacheAdapter = this.config.cacheController; - return cacheAdapter.role.get(this.user.id).then((cachedRoles) => { +Auth.prototype.getRolesForUser = async function() { + //Stack all Parse.Role + const results = []; + if (this.config) { + const restWhere = { + users: { + __type: 'Pointer', + className: '_User', + objectId: this.user.id, + }, + }; + await new RestQuery( + this.config, + master(this.config), + '_Role', + restWhere, + {} + ).each(result => results.push(result)); + } else { + await new Parse.Query(Parse.Role) + .equalTo('users', this.user) + .each(result => results.push(result.toJSON()), { useMasterKey: true }); + } + return results; +}; + +// Iterates through the role tree and compiles a user's roles +Auth.prototype._loadRoles = async function() { + if (this.cacheController) { + const cachedRoles = await this.cacheController.role.get(this.user.id); if (cachedRoles != null) { this.fetchedRoles = true; this.userRoles = cachedRoles; - return Promise.resolve(cachedRoles); + return cachedRoles; } + } - var restWhere = { - 'users': { - __type: 'Pointer', - className: '_User', - objectId: this.user.id - } - }; - // First get the role ids this user is directly a member of - var query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - if (!results.length) { - this.userRoles = []; - this.fetchedRoles = true; - this.rolePromise = null; + // First get the role ids this user is directly a member of + const results = await this.getRolesForUser(); + if (!results.length) { + this.userRoles = []; + this.fetchedRoles = true; + this.rolePromise = null; - cacheAdapter.role.put(this.user.id, Array(...this.userRoles)); - return Promise.resolve(this.userRoles); - } - var rolesMap = results.reduce((m, r) => { - m.names.push(r.name); - m.ids.push(r.objectId); - return m; - }, {ids: [], names: []}); - - // run the recursive finding - return this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names) - .then((roleNames) => { - this.userRoles = roleNames.map((r) => { - return 'role:' + r; - }); - this.fetchedRoles = true; - this.rolePromise = null; - cacheAdapter.role.put(this.user.id, Array(...this.userRoles)); - return Promise.resolve(this.userRoles); - }); - }); + this.cacheRoles(); + return this.userRoles; + } + + const rolesMap = results.reduce( + (m, r) => { + m.names.push(r.name); + m.ids.push(r.objectId); + return m; + }, + { ids: [], names: [] } + ); + + // run the recursive finding + const roleNames = await this._getAllRolesNamesForRoleIds( + rolesMap.ids, + rolesMap.names + ); + this.userRoles = roleNames.map(r => { + return 'role:' + r; }); + this.fetchedRoles = true; + this.rolePromise = null; + this.cacheRoles(); + return this.userRoles; +}; + +Auth.prototype.cacheRoles = function() { + if (!this.cacheController) { + return false; + } + this.cacheController.role.put(this.user.id, Array(...this.userRoles)); + return true; +}; + +Auth.prototype.getRolesByIds = async function(ins) { + const results = []; + // Build an OR query across all parentRoles + if (!this.config) { + await new Parse.Query(Parse.Role) + .containedIn( + 'roles', + ins.map(id => { + const role = new Parse.Object(Parse.Role); + role.id = id; + return role; + }) + ) + .each(result => results.push(result.toJSON()), { useMasterKey: true }); + } else { + const roles = ins.map(id => { + return { + __type: 'Pointer', + className: '_Role', + objectId: id, + }; + }); + const restWhere = { roles: { $in: roles } }; + await new RestQuery( + this.config, + master(this.config), + '_Role', + restWhere, + {} + ).each(result => results.push(result)); + } + return results; }; // Given a list of roleIds, find all the parent roles, returns a promise with all names -Auth.prototype._getAllRolesNamesForRoleIds = function(roleIDs, names = [], queriedRoles = {}) { - const ins = roleIDs.filter((roleID) => { - return queriedRoles[roleID] !== true; - }).map((roleID) => { - // mark as queried +Auth.prototype._getAllRolesNamesForRoleIds = function( + roleIDs, + names = [], + queriedRoles = {} +) { + const ins = roleIDs.filter(roleID => { + const wasQueried = queriedRoles[roleID] !== true; queriedRoles[roleID] = true; - return { - __type: 'Pointer', - className: '_Role', - objectId: roleID - } + return wasQueried; }); // all roles are accounted for, return the names if (ins.length == 0) { return Promise.resolve([...new Set(names)]); } - // Build an OR query across all parentRoles - let restWhere; - if (ins.length == 1) { - restWhere = { 'roles': ins[0] }; - } else { - restWhere = { 'roles': { '$in': ins }} + + return this.getRolesByIds(ins) + .then(results => { + // Nothing found + if (!results.length) { + return Promise.resolve(names); + } + // Map the results with all Ids and names + const resultMap = results.reduce( + (memo, role) => { + memo.names.push(role.name); + memo.ids.push(role.objectId); + return memo; + }, + { ids: [], names: [] } + ); + // store the new found names + names = names.concat(resultMap.names); + // find the next ones, circular roles will be cut + return this._getAllRolesNamesForRoleIds( + resultMap.ids, + names, + queriedRoles + ); + }) + .then(names => { + return Promise.resolve([...new Set(names)]); + }); +}; + +const createSession = function( + config, + { userId, createdWith, installationId, additionalSessionData } +) { + const token = 'r:' + cryptoUtils.newToken(); + const expiresAt = config.generateSessionExpiresAt(); + const sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: userId, + }, + createdWith, + restricted: false, + expiresAt: Parse._encode(expiresAt), + }; + + if (installationId) { + sessionData.installationId = installationId; } - const query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - // Nothing found - if (!results.length) { - return Promise.resolve(names); - } - // Map the results with all Ids and names - const resultMap = results.reduce((memo, role) => { - memo.names.push(role.name); - memo.ids.push(role.objectId); - return memo; - }, {ids: [], names: []}); - // store the new found names - names = names.concat(resultMap.names); - // find the next ones, circular roles will be cut - return this._getAllRolesNamesForRoleIds(resultMap.ids, names, queriedRoles) - }).then((names) => { - return Promise.resolve([...new Set(names)]) - }) -} + + Object.assign(sessionData, additionalSessionData); + // We need to import RestWrite at this point for the cyclic dependency it has to it + const RestWrite = require('./RestWrite'); + + return { + sessionData, + createSession: () => + new RestWrite( + config, + master(config), + '_Session', + null, + sessionData + ).execute(), + }; +}; module.exports = { Auth, @@ -218,5 +388,6 @@ module.exports = { nobody, readOnly, getAuthForSessionToken, - getAuthForLegacySessionToken + getAuthForLegacySessionToken, + createSession, }; diff --git a/src/ClientSDK.js b/src/ClientSDK.js index 9a23b0c6ff..e4a716ab86 100644 --- a/src/ClientSDK.js +++ b/src/ClientSDK.js @@ -12,12 +12,12 @@ function compatible(compatibleSDK) { const clientVersion = clientSDK.version; const compatiblityVersion = compatibleSDK[clientSDK.sdk]; return semver.satisfies(clientVersion, compatiblityVersion); - } + }; } function supportsForwardDelete(clientSDK) { return compatible({ - js: '>=1.9.0' + js: '>=1.9.0', })(clientSDK); } @@ -27,8 +27,8 @@ function fromString(version) { if (match && match.length === 3) { return { sdk: match[1], - version: match[2] - } + version: match[2], + }; } return undefined; } @@ -36,5 +36,5 @@ function fromString(version) { module.exports = { compatible, supportsForwardDelete, - fromString -} + fromString, +}; diff --git a/src/Config.js b/src/Config.js index d9eec85da7..2077626ff8 100644 --- a/src/Config.js +++ b/src/Config.js @@ -11,7 +11,7 @@ function removeTrailingSlash(str) { if (!str) { return str; } - if (str.endsWith("/")) { + if (str.endsWith('/')) { str = str.substr(0, str.length - 1); } return str; @@ -25,19 +25,28 @@ export class Config { } const config = new Config(); config.applicationId = applicationId; - Object.keys(cacheInfo).forEach((key) => { + Object.keys(cacheInfo).forEach(key => { if (key == 'databaseController') { - const schemaCache = new SchemaCache(cacheInfo.cacheController, + const schemaCache = new SchemaCache( + cacheInfo.cacheController, cacheInfo.schemaCacheTTL, - cacheInfo.enableSingleSchemaCache); - config.database = new DatabaseController(cacheInfo.databaseController.adapter, schemaCache); + cacheInfo.enableSingleSchemaCache + ); + config.database = new DatabaseController( + cacheInfo.databaseController.adapter, + schemaCache + ); } else { config[key] = cacheInfo[key]; } }); config.mount = removeTrailingSlash(mount); - config.generateSessionExpiresAt = config.generateSessionExpiresAt.bind(config); - config.generateEmailVerifyTokenExpiresAt = config.generateEmailVerifyTokenExpiresAt.bind(config); + config.generateSessionExpiresAt = config.generateSessionExpiresAt.bind( + config + ); + config.generateEmailVerifyTokenExpiresAt = config.generateEmailVerifyTokenExpiresAt.bind( + config + ); return config; } @@ -63,15 +72,20 @@ export class Config { masterKeyIps, masterKey, readOnlyMasterKey, + allowHeaders, }) { - if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); } const emailAdapter = userController.adapter; if (verifyUserEmails) { - this.validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}); + this.validateEmailConfiguration({ + emailAdapter, + appName, + publicServerURL, + emailVerifyTokenValidityDuration, + }); } this.validateAccountLockoutPolicy(accountLockout); @@ -83,8 +97,11 @@ export class Config { } if (publicServerURL) { - if (!publicServerURL.startsWith("http://") && !publicServerURL.startsWith("https://")) { - throw "publicServerURL should be a valid HTTPS URL starting with https://" + if ( + !publicServerURL.startsWith('http://') && + !publicServerURL.startsWith('https://') + ) { + throw 'publicServerURL should be a valid HTTPS URL starting with https://'; } } @@ -93,15 +110,25 @@ export class Config { this.validateMasterKeyIps(masterKeyIps); this.validateMaxLimit(maxLimit); + + this.validateAllowHeaders(allowHeaders); } static validateAccountLockoutPolicy(accountLockout) { if (accountLockout) { - if (typeof accountLockout.duration !== 'number' || accountLockout.duration <= 0 || accountLockout.duration > 99999) { + if ( + typeof accountLockout.duration !== 'number' || + accountLockout.duration <= 0 || + accountLockout.duration > 99999 + ) { throw 'Account lockout duration should be greater than 0 and less than 100000'; } - if (!Number.isInteger(accountLockout.threshold) || accountLockout.threshold < 1 || accountLockout.threshold > 999) { + if ( + !Number.isInteger(accountLockout.threshold) || + accountLockout.threshold < 1 || + accountLockout.threshold > 999 + ) { throw 'Account lockout threshold should be an integer greater than 0 and less than 1000'; } } @@ -109,33 +136,52 @@ export class Config { static validatePasswordPolicy(passwordPolicy) { if (passwordPolicy) { - if (passwordPolicy.maxPasswordAge !== undefined && (typeof passwordPolicy.maxPasswordAge !== 'number' || passwordPolicy.maxPasswordAge < 0)) { + if ( + passwordPolicy.maxPasswordAge !== undefined && + (typeof passwordPolicy.maxPasswordAge !== 'number' || + passwordPolicy.maxPasswordAge < 0) + ) { throw 'passwordPolicy.maxPasswordAge must be a positive number'; } - if (passwordPolicy.resetTokenValidityDuration !== undefined && (typeof passwordPolicy.resetTokenValidityDuration !== 'number' || passwordPolicy.resetTokenValidityDuration <= 0)) { + if ( + passwordPolicy.resetTokenValidityDuration !== undefined && + (typeof passwordPolicy.resetTokenValidityDuration !== 'number' || + passwordPolicy.resetTokenValidityDuration <= 0) + ) { throw 'passwordPolicy.resetTokenValidityDuration must be a positive number'; } - if(passwordPolicy.validatorPattern){ - if(typeof(passwordPolicy.validatorPattern) === 'string') { - passwordPolicy.validatorPattern = new RegExp(passwordPolicy.validatorPattern); - } - else if(!(passwordPolicy.validatorPattern instanceof RegExp)){ + if (passwordPolicy.validatorPattern) { + if (typeof passwordPolicy.validatorPattern === 'string') { + passwordPolicy.validatorPattern = new RegExp( + passwordPolicy.validatorPattern + ); + } else if (!(passwordPolicy.validatorPattern instanceof RegExp)) { throw 'passwordPolicy.validatorPattern must be a regex string or RegExp object.'; } } - - if(passwordPolicy.validatorCallback && typeof passwordPolicy.validatorCallback !== 'function') { + if ( + passwordPolicy.validatorCallback && + typeof passwordPolicy.validatorCallback !== 'function' + ) { throw 'passwordPolicy.validatorCallback must be a function.'; } - if(passwordPolicy.doNotAllowUsername && typeof passwordPolicy.doNotAllowUsername !== 'boolean') { + if ( + passwordPolicy.doNotAllowUsername && + typeof passwordPolicy.doNotAllowUsername !== 'boolean' + ) { throw 'passwordPolicy.doNotAllowUsername must be a boolean value.'; } - if (passwordPolicy.maxPasswordHistory && (!Number.isInteger(passwordPolicy.maxPasswordHistory) || passwordPolicy.maxPasswordHistory <= 0 || passwordPolicy.maxPasswordHistory > 20)) { + if ( + passwordPolicy.maxPasswordHistory && + (!Number.isInteger(passwordPolicy.maxPasswordHistory) || + passwordPolicy.maxPasswordHistory <= 0 || + passwordPolicy.maxPasswordHistory > 20) + ) { throw 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'; } } @@ -144,13 +190,18 @@ export class Config { // if the passwordPolicy.validatorPattern is configured then setup a callback to process the pattern static setupPasswordValidator(passwordPolicy) { if (passwordPolicy && passwordPolicy.validatorPattern) { - passwordPolicy.patternValidator = (value) => { + passwordPolicy.patternValidator = value => { return passwordPolicy.validatorPattern.test(value); - } + }; } } - static validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}) { + static validateEmailConfiguration({ + emailAdapter, + appName, + publicServerURL, + emailVerifyTokenValidityDuration, + }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; } @@ -164,14 +215,14 @@ export class Config { if (isNaN(emailVerifyTokenValidityDuration)) { throw 'Email verify token validity duration must be a valid number.'; } else if (emailVerifyTokenValidityDuration <= 0) { - throw 'Email verify token validity duration must be a value greater than 0.' + throw 'Email verify token validity duration must be a value greater than 0.'; } } } static validateMasterKeyIps(masterKeyIps) { for (const ip of masterKeyIps) { - if(!net.isIP(ip)){ + if (!net.isIP(ip)) { throw `Invalid ip in masterKeyIps: ${ip}`; } } @@ -193,16 +244,31 @@ export class Config { if (expireInactiveSessions) { if (isNaN(sessionLength)) { throw 'Session length must be a valid number.'; - } - else if (sessionLength <= 0) { - throw 'Session length must be a value greater than 0.' + } else if (sessionLength <= 0) { + throw 'Session length must be a value greater than 0.'; } } } static validateMaxLimit(maxLimit) { if (maxLimit <= 0) { - throw 'Max limit must be a value greater than 0.' + throw 'Max limit must be a value greater than 0.'; + } + } + + static validateAllowHeaders(allowHeaders) { + if (![null, undefined].includes(allowHeaders)) { + if (Array.isArray(allowHeaders)) { + allowHeaders.forEach(header => { + if (typeof header !== 'string') { + throw 'Allow headers must only contain strings'; + } else if (!header.trim().length) { + throw 'Allow headers must not contain empty strings'; + } + }); + } else { + throw 'Allow headers must be an array'; + } } } @@ -211,15 +277,22 @@ export class Config { return undefined; } var now = new Date(); - return new Date(now.getTime() + (this.emailVerifyTokenValidityDuration * 1000)); + return new Date( + now.getTime() + this.emailVerifyTokenValidityDuration * 1000 + ); } generatePasswordResetTokenExpiresAt() { - if (!this.passwordPolicy || !this.passwordPolicy.resetTokenValidityDuration) { + if ( + !this.passwordPolicy || + !this.passwordPolicy.resetTokenValidityDuration + ) { return undefined; } const now = new Date(); - return new Date(now.getTime() + (this.passwordPolicy.resetTokenValidityDuration * 1000)); + return new Date( + now.getTime() + this.passwordPolicy.resetTokenValidityDuration * 1000 + ); } generateSessionExpiresAt() { @@ -227,31 +300,49 @@ export class Config { return undefined; } var now = new Date(); - return new Date(now.getTime() + (this.sessionLength * 1000)); + return new Date(now.getTime() + this.sessionLength * 1000); } get invalidLinkURL() { - return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; + return ( + this.customPages.invalidLink || + `${this.publicServerURL}/apps/invalid_link.html` + ); } get invalidVerificationLinkURL() { - return this.customPages.invalidVerificationLink || `${this.publicServerURL}/apps/invalid_verification_link.html`; + return ( + this.customPages.invalidVerificationLink || + `${this.publicServerURL}/apps/invalid_verification_link.html` + ); } get linkSendSuccessURL() { - return this.customPages.linkSendSuccess || `${this.publicServerURL}/apps/link_send_success.html` + return ( + this.customPages.linkSendSuccess || + `${this.publicServerURL}/apps/link_send_success.html` + ); } get linkSendFailURL() { - return this.customPages.linkSendFail || `${this.publicServerURL}/apps/link_send_fail.html` + return ( + this.customPages.linkSendFail || + `${this.publicServerURL}/apps/link_send_fail.html` + ); } get verifyEmailSuccessURL() { - return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; + return ( + this.customPages.verifyEmailSuccess || + `${this.publicServerURL}/apps/verify_email_success.html` + ); } get choosePasswordURL() { - return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; + return ( + this.customPages.choosePassword || + `${this.publicServerURL}/apps/choose_password` + ); } get requestResetPasswordURL() { @@ -259,7 +350,10 @@ export class Config { } get passwordResetSuccessURL() { - return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; + return ( + this.customPages.passwordResetSuccess || + `${this.publicServerURL}/apps/password_reset_success.html` + ); } get parseFrameURL() { diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index 2638db65ca..da74c63bf1 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -13,7 +13,6 @@ var _adapter = Symbol(); import Config from '../Config'; export class AdaptableController { - constructor(adapter, appId, options) { this.options = options; this.appId = appId; @@ -34,7 +33,7 @@ export class AdaptableController { } expectedAdapterType() { - throw new Error("Subclasses should implement expectedAdapterType()"); + throw new Error('Subclasses should implement expectedAdapterType()'); } validateAdapter(adapter) { @@ -43,7 +42,7 @@ export class AdaptableController { static validateAdapter(adapter, self, ExpectedType) { if (!adapter) { - throw new Error(this.constructor.name + " requires an adapter"); + throw new Error(this.constructor.name + ' requires an adapter'); } const Type = ExpectedType || self.expectedAdapterType(); @@ -53,20 +52,27 @@ export class AdaptableController { } // Makes sure the prototype matches - const mismatches = Object.getOwnPropertyNames(Type.prototype).reduce((obj, key) => { - const adapterType = typeof adapter[key]; - const expectedType = typeof Type.prototype[key]; - if (adapterType !== expectedType) { - obj[key] = { - expected: expectedType, - actual: adapterType + const mismatches = Object.getOwnPropertyNames(Type.prototype).reduce( + (obj, key) => { + const adapterType = typeof adapter[key]; + const expectedType = typeof Type.prototype[key]; + if (adapterType !== expectedType) { + obj[key] = { + expected: expectedType, + actual: adapterType, + }; } - } - return obj; - }, {}); + return obj; + }, + {} + ); if (Object.keys(mismatches).length > 0) { - throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches); + throw new Error( + "Adapter prototype don't match expected prototype", + adapter, + mismatches + ); } } } diff --git a/src/Controllers/AnalyticsController.js b/src/Controllers/AnalyticsController.js index 74b43932d7..89aa48eda8 100644 --- a/src/Controllers/AnalyticsController.js +++ b/src/Controllers/AnalyticsController.js @@ -3,23 +3,29 @@ import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; export class AnalyticsController extends AdaptableController { appOpened(req) { - return Promise.resolve().then(() => { - return this.adapter.appOpened(req.body, req); - }).then((response) => { - return { response: response || {} }; - }).catch(() => { - return { response: {} }; - }); + return Promise.resolve() + .then(() => { + return this.adapter.appOpened(req.body, req); + }) + .then(response => { + return { response: response || {} }; + }) + .catch(() => { + return { response: {} }; + }); } trackEvent(req) { - return Promise.resolve().then(() => { - return this.adapter.trackEvent(req.params.eventName, req.body, req); - }).then((response) => { - return { response: response || {} }; - }).catch(() => { - return { response: {} }; - }); + return Promise.resolve() + .then(() => { + return this.adapter.trackEvent(req.params.eventName, req.body, req); + }) + .then(response => { + return { response: response || {} }; + }) + .catch(() => { + return { response: {} }; + }); } expectedAdapterType() { diff --git a/src/Controllers/CacheController.js b/src/Controllers/CacheController.js index 78e102e470..0c645c5236 100644 --- a/src/Controllers/CacheController.js +++ b/src/Controllers/CacheController.js @@ -1,5 +1,5 @@ import AdaptableController from './AdaptableController'; -import CacheAdapter from '../Adapters/Cache/CacheAdapter'; +import CacheAdapter from '../Adapters/Cache/CacheAdapter'; const KEY_SEPARATOR_CHAR = ':'; @@ -39,14 +39,13 @@ export class SubCache { } } - export class CacheController extends AdaptableController { - constructor(adapter, appId, options = {}) { super(adapter, appId, options); this.role = new SubCache('role', this); this.user = new SubCache('user', this); + this.graphQL = new SubCache('graphQL', this); } get(key) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index c1cdfdadca..5c3b8ab342 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1,24 +1,34 @@ -// A database adapter that works with data exported from the hosted +// @flow +// A database adapter that works with data exported from the hosted // Parse database. -import { Parse } from 'parse/node'; -import _ from 'lodash'; -import intersect from 'intersect'; -import deepcopy from 'deepcopy'; -import logger from '../logger'; -import * as SchemaController from './SchemaController'; +// @flow-disable-next +import { Parse } from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +// @flow-disable-next +import intersect from 'intersect'; +// @flow-disable-next +import deepcopy from 'deepcopy'; +import logger from '../logger'; +import * as SchemaController from './SchemaController'; +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import type { + QueryOptions, + FullQueryOptions, +} from '../Adapters/Storage/StorageAdapter'; function addWriteACL(query, acl) { const newQuery = _.cloneDeep(query); //Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and - newQuery._wperm = { "$in" : [null, ...acl]}; + newQuery._wperm = { $in: [null, ...acl] }; return newQuery; } function addReadACL(query, acl) { const newQuery = _.cloneDeep(query); //Can't be any existing '_rperm' query, we don't allow client queries on that, no need to $and - newQuery._rperm = {"$in": [null, "*", ...acl]}; + newQuery._rperm = { $in: [null, '*', ...acl] }; return newQuery; } @@ -40,15 +50,26 @@ const transformObjectACL = ({ ACL, ...result }) => { } } return result; -} +}; -const specialQuerykeys = ['$and', '$or', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count']; +const specialQuerykeys = [ + '$and', + '$or', + '$nor', + '_rperm', + '_wperm', + '_perishable_token', + '_email_verify_token', + '_email_verify_token_expires_at', + '_account_lockout_expires_at', + '_failed_login_count', +]; const isSpecialQueryKey = key => { return specialQuerykeys.indexOf(key) >= 0; -} +}; -const validateQuery = query => { +const validateQuery = (query: any): void => { if (query.ACL) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.'); } @@ -56,42 +77,11 @@ const validateQuery = query => { if (query.$or) { if (query.$or instanceof Array) { query.$or.forEach(validateQuery); - - /* In MongoDB, $or queries which are not alone at the top level of the - * query can not make efficient use of indexes due to a long standing - * bug known as SERVER-13732. - * - * This block restructures queries in which $or is not the sole top - * level element by moving all other top-level predicates inside every - * subdocument of the $or predicate, allowing MongoDB's query planner - * to make full use of the most relevant indexes. - * - * EG: {$or: [{a: 1}, {a: 2}], b: 2} - * Becomes: {$or: [{a: 1, b: 2}, {a: 2, b: 2}]} - * - * The only exceptions are $near and $nearSphere operators, which are - * constrained to only 1 operator per query. As a result, these ops - * remain at the top level - * - * https://jira.mongodb.org/browse/SERVER-13732 - * https://github.com/parse-community/parse-server/issues/3767 - */ - Object.keys(query).forEach(key => { - const noCollisions = !query.$or.some(subq => subq.hasOwnProperty(key)) - let hasNears = false - if (query[key] != null && typeof query[key] == 'object') { - hasNears = ('$near' in query[key] || '$nearSphere' in query[key]) - } - if (key != '$or' && noCollisions && !hasNears) { - query.$or.forEach(subquery => { - subquery[key] = query[key]; - }); - delete query[key]; - } - }); - query.$or.forEach(validateQuery); } else { - throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.'); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Bad $or format - use an array value.' + ); } } @@ -99,7 +89,21 @@ const validateQuery = query => { if (query.$and instanceof Array) { query.$and.forEach(validateQuery); } else { - throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.'); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Bad $and format - use an array value.' + ); + } + } + + if (query.$nor) { + if (query.$nor instanceof Array && query.$nor.length > 0) { + query.$nor.forEach(validateQuery); + } else { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Bad $nor format - use an array of at least 1 value.' + ); } } @@ -107,88 +111,110 @@ const validateQuery = query => { if (query && query[key] && query[key].$regex) { if (typeof query[key].$options === 'string') { if (!query[key].$options.match(/^[imxs]+$/)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `Bad $options value for query: ${query[key].$options}`); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Bad $options value for query: ${query[key].$options}` + ); } } } if (!isSpecialQueryKey(key) && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`); + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid key name: ${key}` + ); } }); -} +}; -function DatabaseController(adapter, schemaCache) { - this.adapter = adapter; - this.schemaCache = schemaCache; - // We don't want a mutable this.schema, because then you could have - // one request that uses different schemas for different parts of - // it. Instead, use loadSchema to get a schema. - this.schemaPromise = null; -} +// Filters out any data that shouldn't be on this REST-formatted object. +const filterSensitiveData = ( + isMaster: boolean, + aclGroup: any[], + auth: any, + operation: any, + schema: SchemaController.SchemaController, + className: string, + protectedFields: null | Array, + object: any +) => { + let userId = null; + if (auth && auth.user) userId = auth.user.id; + + // replace protectedFields when using pointer-permissions + const perms = schema.getClassLevelPermissions(className); + if (perms) { + const isReadOperation = ['get', 'find'].indexOf(operation) > -1; + + if (isReadOperation && perms.protectedFields) { + // extract protectedFields added with the pointer-permission prefix + const protectedFieldsPointerPerm = Object.keys(perms.protectedFields) + .filter(key => key.startsWith('userField:')) + .map(key => { + return { key: key.substring(10), value: perms.protectedFields[key] }; + }); -DatabaseController.prototype.collectionExists = function(className) { - return this.adapter.classExists(className); -}; + const newProtectedFields: Array[] = []; + let overrideProtectedFields = false; + + // check if the object grants the current user access based on the extracted fields + protectedFieldsPointerPerm.forEach(pointerPerm => { + let pointerPermIncludesUser = false; + const readUserFieldValue = object[pointerPerm.key]; + if (readUserFieldValue) { + if (Array.isArray(readUserFieldValue)) { + pointerPermIncludesUser = readUserFieldValue.some( + user => user.objectId && user.objectId === userId + ); + } else { + pointerPermIncludesUser = + readUserFieldValue.objectId && + readUserFieldValue.objectId === userId; + } + } -DatabaseController.prototype.purgeCollection = function(className) { - return this.loadSchema() - .then(schemaController => schemaController.getOneSchema(className)) - .then(schema => this.adapter.deleteObjectsByQuery(className, schema, {})); -}; + if (pointerPermIncludesUser) { + overrideProtectedFields = true; + newProtectedFields.push(pointerPerm.value); + } + }); -DatabaseController.prototype.validateClassName = function(className) { - if (!SchemaController.classNameIsValid(className)) { - return Promise.reject(new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className)); + // if at least one pointer-permission affected the current user + // intersect vs protectedFields from previous stage (@see addProtectedFields) + // Sets theory (intersections): A x (B x C) == (A x B) x C + if (overrideProtectedFields && protectedFields) { + newProtectedFields.push(protectedFields); + } + // intersect all sets of protectedFields + newProtectedFields.forEach(fields => { + if (fields) { + // if there're no protctedFields by other criteria ( id / role / auth) + // then we must intersect each set (per userField) + if (!protectedFields) { + protectedFields = fields; + } else { + protectedFields = protectedFields.filter(v => fields.includes(v)); + } + } + }); + } } - return Promise.resolve(); -}; -// Returns a promise for a schemaController. -DatabaseController.prototype.loadSchema = function(options = {clearCache: false}) { - if (!this.schemaPromise) { - this.schemaPromise = SchemaController.load(this.adapter, this.schemaCache, options); - this.schemaPromise.then(() => delete this.schemaPromise, - () => delete this.schemaPromise); - } - return this.schemaPromise; -}; + const isUserClass = className === '_User'; -// Returns a promise for the classname that is related to the given -// classname through the key. -// TODO: make this not in the DatabaseController interface -DatabaseController.prototype.redirectClassNameForKey = function(className, key) { - return this.loadSchema().then((schema) => { - var t = schema.getExpectedType(className, key); - if (t && t.type == 'Relation') { - return t.targetClass; - } else { - return className; - } - }); -}; + /* special treat for the user class: don't filter protectedFields if currently loggedin user is + the retrieved user */ + if (!(isUserClass && userId && object.objectId === userId)) { + protectedFields && protectedFields.forEach(k => delete object[k]); -// Uses the schema to validate the object (REST API format). -// Returns a promise that resolves to the new schema. -// This does not update this.schema, because in a situation like a -// batch request, that could confuse other users of the schema. -DatabaseController.prototype.validateObject = function(className, object, query, { acl }) { - let schema; - const isMaster = acl === undefined; - var aclGroup = acl || []; - return this.loadSchema().then(s => { - schema = s; - if (isMaster) { - return Promise.resolve(); - } - return this.canAddField(schema, className, object, aclGroup); - }).then(() => { - return schema.validateObject(className, object, query); - }); -}; + // fields not requested by client (excluded), + //but were needed to apply protecttedFields + perms.protectedFields && + perms.protectedFields.temporaryKeys && + perms.protectedFields.temporaryKeys.forEach(k => delete object[k]); + } -// Filters out any data that shouldn't be on this REST-formatted object. -const filterSensitiveData = (isMaster, aclGroup, className, object) => { - if (className !== '_User') { + if (!isUserClass) { return object; } @@ -208,14 +234,17 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => { delete object._failed_login_count; delete object._account_lockout_expires_at; delete object._password_changed_at; + delete object._password_history; - if ((aclGroup.indexOf(object.objectId) > -1)) { + if (aclGroup.indexOf(object.objectId) > -1) { return object; } delete object.authData; return object; }; +import type { LoadSchemaOptions } from './types'; + // Runs an update on the database. // Returns a promise for an object with the new values for field // modifications that don't know their results ahead of time, like @@ -224,88 +253,20 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => { // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count', '_perishable_token_expires_at', '_password_changed_at', '_password_history']; +const specialKeysForUpdate = [ + '_hashed_password', + '_perishable_token', + '_email_verify_token', + '_email_verify_token_expires_at', + '_account_lockout_expires_at', + '_failed_login_count', + '_perishable_token_expires_at', + '_password_changed_at', + '_password_history', +]; const isSpecialUpdateKey = key => { return specialKeysForUpdate.indexOf(key) >= 0; -} - -DatabaseController.prototype.update = function(className, query, update, { - acl, - many, - upsert, -} = {}, skipSanitization = false) { - const originalQuery = query; - const originalUpdate = update; - // Make a copy of the object, so we don't mutate the incoming data. - update = deepcopy(update); - var relationUpdates = []; - var isMaster = acl === undefined; - var aclGroup = acl || []; - return this.loadSchema() - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'update')) - .then(() => { - relationUpdates = this.collectRelationUpdates(className, originalQuery.objectId, update); - if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, 'update', query, aclGroup); - } - if (!query) { - return Promise.resolve(); - } - if (acl) { - query = addWriteACL(query, acl); - } - validateQuery(query); - return schemaController.getOneSchema(className, true) - .catch(error => { - // If the schema doesn't exist, pretend it exists with no fields. This behavior - // will likely need revisiting. - if (error === undefined) { - return { fields: {} }; - } - throw error; - }) - .then(schema => { - Object.keys(update).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}`); - } - fieldName = fieldName.split('.')[0]; - if (!SchemaController.fieldNameIsValid(fieldName) && !isSpecialUpdateKey(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}`); - } - }); - for (const updateOperation in update) { - if (Object.keys(updateOperation).some(innerKey => innerKey.includes('$') || innerKey.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); - } - } - update = transformObjectACL(update); - transformAuthData(className, update, schema); - if (many) { - return this.adapter.updateObjectsByQuery(className, schema, query, update); - } else if (upsert) { - return this.adapter.upsertOneObject(className, schema, query, update); - } else { - return this.adapter.findOneAndUpdate(className, schema, query, update) - } - }); - }) - .then(result => { - if (!result) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.')); - } - return this.handleRelationUpdates(className, originalQuery.objectId, update, relationUpdates).then(() => { - return result; - }); - }).then((result) => { - if (skipSanitization) { - return Promise.resolve(result); - } - return sanitizeDatabaseResult(originalUpdate, result); - }); - }); }; function expandResultOnKeyPath(object, key, value) { @@ -316,12 +277,16 @@ function expandResultOnKeyPath(object, key, value) { const path = key.split('.'); const firstKey = path[0]; const nextPath = path.slice(1).join('.'); - object[firstKey] = expandResultOnKeyPath(object[firstKey] || {}, nextPath, value[firstKey]); + object[firstKey] = expandResultOnKeyPath( + object[firstKey] || {}, + nextPath, + value[firstKey] + ); delete object[key]; return object; } -function sanitizeDatabaseResult(originalObject, result) { +function sanitizeDatabaseResult(originalObject, result): Promise { const response = {}; if (!result) { return Promise.resolve(response); @@ -329,8 +294,12 @@ function sanitizeDatabaseResult(originalObject, result) { Object.keys(originalObject).forEach(key => { const keyUpdate = originalObject[key]; // determine if that was an op - if (keyUpdate && typeof keyUpdate === 'object' && keyUpdate.__op - && ['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1) { + if ( + keyUpdate && + typeof keyUpdate === 'object' && + keyUpdate.__op && + ['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1 + ) { // only valid ops that produce an actionable result // the op may have happend on a keypath expandResultOnKeyPath(response, key, result); @@ -339,121 +308,562 @@ function sanitizeDatabaseResult(originalObject, result) { return Promise.resolve(response); } -// Collect all relation-updating operations from a REST-format update. -// Returns a list of all relation updates to perform -// This mutates update. -DatabaseController.prototype.collectRelationUpdates = function(className, objectId, update) { - var ops = []; - var deleteMe = []; - objectId = update.objectId || objectId; +function joinTableName(className, key) { + return `_Join:${key}:${className}`; +} - var process = (op, key) => { - if (!op) { - return; +const flattenUpdateOperatorsForCreate = object => { + for (const key in object) { + if (object[key] && object[key].__op) { + switch (object[key].__op) { + case 'Increment': + if (typeof object[key].amount !== 'number') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to add must be an array' + ); + } + object[key] = object[key].amount; + break; + case 'Add': + if (!(object[key].objects instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to add must be an array' + ); + } + object[key] = object[key].objects; + break; + case 'AddUnique': + if (!(object[key].objects instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to add must be an array' + ); + } + object[key] = object[key].objects; + break; + case 'Remove': + if (!(object[key].objects instanceof Array)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'objects to add must be an array' + ); + } + object[key] = []; + break; + case 'Delete': + delete object[key]; + break; + default: + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + `The ${object[key].__op} operator is not supported yet.` + ); + } } - if (op.__op == 'AddRelation') { - ops.push({key, op}); - deleteMe.push(key); + } +}; + +const transformAuthData = (className, object, schema) => { + if (object.authData && className === '_User') { + Object.keys(object.authData).forEach(provider => { + const providerData = object.authData[provider]; + const fieldName = `_auth_data_${provider}`; + if (providerData == null) { + object[fieldName] = { + __op: 'Delete', + }; + } else { + object[fieldName] = providerData; + schema.fields[fieldName] = { type: 'Object' }; + } + }); + delete object.authData; + } +}; +// Transforms a Database format ACL to a REST API format ACL +const untransformObjectACL = ({ _rperm, _wperm, ...output }) => { + if (_rperm || _wperm) { + output.ACL = {}; + + (_rperm || []).forEach(entry => { + if (!output.ACL[entry]) { + output.ACL[entry] = { read: true }; + } else { + output.ACL[entry]['read'] = true; + } + }); + + (_wperm || []).forEach(entry => { + if (!output.ACL[entry]) { + output.ACL[entry] = { write: true }; + } else { + output.ACL[entry]['write'] = true; + } + }); + } + return output; +}; + +/** + * When querying, the fieldName may be compound, extract the root fieldName + * `temperature.celsius` becomes `temperature` + * @param {string} fieldName that may be a compound field name + * @returns {string} the root name of the field + */ +const getRootFieldName = (fieldName: string): string => { + return fieldName.split('.')[0]; +}; + +const relationSchema = { + fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } }, +}; + +class DatabaseController { + adapter: StorageAdapter; + schemaCache: any; + schemaPromise: ?Promise; + _transactionalSession: ?any; + + constructor(adapter: StorageAdapter, schemaCache: any) { + this.adapter = adapter; + this.schemaCache = schemaCache; + // We don't want a mutable this.schema, because then you could have + // one request that uses different schemas for different parts of + // it. Instead, use loadSchema to get a schema. + this.schemaPromise = null; + this._transactionalSession = null; + } + + collectionExists(className: string): Promise { + return this.adapter.classExists(className); + } + + purgeCollection(className: string): Promise { + return this.loadSchema() + .then(schemaController => schemaController.getOneSchema(className)) + .then(schema => this.adapter.deleteObjectsByQuery(className, schema, {})); + } + + validateClassName(className: string): Promise { + if (!SchemaController.classNameIsValid(className)) { + return Promise.reject( + new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + 'invalid className: ' + className + ) + ); } + return Promise.resolve(); + } - if (op.__op == 'RemoveRelation') { - ops.push({key, op}); - deleteMe.push(key); + // Returns a promise for a schemaController. + loadSchema( + options: LoadSchemaOptions = { clearCache: false } + ): Promise { + if (this.schemaPromise != null) { + return this.schemaPromise; } + this.schemaPromise = SchemaController.load( + this.adapter, + this.schemaCache, + options + ); + this.schemaPromise.then( + () => delete this.schemaPromise, + () => delete this.schemaPromise + ); + return this.loadSchema(options); + } + + loadSchemaIfNeeded( + schemaController: SchemaController.SchemaController, + options: LoadSchemaOptions = { clearCache: false } + ): Promise { + return schemaController + ? Promise.resolve(schemaController) + : this.loadSchema(options); + } - if (op.__op == 'Batch') { - for (var x of op.ops) { - process(x, key); + // Returns a promise for the classname that is related to the given + // classname through the key. + // TODO: make this not in the DatabaseController interface + redirectClassNameForKey(className: string, key: string): Promise { + return this.loadSchema().then(schema => { + var t = schema.getExpectedType(className, key); + if (t != null && typeof t !== 'string' && t.type === 'Relation') { + return t.targetClass; } - } - }; + return className; + }); + } - for (const key in update) { - process(update[key], key); + // Uses the schema to validate the object (REST API format). + // Returns a promise that resolves to the new schema. + // This does not update this.schema, because in a situation like a + // batch request, that could confuse other users of the schema. + validateObject( + className: string, + object: any, + query: any, + runOptions: QueryOptions + ): Promise { + let schema; + const acl = runOptions.acl; + const isMaster = acl === undefined; + var aclGroup: string[] = acl || []; + return this.loadSchema() + .then(s => { + schema = s; + if (isMaster) { + return Promise.resolve(); + } + return this.canAddField( + schema, + className, + object, + aclGroup, + runOptions + ); + }) + .then(() => { + return schema.validateObject(className, object, query); + }); } - for (const key of deleteMe) { - delete update[key]; + + update( + className: string, + query: any, + update: any, + { acl, many, upsert, addsField }: FullQueryOptions = {}, + skipSanitization: boolean = false, + validateOnly: boolean = false, + validSchemaController: SchemaController.SchemaController + ): Promise { + const originalQuery = query; + const originalUpdate = update; + // Make a copy of the object, so we don't mutate the incoming data. + update = deepcopy(update); + var relationUpdates = []; + var isMaster = acl === undefined; + var aclGroup = acl || []; + + return this.loadSchemaIfNeeded(validSchemaController).then( + schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'update') + ) + .then(() => { + relationUpdates = this.collectRelationUpdates( + className, + originalQuery.objectId, + update + ); + if (!isMaster) { + query = this.addPointerPermissions( + schemaController, + className, + 'update', + query, + aclGroup + ); + + if (addsField) { + query = { + $and: [ + query, + this.addPointerPermissions( + schemaController, + className, + 'addField', + query, + aclGroup + ), + ], + }; + } + } + if (!query) { + return Promise.resolve(); + } + if (acl) { + query = addWriteACL(query, acl); + } + validateQuery(query); + return schemaController + .getOneSchema(className, true) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behavior + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; + } + throw error; + }) + .then(schema => { + Object.keys(update).forEach(fieldName => { + if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name for update: ${fieldName}` + ); + } + const rootFieldName = getRootFieldName(fieldName); + if ( + !SchemaController.fieldNameIsValid(rootFieldName) && + !isSpecialUpdateKey(rootFieldName) + ) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name for update: ${fieldName}` + ); + } + }); + for (const updateOperation in update) { + if ( + update[updateOperation] && + typeof update[updateOperation] === 'object' && + Object.keys(update[updateOperation]).some( + innerKey => + innerKey.includes('$') || innerKey.includes('.') + ) + ) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); + } + } + update = transformObjectACL(update); + transformAuthData(className, update, schema); + if (validateOnly) { + return this.adapter + .find(className, schema, query, {}) + .then(result => { + if (!result || !result.length) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } + return {}; + }); + } + if (many) { + return this.adapter.updateObjectsByQuery( + className, + schema, + query, + update, + this._transactionalSession + ); + } else if (upsert) { + return this.adapter.upsertOneObject( + className, + schema, + query, + update, + this._transactionalSession + ); + } else { + return this.adapter.findOneAndUpdate( + className, + schema, + query, + update, + this._transactionalSession + ); + } + }); + }) + .then((result: any) => { + if (!result) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } + if (validateOnly) { + return result; + } + return this.handleRelationUpdates( + className, + originalQuery.objectId, + update, + relationUpdates + ).then(() => { + return result; + }); + }) + .then(result => { + if (skipSanitization) { + return Promise.resolve(result); + } + return sanitizeDatabaseResult(originalUpdate, result); + }); + } + ); } - return ops; -} -// Processes relation-updating operations from a REST-format update. -// Returns a promise that resolves when all updates have been performed -DatabaseController.prototype.handleRelationUpdates = function(className, objectId, update, ops) { - var pending = []; - objectId = update.objectId || objectId; - ops.forEach(({key, op}) => { - if (!op) { - return; - } - if (op.__op == 'AddRelation') { - for (const object of op.objects) { - pending.push(this.addRelation(key, className, - objectId, - object.objectId)); + // Collect all relation-updating operations from a REST-format update. + // Returns a list of all relation updates to perform + // This mutates update. + collectRelationUpdates(className: string, objectId: ?string, update: any) { + var ops = []; + var deleteMe = []; + objectId = update.objectId || objectId; + + var process = (op, key) => { + if (!op) { + return; + } + if (op.__op == 'AddRelation') { + ops.push({ key, op }); + deleteMe.push(key); } - } - if (op.__op == 'RemoveRelation') { - for (const object of op.objects) { - pending.push(this.removeRelation(key, className, - objectId, - object.objectId)); + if (op.__op == 'RemoveRelation') { + ops.push({ key, op }); + deleteMe.push(key); } - } - }); - return Promise.all(pending); -}; + if (op.__op == 'Batch') { + for (var x of op.ops) { + process(x, key); + } + } + }; -// Adds a relation. -// Returns a promise that resolves successfully iff the add was successful. -const relationSchema = { fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } } }; -DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, toId) { - const doc = { - relatedId: toId, - owningId : fromId - }; - return this.adapter.upsertOneObject(`_Join:${key}:${fromClassName}`, relationSchema, doc, doc); -}; + for (const key in update) { + process(update[key], key); + } + for (const key of deleteMe) { + delete update[key]; + } + return ops; + } -// Removes a relation. -// Returns a promise that resolves successfully iff the remove was -// successful. -DatabaseController.prototype.removeRelation = function(key, fromClassName, fromId, toId) { - var doc = { - relatedId: toId, - owningId: fromId - }; - return this.adapter.deleteObjectsByQuery(`_Join:${key}:${fromClassName}`, relationSchema, doc) - .catch(error => { - // We don't care if they try to delete a non-existent relation. - if (error.code == Parse.Error.OBJECT_NOT_FOUND) { + // Processes relation-updating operations from a REST-format update. + // Returns a promise that resolves when all updates have been performed + handleRelationUpdates( + className: string, + objectId: string, + update: any, + ops: any + ) { + var pending = []; + objectId = update.objectId || objectId; + ops.forEach(({ key, op }) => { + if (!op) { return; } - throw error; + if (op.__op == 'AddRelation') { + for (const object of op.objects) { + pending.push( + this.addRelation(key, className, objectId, object.objectId) + ); + } + } + + if (op.__op == 'RemoveRelation') { + for (const object of op.objects) { + pending.push( + this.removeRelation(key, className, objectId, object.objectId) + ); + } + } }); -}; -// Removes objects matches this query from the database. -// Returns a promise that resolves successfully iff the object was -// deleted. -// Options: -// acl: a list of strings. If the object to be updated has an ACL, -// one of the provided strings must provide the caller with -// write permissions. -DatabaseController.prototype.destroy = function(className, query, { acl } = {}) { - const isMaster = acl === undefined; - const aclGroup = acl || []; - - return this.loadSchema() - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'delete')) - .then(() => { + return Promise.all(pending); + } + + // Adds a relation. + // Returns a promise that resolves successfully iff the add was successful. + addRelation( + key: string, + fromClassName: string, + fromId: string, + toId: string + ) { + const doc = { + relatedId: toId, + owningId: fromId, + }; + return this.adapter.upsertOneObject( + `_Join:${key}:${fromClassName}`, + relationSchema, + doc, + doc, + this._transactionalSession + ); + } + + // Removes a relation. + // Returns a promise that resolves successfully iff the remove was + // successful. + removeRelation( + key: string, + fromClassName: string, + fromId: string, + toId: string + ) { + var doc = { + relatedId: toId, + owningId: fromId, + }; + return this.adapter + .deleteObjectsByQuery( + `_Join:${key}:${fromClassName}`, + relationSchema, + doc, + this._transactionalSession + ) + .catch(error => { + // We don't care if they try to delete a non-existent relation. + if (error.code == Parse.Error.OBJECT_NOT_FOUND) { + return; + } + throw error; + }); + } + + // Removes objects matches this query from the database. + // Returns a promise that resolves successfully iff the object was + // deleted. + // Options: + // acl: a list of strings. If the object to be updated has an ACL, + // one of the provided strings must provide the caller with + // write permissions. + destroy( + className: string, + query: any, + { acl }: QueryOptions = {}, + validSchemaController: SchemaController.SchemaController + ): Promise { + const isMaster = acl === undefined; + const aclGroup = acl || []; + + return this.loadSchemaIfNeeded(validSchemaController).then( + schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'delete') + ).then(() => { if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, 'delete', query, aclGroup); + query = this.addPointerPermissions( + schemaController, + className, + 'delete', + query, + aclGroup + ); if (!query) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); } } // delete by query @@ -461,7 +871,8 @@ DatabaseController.prototype.destroy = function(className, query, { acl } = {}) query = addWriteACL(query, acl); } validateQuery(query); - return schemaController.getOneSchema(className) + return schemaController + .getOneSchema(className) .catch(error => { // If the schema doesn't exist, pretend it exists with no fields. This behavior // will likely need revisiting. @@ -470,550 +881,952 @@ DatabaseController.prototype.destroy = function(className, query, { acl } = {}) } throw error; }) - .then(parseFormatSchema => this.adapter.deleteObjectsByQuery(className, parseFormatSchema, query)) + .then(parseFormatSchema => + this.adapter.deleteObjectsByQuery( + className, + parseFormatSchema, + query, + this._transactionalSession + ) + ) .catch(error => { // When deleting sessions while changing passwords, don't throw an error if they don't have any sessions. - if (className === "_Session" && error.code === Parse.Error.OBJECT_NOT_FOUND) { + if ( + className === '_Session' && + error.code === Parse.Error.OBJECT_NOT_FOUND + ) { return Promise.resolve({}); } throw error; }); }); - }); -}; - -const flattenUpdateOperatorsForCreate = object => { - for (const key in object) { - if (object[key] && object[key].__op) { - switch (object[key].__op) { - case 'Increment': - if (typeof object[key].amount !== 'number') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - object[key] = object[key].amount; - break; - case 'Add': - if (!(object[key].objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - object[key] = object[key].objects; - break; - case 'AddUnique': - if (!(object[key].objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - object[key] = object[key].objects; - break; - case 'Remove': - if (!(object[key].objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - object[key] = [] - break; - case 'Delete': - delete object[key]; - break; - default: - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, `The ${object[key].__op} operator is not supported yet.`); } - } + ); } -} -const transformAuthData = (className, object, schema) => { - if (object.authData && className === '_User') { - Object.keys(object.authData).forEach(provider => { - const providerData = object.authData[provider]; - const fieldName = `_auth_data_${provider}`; - if (providerData == null) { - object[fieldName] = { - __op: 'Delete' - } - } else { - object[fieldName] = providerData; - schema.fields[fieldName] = { type: 'Object' } - } - }); - delete object.authData; + // Inserts an object into the database. + // Returns a promise that resolves successfully iff the object saved. + create( + className: string, + object: any, + { acl }: QueryOptions = {}, + validateOnly: boolean = false, + validSchemaController: SchemaController.SchemaController + ): Promise { + // Make a copy of the object, so we don't mutate the incoming data. + const originalObject = object; + object = transformObjectACL(object); + + object.createdAt = { iso: object.createdAt, __type: 'Date' }; + object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; + + var isMaster = acl === undefined; + var aclGroup = acl || []; + const relationUpdates = this.collectRelationUpdates( + className, + null, + object + ); + + return this.validateClassName(className) + .then(() => this.loadSchemaIfNeeded(validSchemaController)) + .then(schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'create') + ) + .then(() => schemaController.enforceClassExists(className)) + .then(() => schemaController.getOneSchema(className, true)) + .then(schema => { + transformAuthData(className, object, schema); + flattenUpdateOperatorsForCreate(object); + if (validateOnly) { + return {}; + } + return this.adapter.createObject( + className, + SchemaController.convertSchemaToAdapterSchema(schema), + object, + this._transactionalSession + ); + }) + .then(result => { + if (validateOnly) { + return originalObject; + } + return this.handleRelationUpdates( + className, + object.objectId, + object, + relationUpdates + ).then(() => { + return sanitizeDatabaseResult(originalObject, result.ops[0]); + }); + }); + }); } -} -// Inserts an object into the database. -// Returns a promise that resolves successfully iff the object saved. -DatabaseController.prototype.create = function(className, object, { acl } = {}) { - // Make a copy of the object, so we don't mutate the incoming data. - const originalObject = object; - object = transformObjectACL(object); - - object.createdAt = { iso: object.createdAt, __type: 'Date' }; - object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; - - var isMaster = acl === undefined; - var aclGroup = acl || []; - const relationUpdates = this.collectRelationUpdates(className, null, object); - return this.validateClassName(className) - .then(() => this.loadSchema()) - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'create')) - .then(() => schemaController.enforceClassExists(className)) - .then(() => schemaController.reloadData()) - .then(() => schemaController.getOneSchema(className, true)) - .then(schema => { - transformAuthData(className, object, schema); - flattenUpdateOperatorsForCreate(object); - return this.adapter.createObject(className, SchemaController.convertSchemaToAdapterSchema(schema), object); - }) - .then(result => { - return this.handleRelationUpdates(className, null, object, relationUpdates).then(() => { - return sanitizeDatabaseResult(originalObject, result.ops[0]) - }); - }); - }) -}; + canAddField( + schema: SchemaController.SchemaController, + className: string, + object: any, + aclGroup: string[], + runOptions: QueryOptions + ): Promise { + const classSchema = schema.schemaData[className]; + if (!classSchema) { + return Promise.resolve(); + } + const fields = Object.keys(object); + const schemaFields = Object.keys(classSchema.fields); + const newKeys = fields.filter(field => { + // Skip fields that are unset + if ( + object[field] && + object[field].__op && + object[field].__op === 'Delete' + ) { + return false; + } + return schemaFields.indexOf(field) < 0; + }); + if (newKeys.length > 0) { + // adds a marker that new field is being adding during update + runOptions.addsField = true; -DatabaseController.prototype.canAddField = function(schema, className, object, aclGroup) { - const classSchema = schema.data[className]; - if (!classSchema) { + const action = runOptions.action; + return schema.validatePermission(className, aclGroup, 'addField', action); + } return Promise.resolve(); } - const fields = Object.keys(object); - const schemaFields = Object.keys(classSchema); - const newKeys = fields.filter((field) => { - return schemaFields.indexOf(field) < 0; - }) - if (newKeys.length > 0) { - return schema.validatePermission(className, aclGroup, 'addField'); - } - return Promise.resolve(); -} -// Won't delete collections in the system namespace -// Returns a promise. -DatabaseController.prototype.deleteEverything = function() { - this.schemaPromise = null; - return Promise.all([ - this.adapter.deleteAllClasses(), - this.schemaCache.clear() - ]); -}; - -// Returns a promise for a list of related ids given an owning id. -// className here is the owning className. -DatabaseController.prototype.relatedIds = function(className, key, owningId) { - return this.adapter.find(joinTableName(className, key), relationSchema, { owningId }, {}) - .then(results => results.map(result => result.relatedId)); -}; + // Won't delete collections in the system namespace + /** + * Delete all classes and clears the schema cache + * + * @param {boolean} fast set to true if it's ok to just delete rows and not indexes + * @returns {Promise} when the deletions completes + */ + deleteEverything(fast: boolean = false): Promise { + this.schemaPromise = null; + return Promise.all([ + this.adapter.deleteAllClasses(fast), + this.schemaCache.clear(), + ]); + } -// Returns a promise for a list of owning ids given some related ids. -// className here is the owning className. -DatabaseController.prototype.owningIds = function(className, key, relatedIds) { - return this.adapter.find(joinTableName(className, key), relationSchema, { relatedId: { '$in': relatedIds } }, {}) - .then(results => results.map(result => result.owningId)); -}; + // Returns a promise for a list of related ids given an owning id. + // className here is the owning className. + relatedIds( + className: string, + key: string, + owningId: string, + queryOptions: QueryOptions + ): Promise> { + const { skip, limit, sort } = queryOptions; + const findOptions = {}; + if (sort && sort.createdAt && this.adapter.canSortOnJoinTables) { + findOptions.sort = { _id: sort.createdAt }; + findOptions.limit = limit; + findOptions.skip = skip; + queryOptions.skip = 0; + } + return this.adapter + .find( + joinTableName(className, key), + relationSchema, + { owningId }, + findOptions + ) + .then(results => results.map(result => result.relatedId)); + } -// Modifies query so that it no longer has $in on relation fields, or -// equal-to-pointer constraints on relation fields. -// Returns a promise that resolves when query is mutated -DatabaseController.prototype.reduceInRelation = function(className, query, schema) { - - // Search for an in-relation or equal-to-relation - // Make it sequential for now, not sure of paralleization side effects - if (query['$or']) { - const ors = query['$or']; - return Promise.all(ors.map((aQuery, index) => { - return this.reduceInRelation(className, aQuery, schema).then((aQuery) => { - query['$or'][index] = aQuery; - }); - })).then(() => { - return Promise.resolve(query); - }); + // Returns a promise for a list of owning ids given some related ids. + // className here is the owning className. + owningIds( + className: string, + key: string, + relatedIds: string[] + ): Promise { + return this.adapter + .find( + joinTableName(className, key), + relationSchema, + { relatedId: { $in: relatedIds } }, + {} + ) + .then(results => results.map(result => result.owningId)); } - const promises = Object.keys(query).map((key) => { - const t = schema.getExpectedType(className, key); - if (!t || t.type !== 'Relation') { - return Promise.resolve(query); - } - let queries = null; - if (query[key] && (query[key]['$in'] || query[key]['$ne'] || query[key]['$nin'] || query[key].__type == 'Pointer')) { - // Build the list of queries - queries = Object.keys(query[key]).map((constraintKey) => { - let relatedIds; - let isNegation = false; - if (constraintKey === 'objectId') { - relatedIds = [query[key].objectId]; - } else if (constraintKey == '$in') { - relatedIds = query[key]['$in'].map(r => r.objectId); - } else if (constraintKey == '$nin') { - isNegation = true; - relatedIds = query[key]['$nin'].map(r => r.objectId); - } else if (constraintKey == '$ne') { - isNegation = true; - relatedIds = [query[key]['$ne'].objectId]; - } else { - return; - } - return { - isNegation, - relatedIds - } + // Modifies query so that it no longer has $in on relation fields, or + // equal-to-pointer constraints on relation fields. + // Returns a promise that resolves when query is mutated + reduceInRelation(className: string, query: any, schema: any): Promise { + // Search for an in-relation or equal-to-relation + // Make it sequential for now, not sure of paralleization side effects + if (query['$or']) { + const ors = query['$or']; + return Promise.all( + ors.map((aQuery, index) => { + return this.reduceInRelation(className, aQuery, schema).then( + aQuery => { + query['$or'][index] = aQuery; + } + ); + }) + ).then(() => { + return Promise.resolve(query); }); - } else { - queries = [{isNegation: false, relatedIds: []}]; } - // remove the current queryKey as we don,t need it anymore - delete query[key]; - // execute each query independently to build the list of - // $in / $nin - const promises = queries.map((q) => { - if (!q) { - return Promise.resolve(); + const promises = Object.keys(query).map(key => { + const t = schema.getExpectedType(className, key); + if (!t || t.type !== 'Relation') { + return Promise.resolve(query); } - return this.owningIds(className, key, q.relatedIds).then((ids) => { - if (q.isNegation) { - this.addNotInObjectIdsIds(ids, query); - } else { - this.addInObjectIdsIds(ids, query); + let queries: ?(any[]) = null; + if ( + query[key] && + (query[key]['$in'] || + query[key]['$ne'] || + query[key]['$nin'] || + query[key].__type == 'Pointer') + ) { + // Build the list of queries + queries = Object.keys(query[key]).map(constraintKey => { + let relatedIds; + let isNegation = false; + if (constraintKey === 'objectId') { + relatedIds = [query[key].objectId]; + } else if (constraintKey == '$in') { + relatedIds = query[key]['$in'].map(r => r.objectId); + } else if (constraintKey == '$nin') { + isNegation = true; + relatedIds = query[key]['$nin'].map(r => r.objectId); + } else if (constraintKey == '$ne') { + isNegation = true; + relatedIds = [query[key]['$ne'].objectId]; + } else { + return; + } + return { + isNegation, + relatedIds, + }; + }); + } else { + queries = [{ isNegation: false, relatedIds: [] }]; + } + + // remove the current queryKey as we don,t need it anymore + delete query[key]; + // execute each query independently to build the list of + // $in / $nin + const promises = queries.map(q => { + if (!q) { + return Promise.resolve(); } + return this.owningIds(className, key, q.relatedIds).then(ids => { + if (q.isNegation) { + this.addNotInObjectIdsIds(ids, query); + } else { + this.addInObjectIdsIds(ids, query); + } + return Promise.resolve(); + }); + }); + + return Promise.all(promises).then(() => { return Promise.resolve(); }); }); return Promise.all(promises).then(() => { - return Promise.resolve(); - }) - - }) + return Promise.resolve(query); + }); + } - return Promise.all(promises).then(() => { - return Promise.resolve(query); - }) -}; + // Modifies query so that it no longer has $relatedTo + // Returns a promise that resolves when query is mutated + reduceRelationKeys( + className: string, + query: any, + queryOptions: any + ): ?Promise { + if (query['$or']) { + return Promise.all( + query['$or'].map(aQuery => { + return this.reduceRelationKeys(className, aQuery, queryOptions); + }) + ); + } -// Modifies query so that it no longer has $relatedTo -// Returns a promise that resolves when query is mutated -DatabaseController.prototype.reduceRelationKeys = function(className, query) { - - if (query['$or']) { - return Promise.all(query['$or'].map((aQuery) => { - return this.reduceRelationKeys(className, aQuery); - })); - } - - var relatedTo = query['$relatedTo']; - if (relatedTo) { - 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); - }); + var relatedTo = query['$relatedTo']; + if (relatedTo) { + return this.relatedIds( + relatedTo.object.className, + relatedTo.key, + relatedTo.object.objectId, + queryOptions + ) + .then(ids => { + delete query['$relatedTo']; + this.addInObjectIdsIds(ids, query); + return this.reduceRelationKeys(className, query, queryOptions); + }) + .then(() => {}); + } } -}; - -DatabaseController.prototype.addInObjectIdsIds = function(ids = null, query) { - const idsFromString = typeof query.objectId === 'string' ? [query.objectId] : null; - const idsFromEq = query.objectId && query.objectId['$eq'] ? [query.objectId['$eq']] : null; - const idsFromIn = query.objectId && query.objectId['$in'] ? query.objectId['$in'] : null; - const allIds = [idsFromString, idsFromEq, idsFromIn, ids].filter(list => list !== null); - const totalLength = allIds.reduce((memo, list) => memo + list.length, 0); + addInObjectIdsIds(ids: ?Array = null, query: any) { + const idsFromString: ?Array = + typeof query.objectId === 'string' ? [query.objectId] : null; + const idsFromEq: ?Array = + query.objectId && query.objectId['$eq'] ? [query.objectId['$eq']] : null; + const idsFromIn: ?Array = + query.objectId && query.objectId['$in'] ? query.objectId['$in'] : null; + + // @flow-disable-next + const allIds: Array> = [ + idsFromString, + idsFromEq, + idsFromIn, + ids, + ].filter(list => list !== null); + const totalLength = allIds.reduce((memo, list) => memo + list.length, 0); + + let idsIntersection = []; + if (totalLength > 125) { + idsIntersection = intersect.big(allIds); + } else { + idsIntersection = intersect(allIds); + } - let idsIntersection = []; - if (totalLength > 125) { - idsIntersection = intersect.big(allIds); - } else { - idsIntersection = intersect(allIds); - } + // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. + if (!('objectId' in query)) { + query.objectId = { + $in: undefined, + }; + } else if (typeof query.objectId === 'string') { + query.objectId = { + $in: undefined, + $eq: query.objectId, + }; + } + query.objectId['$in'] = idsIntersection; - // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. - if (!('objectId' in query)) { - query.objectId = {}; - } else if (typeof query.objectId === 'string') { - query.objectId = { - $eq: query.objectId - }; + return query; } - query.objectId['$in'] = idsIntersection; - return query; -} + addNotInObjectIdsIds(ids: string[] = [], query: any) { + const idsFromNin = + query.objectId && query.objectId['$nin'] ? query.objectId['$nin'] : []; + let allIds = [...idsFromNin, ...ids].filter(list => list !== null); -DatabaseController.prototype.addNotInObjectIdsIds = function(ids = [], query) { - const idsFromNin = query.objectId && query.objectId['$nin'] ? query.objectId['$nin'] : []; - let allIds = [...idsFromNin,...ids].filter(list => list !== null); + // make a set and spread to remove duplicates + allIds = [...new Set(allIds)]; - // make a set and spread to remove duplicates - allIds = [...new Set(allIds)]; + // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. + if (!('objectId' in query)) { + query.objectId = { + $nin: undefined, + }; + } else if (typeof query.objectId === 'string') { + query.objectId = { + $nin: undefined, + $eq: query.objectId, + }; + } - // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. - if (!('objectId' in query)) { - query.objectId = {}; - } else if (typeof query.objectId === 'string') { - query.objectId = { - $eq: query.objectId - }; + query.objectId['$nin'] = allIds; + return query; } - query.objectId['$nin'] = allIds; - return query; -} - -// Runs a query on the database. -// Returns a promise that resolves to a list of items. -// Options: -// skip number of results to skip. -// limit limit to this number of results. -// sort an object where keys are the fields to sort by. -// the value is +1 for ascending, -1 for descending. -// count run a count instead of returning results. -// acl restrict this operation with an ACL for the provided array -// of user objectIds and roles. acl: null means no user. -// when this field is not present, don't do anything regarding ACLs. -// TODO: make userIds not needed here. The db adapter shouldn't know -// anything about users, ideally. Then, improve the format of the ACL -// arg to work like the others. -DatabaseController.prototype.find = function(className, query, { - skip, - limit, - acl, - sort = {}, - count, - keys, - op, - readPreference -} = {}) { - const isMaster = acl === undefined; - const aclGroup = acl || []; - op = op || (typeof query.objectId == 'string' && Object.keys(query).length === 1 ? 'get' : 'find'); - // Count operation if counting - op = (count === true ? 'count' : op); - - let classExists = true; - return this.loadSchema() - .then(schemaController => { - //Allow volatile classes if querying with Master (for _PushStatus) - //TODO: Move volatile classes concept into mongo adapter, postgres adapter shouldn't care - //that api.parse.com breaks when _PushStatus exists in mongo. - return schemaController.getOneSchema(className, isMaster) - .catch(error => { - // Behavior for non-existent classes is kinda weird on Parse.com. Probably doesn't matter too much. - // For now, pretend the class exists but has no objects, - if (error === undefined) { - classExists = false; - return { fields: {} }; - } - throw error; - }) - .then(schema => { - // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, - // so duplicate that behavior here. If both are specified, the correct behavior to match Parse.com is to - // use the one that appears first in the sort list. - if (sort._created_at) { - sort.createdAt = sort._created_at; - delete sort._created_at; - } - if (sort._updated_at) { - sort.updatedAt = sort._updated_at; - delete sort._updated_at; - } - Object.keys(sort).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Cannot sort by ${fieldName}`); + // Runs a query on the database. + // Returns a promise that resolves to a list of items. + // Options: + // skip number of results to skip. + // limit limit to this number of results. + // sort an object where keys are the fields to sort by. + // the value is +1 for ascending, -1 for descending. + // count run a count instead of returning results. + // acl restrict this operation with an ACL for the provided array + // of user objectIds and roles. acl: null means no user. + // when this field is not present, don't do anything regarding ACLs. + // caseInsensitive make string comparisons case insensitive + // TODO: make userIds not needed here. The db adapter shouldn't know + // anything about users, ideally. Then, improve the format of the ACL + // arg to work like the others. + find( + className: string, + query: any, + { + skip, + limit, + acl, + sort = {}, + count, + keys, + op, + distinct, + pipeline, + readPreference, + hint, + caseInsensitive = false, + explain, + }: any = {}, + auth: any = {}, + validSchemaController: SchemaController.SchemaController + ): Promise { + const isMaster = acl === undefined; + const aclGroup = acl || []; + op = + op || + (typeof query.objectId == 'string' && Object.keys(query).length === 1 + ? 'get' + : 'find'); + // Count operation if counting + op = count === true ? 'count' : op; + + let classExists = true; + return this.loadSchemaIfNeeded(validSchemaController).then( + schemaController => { + //Allow volatile classes if querying with Master (for _PushStatus) + //TODO: Move volatile classes concept into mongo adapter, postgres adapter shouldn't care + //that api.parse.com breaks when _PushStatus exists in mongo. + return schemaController + .getOneSchema(className, isMaster) + .catch(error => { + // Behavior for non-existent classes is kinda weird on Parse.com. Probably doesn't matter too much. + // For now, pretend the class exists but has no objects, + if (error === undefined) { + classExists = false; + return { fields: {} }; } - if (!SchemaController.fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + throw error; + }) + .then(schema => { + // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, + // so duplicate that behavior here. If both are specified, the correct behavior to match Parse.com is to + // use the one that appears first in the sort list. + if (sort._created_at) { + sort.createdAt = sort._created_at; + delete sort._created_at; } - }); - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, op)) - .then(() => this.reduceRelationKeys(className, query)) - .then(() => this.reduceInRelation(className, query, schemaController)) - .then(() => { - if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, op, query, aclGroup); - } - if (!query) { - if (op == 'get') { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return []; - } + if (sort._updated_at) { + sort.updatedAt = sort._updated_at; + delete sort._updated_at; + } + const queryOptions = { + skip, + limit, + sort, + keys, + readPreference, + hint, + caseInsensitive, + explain, + }; + Object.keys(sort).forEach(fieldName => { + if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Cannot sort by ${fieldName}` + ); } - if (!isMaster) { - query = addReadACL(query, aclGroup); + const rootFieldName = getRootFieldName(fieldName); + if (!SchemaController.fieldNameIsValid(rootFieldName)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name: ${fieldName}.` + ); } - validateQuery(query); - if (count) { - if (!classExists) { - return 0; - } else { - return this.adapter.count(className, schema, query, readPreference); + }); + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, op) + ) + .then(() => + this.reduceRelationKeys(className, query, queryOptions) + ) + .then(() => + this.reduceInRelation(className, query, schemaController) + ) + .then(() => { + let protectedFields; + if (!isMaster) { + query = this.addPointerPermissions( + schemaController, + className, + op, + query, + aclGroup + ); + /* Don't use projections to optimize the protectedFields since the protectedFields + based on pointer-permissions are determined after querying. The filtering can + overwrite the protected fields. */ + protectedFields = this.addProtectedFields( + schemaController, + className, + query, + aclGroup, + auth, + queryOptions + ); + } + if (!query) { + if (op === 'get') { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } else { + return []; + } } - } else { - if (!classExists) { - return []; + if (!isMaster) { + if (op === 'update' || op === 'delete') { + query = addWriteACL(query, aclGroup); + } else { + query = addReadACL(query, aclGroup); + } + } + validateQuery(query); + if (count) { + if (!classExists) { + return 0; + } else { + return this.adapter.count( + className, + schema, + query, + readPreference, + undefined, + hint + ); + } + } else if (distinct) { + if (!classExists) { + return []; + } else { + return this.adapter.distinct( + className, + schema, + query, + distinct + ); + } + } else if (pipeline) { + if (!classExists) { + return []; + } else { + return this.adapter.aggregate( + className, + schema, + pipeline, + readPreference, + hint, + explain + ); + } + } else if (explain) { + return this.adapter.find( + className, + schema, + query, + queryOptions + ); } else { - return this.adapter.find(className, schema, query, { skip, limit, sort, keys, readPreference }) - .then(objects => objects.map(object => { - object = untransformObjectACL(object); - return filterSensitiveData(isMaster, aclGroup, className, object) - })).catch((error) => { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, error); + return this.adapter + .find(className, schema, query, queryOptions) + .then(objects => + objects.map(object => { + object = untransformObjectACL(object); + return filterSensitiveData( + isMaster, + aclGroup, + auth, + op, + schemaController, + className, + protectedFields, + object + ); + }) + ) + .catch(error => { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + error + ); }); } - } - }); - }); - }); -}; + }); + }); + } + ); + } -// Transforms a Database format ACL to a REST API format ACL -const untransformObjectACL = ({_rperm, _wperm, ...output}) => { - if (_rperm || _wperm) { - output.ACL = {}; + deleteSchema(className: string): Promise { + return this.loadSchema({ clearCache: true }) + .then(schemaController => schemaController.getOneSchema(className, true)) + .catch(error => { + if (error === undefined) { + return { fields: {} }; + } else { + throw error; + } + }) + .then((schema: any) => { + return this.collectionExists(className) + .then(() => + this.adapter.count(className, { fields: {} }, null, '', false) + ) + .then(count => { + if (count > 0) { + throw new Parse.Error( + 255, + `Class ${className} is not empty, contains ${count} objects, cannot drop schema.` + ); + } + return this.adapter.deleteClass(className); + }) + .then(wasParseCollection => { + if (wasParseCollection) { + const relationFieldNames = Object.keys(schema.fields).filter( + fieldName => schema.fields[fieldName].type === 'Relation' + ); + return Promise.all( + relationFieldNames.map(name => + this.adapter.deleteClass(joinTableName(className, name)) + ) + ).then(() => { + return; + }); + } else { + return Promise.resolve(); + } + }); + }); + } - (_rperm || []).forEach(entry => { - if (!output.ACL[entry]) { - output.ACL[entry] = { read: true }; - } else { - output.ACL[entry]['read'] = true; - } + // Constraints query using CLP's pointer permissions (PP) if any. + // 1. Etract the user id from caller's ACLgroup; + // 2. Exctract a list of field names that are PP for target collection and operation; + // 3. Constraint the original query so that each PP field must + // point to caller's id (or contain it in case of PP field being an array) + addPointerPermissions( + schema: SchemaController.SchemaController, + className: string, + operation: string, + query: any, + aclGroup: any[] = [] + ): any { + // Check if class has public permission for operation + // If the BaseCLP pass, let go through + if (schema.testPermissionsForClassName(className, aclGroup, operation)) { + return query; + } + const perms = schema.getClassLevelPermissions(className); + + const userACL = aclGroup.filter(acl => { + return acl.indexOf('role:') != 0 && acl != '*'; }); - (_wperm || []).forEach(entry => { - if (!output.ACL[entry]) { - output.ACL[entry] = { write: true }; - } else { - output.ACL[entry]['write'] = true; + const groupKey = + ['get', 'find', 'count'].indexOf(operation) > -1 + ? 'readUserFields' + : 'writeUserFields'; + + const permFields = []; + + if (perms[operation] && perms[operation].pointerFields) { + permFields.push(...perms[operation].pointerFields); + } + + if (perms[groupKey]) { + for (const field of perms[groupKey]) { + if (!permFields.includes(field)) { + permFields.push(field); + } } - }); + } + // the ACL should have exactly 1 user + if (permFields.length > 0) { + // the ACL should have exactly 1 user + // No user set return undefined + // If the length is > 1, that means we didn't de-dupe users correctly + if (userACL.length != 1) { + return; + } + const userId = userACL[0]; + const userPointer = { + __type: 'Pointer', + className: '_User', + objectId: userId, + }; + + const ors = permFields.flatMap(key => { + // constraint for single pointer setup + const q = { + [key]: userPointer, + }; + // constraint for users-array setup + const qa = { + [key]: { $all: [userPointer] }, + }; + // if we already have a constraint on the key, use the $and + if (Object.prototype.hasOwnProperty.call(query, key)) { + return [{ $and: [q, query] }, { $and: [qa, query] }]; + } + // otherwise just add the constaint + return [Object.assign({}, query, q), Object.assign({}, query, qa)]; + }); + return { $or: ors }; + } else { + return query; + } } - return output; -} -DatabaseController.prototype.deleteSchema = function(className) { - return this.loadSchema(true) - .then(schemaController => schemaController.getOneSchema(className, true)) - .catch(error => { - if (error === undefined) { - return { fields: {} }; - } else { - throw error; - } - }) - .then(schema => { - return this.collectionExists(className) - .then(() => this.adapter.count(className, { fields: {} })) - .then(count => { - if (count > 0) { - throw new Parse.Error(255, `Class ${className} is not empty, contains ${count} objects, cannot drop schema.`); - } - return this.adapter.deleteClass(className); - }) - .then(wasParseCollection => { - if (wasParseCollection) { - const relationFieldNames = Object.keys(schema.fields).filter(fieldName => schema.fields[fieldName].type === 'Relation'); - return Promise.all(relationFieldNames.map(name => this.adapter.deleteClass(joinTableName(className, name)))); - } else { - return Promise.resolve(); + addProtectedFields( + schema: SchemaController.SchemaController, + className: string, + query: any = {}, + aclGroup: any[] = [], + auth: any = {}, + queryOptions: FullQueryOptions = {} + ): null | string[] { + const perms = schema.getClassLevelPermissions(className); + if (!perms) return null; + + const protectedFields = perms.protectedFields; + if (!protectedFields) return null; + + if (aclGroup.indexOf(query.objectId) > -1) return null; + + // for queries where "keys" are set and do not include all 'userField':{field}, + // we have to transparently include it, and then remove before returning to client + // Because if such key not projected the permission won't be enforced properly + // PS this is called when 'excludeKeys' already reduced to 'keys' + const preserveKeys = queryOptions.keys; + + // these are keys that need to be included only + // to be able to apply protectedFields by pointer + // and then unset before returning to client (later in filterSensitiveFields) + const serverOnlyKeys = []; + + const authenticated = auth.user; + + // map to allow check without array search + const roles = (auth.userRoles || []).reduce((acc, r) => { + acc[r] = protectedFields[r]; + return acc; + }, {}); + + // array of sets of protected fields. separate item for each applicable criteria + const protectedKeysSets = []; + + for (const key in protectedFields) { + // skip userFields + if (key.startsWith('userField:')) { + if (preserveKeys) { + const fieldName = key.substring(10); + if (!preserveKeys.includes(fieldName)) { + // 1. put it there temporarily + queryOptions.keys && queryOptions.keys.push(fieldName); + // 2. preserve it delete later + serverOnlyKeys.push(fieldName); } - }); - }) -} + } + continue; + } -DatabaseController.prototype.addPointerPermissions = function(schema, className, operation, query, aclGroup = []) { - // Check if class has public permission for operation - // If the BaseCLP pass, let go through - if (schema.testBaseCLP(className, aclGroup, operation)) { - return query; - } - const perms = schema.perms[className]; - const field = ['get', 'find'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; - const userACL = aclGroup.filter((acl) => { - return acl.indexOf('role:') != 0 && acl != '*'; - }); - // the ACL should have exactly 1 user - if (perms && perms[field] && perms[field].length > 0) { - // No user set return undefined - // If the length is > 1, that means we didn't de-dupe users correctly - if (userACL.length != 1) { - return; - } - const userId = userACL[0]; - const userPointer = { - "__type": "Pointer", - "className": "_User", - "objectId": userId - }; + // add public tier + if (key === '*') { + protectedKeysSets.push(protectedFields[key]); + continue; + } - const permFields = perms[field]; - const ors = permFields.map((key) => { - const q = { - [key]: userPointer - }; - // if we already have a constraint on the key, use the $and - if (query.hasOwnProperty(key)) { - return {'$and': [q, query]}; + if (authenticated) { + if (key === 'authenticated') { + // for logged in users + protectedKeysSets.push(protectedFields[key]); + continue; + } + + if (roles[key] && key.startsWith('role:')) { + // add applicable roles + protectedKeysSets.push(roles[key]); + } + } + } + + // check if there's a rule for current user's id + if (authenticated) { + const userId = auth.user.id; + if (perms.protectedFields[userId]) { + protectedKeysSets.push(perms.protectedFields[userId]); + } + } + + // preserve fields to be removed before sending response to client + if (serverOnlyKeys.length > 0) { + perms.protectedFields.temporaryKeys = serverOnlyKeys; + } + + let protectedKeys = protectedKeysSets.reduce((acc, next) => { + if (next) { + acc.push(...next); + } + return acc; + }, []); + + // intersect all sets of protectedFields + protectedKeysSets.forEach(fields => { + if (fields) { + protectedKeys = protectedKeys.filter(v => fields.includes(v)); } - // otherwise just add the constaint - return Object.assign({}, query, { - [`${key}`]: userPointer, - }) }); - if (ors.length > 1) { - return {'$or': ors}; + + return protectedKeys; + } + + createTransactionalSession() { + return this.adapter + .createTransactionalSession() + .then(transactionalSession => { + this._transactionalSession = transactionalSession; + }); + } + + commitTransactionalSession() { + if (!this._transactionalSession) { + throw new Error('There is no transactional session to commit'); } - return ors[0]; - } else { - return query; + return this.adapter + .commitTransactionalSession(this._transactionalSession) + .then(() => { + this._transactionalSession = null; + }); } -} -// TODO: create indexes on first creation of a _User object. Otherwise it's impossible to -// have a Parse app without it having a _User collection. -DatabaseController.prototype.performInitialization = function() { - const requiredUserFields = { fields: { ...SchemaController.defaultColumns._Default, ...SchemaController.defaultColumns._User } }; - const requiredRoleFields = { fields: { ...SchemaController.defaultColumns._Default, ...SchemaController.defaultColumns._Role } }; - - const userClassPromise = this.loadSchema() - .then(schema => schema.enforceClassExists('_User')) - const roleClassPromise = this.loadSchema() - .then(schema => schema.enforceClassExists('_Role')) - - const usernameUniqueness = userClassPromise - .then(() => this.adapter.ensureUniqueness('_User', requiredUserFields, ['username'])) - .catch(error => { - logger.warn('Unable to ensure uniqueness for usernames: ', error); - throw error; - }); + abortTransactionalSession() { + if (!this._transactionalSession) { + throw new Error('There is no transactional session to abort'); + } + return this.adapter + .abortTransactionalSession(this._transactionalSession) + .then(() => { + this._transactionalSession = null; + }); + } - const emailUniqueness = userClassPromise - .then(() => this.adapter.ensureUniqueness('_User', requiredUserFields, ['email'])) - .catch(error => { - logger.warn('Unable to ensure uniqueness for user email addresses: ', error); - throw error; - }); + // TODO: create indexes on first creation of a _User object. Otherwise it's impossible to + // have a Parse app without it having a _User collection. + performInitialization() { + const requiredUserFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._User, + }, + }; + const requiredRoleFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._Role, + }, + }; - const roleUniqueness = roleClassPromise - .then(() => this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name'])) - .catch(error => { - logger.warn('Unable to ensure uniqueness for role name: ', error); - throw error; - }); + const userClassPromise = this.loadSchema().then(schema => + schema.enforceClassExists('_User') + ); + const roleClassPromise = this.loadSchema().then(schema => + schema.enforceClassExists('_Role') + ); + + const usernameUniqueness = userClassPromise + .then(() => + this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']) + ) + .catch(error => { + logger.warn('Unable to ensure uniqueness for usernames: ', error); + throw error; + }); - // Create tables for volatile classes - const adapterInit = this.adapter.performInitialization({ VolatileClassesSchemas: SchemaController.VolatileClassesSchemas }); - return Promise.all([usernameUniqueness, emailUniqueness, roleUniqueness, adapterInit]); -} + const usernameCaseInsensitiveIndex = userClassPromise + .then(() => + this.adapter.ensureIndex( + '_User', + requiredUserFields, + ['username'], + 'case_insensitive_username', + true + ) + ) + .catch(error => { + logger.warn( + 'Unable to create case insensitive username index: ', + error + ); + throw error; + }); -function joinTableName(className, key) { - return `_Join:${key}:${className}`; + const emailUniqueness = userClassPromise + .then(() => + this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']) + ) + .catch(error => { + logger.warn( + 'Unable to ensure uniqueness for user email addresses: ', + error + ); + throw error; + }); + + const emailCaseInsensitiveIndex = userClassPromise + .then(() => + this.adapter.ensureIndex( + '_User', + requiredUserFields, + ['email'], + 'case_insensitive_email', + true + ) + ) + .catch(error => { + logger.warn('Unable to create case insensitive email index: ', error); + throw error; + }); + + const roleUniqueness = roleClassPromise + .then(() => + this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']) + ) + .catch(error => { + logger.warn('Unable to ensure uniqueness for role name: ', error); + throw error; + }); + + const indexPromise = this.adapter.updateSchemaWithIndexes(); + + // Create tables for volatile classes + const adapterInit = this.adapter.performInitialization({ + VolatileClassesSchemas: SchemaController.VolatileClassesSchemas, + }); + return Promise.all([ + usernameUniqueness, + usernameCaseInsensitiveIndex, + emailUniqueness, + emailCaseInsensitiveIndex, + roleUniqueness, + adapterInit, + indexPromise, + ]); + } + + static _validateQuery: any => void; } -// Expose validateQuery for tests -DatabaseController._validateQuery = validateQuery; module.exports = DatabaseController; +// Expose validateQuery for tests +module.exports._validateQuery = validateQuery; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index e8bcbc71d9..461fa229a9 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -1,37 +1,40 @@ // FilesController.js import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; -import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; -import path from 'path'; +import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import path from 'path'; import mime from 'mime'; +const Parse = require('parse').Parse; -const legacyFilesRegex = new RegExp("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*"); +const legacyFilesRegex = new RegExp( + '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*' +); export class FilesController extends AdaptableController { - getFileData(config, filename) { return this.adapter.getFileData(filename); } createFile(config, filename, data, contentType) { - const extname = path.extname(filename); const hasExtension = extname.length > 0; - if (!hasExtension && contentType && mime.extension(contentType)) { - filename = filename + '.' + mime.extension(contentType); + if (!hasExtension && contentType && mime.getExtension(contentType)) { + filename = filename + '.' + mime.getExtension(contentType); } else if (hasExtension && !contentType) { - contentType = mime.lookup(filename); + contentType = mime.getType(filename); } - filename = randomHexString(32) + '_' + filename; + if (!this.options.preserveFileName) { + filename = randomHexString(32) + '_' + filename; + } - var location = this.adapter.getFileLocation(config, filename); + const location = this.adapter.getFileLocation(config, filename); return this.adapter.createFile(filename, data, contentType).then(() => { return Promise.resolve({ url: location, - name: filename + name: filename, }); }); } @@ -47,7 +50,7 @@ export class FilesController extends AdaptableController { */ expandFilesInObject(config, object) { if (object instanceof Array) { - object.map((obj) => this.expandFilesInObject(config, obj)); + object.map(obj => this.expandFilesInObject(config, obj)); return; } if (typeof object !== 'object') { @@ -67,9 +70,17 @@ export class FilesController extends AdaptableController { fileObject['url'] = this.adapter.getFileLocation(config, filename); } else { if (filename.indexOf('tfss-') === 0) { - fileObject['url'] = 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename); + fileObject['url'] = + 'http://files.parsetfss.com/' + + config.fileKey + + '/' + + encodeURIComponent(filename); } else if (legacyFilesRegex.test(filename)) { - fileObject['url'] = 'http://files.parse.com/' + config.fileKey + '/' + encodeURIComponent(filename); + fileObject['url'] = + 'http://files.parse.com/' + + config.fileKey + + '/' + + encodeURIComponent(filename); } else { fileObject['url'] = this.adapter.getFileLocation(config, filename); } @@ -82,8 +93,19 @@ export class FilesController extends AdaptableController { return FilesAdapter; } - getFileStream(config, filename) { - return this.adapter.getFileStream(filename); + handleFileStream(config, filename, req, res, contentType) { + return this.adapter.handleFileStream(filename, req, res, contentType); + } + + validateFilename(filename) { + if (typeof this.adapter.validateFilename === 'function') { + const error = this.adapter.validateFilename(filename); + if (typeof error !== 'string') { + return error; + } + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, error); + } + return validateFilename(filename); } } diff --git a/src/Controllers/HooksController.js b/src/Controllers/HooksController.js index 6fcdfc3c91..3ac09b30fa 100644 --- a/src/Controllers/HooksController.js +++ b/src/Controllers/HooksController.js @@ -1,18 +1,26 @@ /** @flow weak */ -import * as triggers from "../triggers"; -import * as Parse from "parse/node"; -import * as request from "request"; -import { logger } from '../logger'; +import * as triggers from '../triggers'; +// @flow-disable-next +import * as Parse from 'parse/node'; +// @flow-disable-next +import request from '../request'; +import { logger } from '../logger'; +import http from 'http'; +import https from 'https'; -const DefaultHooksCollectionName = "_Hooks"; +const DefaultHooksCollectionName = '_Hooks'; +const HTTPAgents = { + http: new http.Agent({ keepAlive: true }), + https: new https.Agent({ keepAlive: true }), +}; export class HooksController { - _applicationId:string; - _webhookKey:string; + _applicationId: string; + _webhookKey: string; database: any; - constructor(applicationId:string, databaseController, webhookKey) { + constructor(applicationId: string, databaseController, webhookKey) { this._applicationId = applicationId; this._webhookKey = webhookKey; this.database = databaseController; @@ -21,14 +29,16 @@ export class HooksController { load() { return this._getHooks().then(hooks => { hooks = hooks || []; - hooks.forEach((hook) => { + hooks.forEach(hook => { this.addHookToTriggers(hook); }); }); } getFunction(functionName) { - return this._getHooks({ functionName: functionName }, 1).then(results => results[0]); + return this._getHooks({ functionName: functionName }).then( + results => results[0] + ); } getFunctions() { @@ -36,11 +46,17 @@ export class HooksController { } getTrigger(className, triggerName) { - return this._getHooks({ className: className, triggerName: triggerName }, 1).then(results => results[0]); + return this._getHooks({ + className: className, + triggerName: triggerName, + }).then(results => results[0]); } getTriggers() { - return this._getHooks({ className: { $exists: true }, triggerName: { $exists: true } }); + return this._getHooks({ + className: { $exists: true }, + triggerName: { $exists: true }, + }); } deleteFunction(functionName) { @@ -50,16 +66,21 @@ export class HooksController { deleteTrigger(className, triggerName) { triggers.removeTrigger(triggerName, className, this._applicationId); - return this._removeHooks({ className: className, triggerName: triggerName }); + return this._removeHooks({ + className: className, + triggerName: triggerName, + }); } _getHooks(query = {}) { - return this.database.find(DefaultHooksCollectionName, query).then((results) => { - return results.map((result) => { - delete result.objectId; - return result; + return this.database + .find(DefaultHooksCollectionName, query) + .then(results => { + return results.map(result => { + delete result.objectId; + return result; + }); }); - }); } _removeHooks(query) { @@ -71,24 +92,36 @@ export class HooksController { saveHook(hook) { var query; if (hook.functionName && hook.url) { - query = { functionName: hook.functionName } + query = { functionName: hook.functionName }; } else if (hook.triggerName && hook.className && hook.url) { - query = { className: hook.className, triggerName: hook.triggerName } + query = { className: hook.className, triggerName: hook.triggerName }; } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } - return this.database.update(DefaultHooksCollectionName, query, hook, {upsert: true}).then(() => { - return Promise.resolve(hook); - }) + return this.database + .update(DefaultHooksCollectionName, query, hook, { upsert: true }) + .then(() => { + return Promise.resolve(hook); + }); } addHookToTriggers(hook) { var wrappedFunction = wrapToHTTPRequest(hook, this._webhookKey); wrappedFunction.url = hook.url; if (hook.className) { - triggers.addTrigger(hook.triggerName, hook.className, wrappedFunction, this._applicationId) + triggers.addTrigger( + hook.triggerName, + hook.className, + wrappedFunction, + this._applicationId + ); } else { - triggers.addFunction(hook.functionName, wrappedFunction, null, this._applicationId); + triggers.addFunction( + hook.functionName, + wrappedFunction, + null, + this._applicationId + ); } } @@ -103,14 +136,19 @@ export class HooksController { hook = {}; hook.functionName = aHook.functionName; hook.url = aHook.url; - } else if (aHook && aHook.className && aHook.url && aHook.triggerName && triggers.Types[aHook.triggerName]) { + } else if ( + aHook && + aHook.className && + aHook.url && + aHook.triggerName && + triggers.Types[aHook.triggerName] + ) { hook = {}; hook.className = aHook.className; hook.url = aHook.url; hook.triggerName = aHook.triggerName; - } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } return this.addHook(hook); @@ -118,47 +156,62 @@ export class HooksController { createHook(aHook) { if (aHook.functionName) { - return this.getFunction(aHook.functionName).then((result) => { + return this.getFunction(aHook.functionName).then(result => { if (result) { - throw new Parse.Error(143, `function name: ${aHook.functionName} already exits`); + throw new Parse.Error( + 143, + `function name: ${aHook.functionName} already exits` + ); } else { return this.createOrUpdateHook(aHook); } }); } else if (aHook.className && aHook.triggerName) { - return this.getTrigger(aHook.className, aHook.triggerName).then((result) => { - if (result) { - throw new Parse.Error(143, `class ${aHook.className} already has trigger ${aHook.triggerName}`); + return this.getTrigger(aHook.className, aHook.triggerName).then( + result => { + if (result) { + throw new Parse.Error( + 143, + `class ${aHook.className} already has trigger ${ + aHook.triggerName + }` + ); + } + return this.createOrUpdateHook(aHook); } - return this.createOrUpdateHook(aHook); - }); + ); } - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } updateHook(aHook) { if (aHook.functionName) { - return this.getFunction(aHook.functionName).then((result) => { + return this.getFunction(aHook.functionName).then(result => { if (result) { return this.createOrUpdateHook(aHook); } - throw new Parse.Error(143, `no function named: ${aHook.functionName} is defined`); + throw new Parse.Error( + 143, + `no function named: ${aHook.functionName} is defined` + ); }); } else if (aHook.className && aHook.triggerName) { - return this.getTrigger(aHook.className, aHook.triggerName).then((result) => { - if (result) { - return this.createOrUpdateHook(aHook); + return this.getTrigger(aHook.className, aHook.triggerName).then( + result => { + if (result) { + return this.createOrUpdateHook(aHook); + } + throw new Parse.Error(143, `class ${aHook.className} does not exist`); } - throw new Parse.Error(143, `class ${aHook.className} does not exist`); - }); + ); } - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } } function wrapToHTTPRequest(hook, key) { - return (req, res) => { + return req => { const jsonBody = {}; for (var i in req) { jsonBody[i] = req[i]; @@ -172,29 +225,39 @@ function wrapToHTTPRequest(hook, key) { jsonBody.original.className = req.original.className; } const jsonRequest: any = { + url: hook.url, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - body: JSON.stringify(jsonBody) + body: jsonBody, + method: 'POST', }; + const agent = hook.url.startsWith('https') + ? HTTPAgents['https'] + : HTTPAgents['http']; + jsonRequest.agent = agent; + if (key) { jsonRequest.headers['X-Parse-Webhook-Key'] = key; } else { - logger.warn('Making outgoing webhook request without webhookKey being set!'); + logger.warn( + 'Making outgoing webhook request without webhookKey being set!' + ); } - - request.post(hook.url, jsonRequest, function (err, httpResponse, body) { - var result; + return request(jsonRequest).then(response => { + let err; + let result; + let body = response.data; if (body) { - if (typeof body === "string") { + if (typeof body === 'string') { try { body = JSON.parse(body); } catch (e) { err = { - error: "Malformed response", + error: 'Malformed response', code: -1, - partialResponse: body.substring(0, 100) + partialResponse: body.substring(0, 100), }; } } @@ -203,20 +266,19 @@ function wrapToHTTPRequest(hook, key) { err = body.error; } } - if (err) { - return res.error(err); + throw err; } else if (hook.triggerName === 'beforeSave') { if (typeof result === 'object') { delete result.createdAt; delete result.updatedAt; } - return res.success({object: result}); + return { object: result }; } else { - return res.success(result); + return result; } }); - } + }; } export default HooksController; diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js index 7f741c359c..7ad6d977ec 100644 --- a/src/Controllers/LiveQueryController.js +++ b/src/Controllers/LiveQueryController.js @@ -11,24 +11,42 @@ export class LiveQueryController { } else if (config.classNames instanceof Array) { this.classNames = new Set(config.classNames); } else { - throw 'liveQuery.classes should be an array of string' + throw 'liveQuery.classes should be an array of string'; } this.liveQueryPublisher = new ParseCloudCodePublisher(config); } - onAfterSave(className: string, currentObject: any, originalObject: any) { + onAfterSave( + className: string, + currentObject: any, + originalObject: any, + classLevelPermissions: ?any + ) { if (!this.hasLiveQuery(className)) { return; } - const req = this._makePublisherRequest(currentObject, originalObject); + const req = this._makePublisherRequest( + currentObject, + originalObject, + classLevelPermissions + ); this.liveQueryPublisher.onCloudCodeAfterSave(req); } - onAfterDelete(className: string, currentObject: any, originalObject: any) { + onAfterDelete( + className: string, + currentObject: any, + originalObject: any, + classLevelPermissions: any + ) { if (!this.hasLiveQuery(className)) { return; } - const req = this._makePublisherRequest(currentObject, originalObject); + const req = this._makePublisherRequest( + currentObject, + originalObject, + classLevelPermissions + ); this.liveQueryPublisher.onCloudCodeAfterDelete(req); } @@ -36,13 +54,20 @@ export class LiveQueryController { return this.classNames.has(className); } - _makePublisherRequest(currentObject: any, originalObject: any): any { + _makePublisherRequest( + currentObject: any, + originalObject: any, + classLevelPermissions: ?any + ): any { const req = { - object: currentObject + object: currentObject, }; if (currentObject) { req.original = originalObject; } + if (classLevelPermissions) { + req.classLevelPermissions = classLevelPermissions; + } return req; } } diff --git a/src/Controllers/LoggerController.js b/src/Controllers/LoggerController.js index 5e5da68363..54effc3ddf 100644 --- a/src/Controllers/LoggerController.js +++ b/src/Controllers/LoggerController.js @@ -9,26 +9,18 @@ const truncationMarker = '... (truncated)'; export const LogLevel = { INFO: 'info', - ERROR: 'error' -} + ERROR: 'error', +}; export const LogOrder = { DESCENDING: 'desc', - ASCENDING: 'asc' -} + ASCENDING: 'asc', +}; -const logLevels = [ - 'error', - 'warn', - 'info', - 'debug', - 'verbose', - 'silly', -] +const logLevels = ['error', 'warn', 'info', 'debug', 'verbose', 'silly']; export class LoggerController extends AdaptableController { - - constructor(adapter, appId, options = {logLevel: 'info'}) { + constructor(adapter, appId, options = { logLevel: 'info' }) { super(adapter, appId, options); let level = 'info'; if (options.verbose) { @@ -39,19 +31,33 @@ export class LoggerController extends AdaptableController { } const index = logLevels.indexOf(level); // info by default logLevels.forEach((level, levelIndex) => { - if (levelIndex > index) { // silence the levels that are > maxIndex + if (levelIndex > index) { + // silence the levels that are > maxIndex this[level] = () => {}; } }); } maskSensitiveUrl(urlString) { - const password = url.parse(urlString, true).query.password; - - if (password) { - urlString = urlString.replace('password=' + password, 'password=********'); + const urlObj = url.parse(urlString, true); + const query = urlObj.query; + let sanitizedQuery = '?'; + + for (const key in query) { + if (key !== 'password') { + // normal value + sanitizedQuery += key + '=' + query[key] + '&'; + } else { + // password value, redact it + sanitizedQuery += key + '=' + '********' + '&'; + } } - return urlString; + + // trim last character, ? or & + sanitizedQuery = sanitizedQuery.slice(0, -1); + + // return original path name with sanitized params attached + return urlObj.pathname + sanitizedQuery; } maskSensitive(argArray) { @@ -70,7 +76,8 @@ export class LoggerController extends AdaptableController { // for strings if (typeof e.url === 'string') { e.url = this.maskSensitiveUrl(e.url); - } else if (Array.isArray(e.url)) { // for strings in array + } else if (Array.isArray(e.url)) { + // for strings in array e.url = e.url.map(item => { if (typeof item === 'string') { return this.maskSensitiveUrl(item); @@ -106,10 +113,15 @@ export class LoggerController extends AdaptableController { log(level, args) { // make the passed in arguments object an array with the spread operator args = this.maskSensitive([...args]); - args = [].concat(level, args.map((arg) => { - if (typeof arg === 'function') { return arg(); } - return arg; - })); + args = [].concat( + level, + args.map(arg => { + if (typeof arg === 'function') { + return arg(); + } + return arg; + }) + ); this.adapter.log.apply(this.adapter, args); } @@ -137,33 +149,28 @@ export class LoggerController extends AdaptableController { return this.log('silly', arguments); } - logRequest({ - method, - url, - headers, - body - }) { - this.verbose(() => { - const stringifiedBody = JSON.stringify(body, null, 2); - return `REQUEST for [${method}] ${url}: ${stringifiedBody}`; - }, { - method, - url, - headers, - body - }); + logRequest({ method, url, headers, body }) { + this.verbose( + () => { + const stringifiedBody = JSON.stringify(body, null, 2); + return `REQUEST for [${method}] ${url}: ${stringifiedBody}`; + }, + { + method, + url, + headers, + body, + } + ); } - logResponse({ - method, - url, - result - }) { + logResponse({ method, url, result }) { this.verbose( - () => { const stringifiedResponse = JSON.stringify(result, null, 2); + () => { + const stringifiedResponse = JSON.stringify(result, null, 2); return `RESPONSE from [${method}] ${url}: ${stringifiedResponse}`; }, - {result: result} + { result: result } ); } // check that date input is valid @@ -182,7 +189,8 @@ export class LoggerController extends AdaptableController { truncateLogMessage(string) { if (string && string.length > LOG_STRING_TRUNCATE_LENGTH) { - const truncated = string.substring(0, LOG_STRING_TRUNCATE_LENGTH) + truncationMarker; + const truncated = + string.substring(0, LOG_STRING_TRUNCATE_LENGTH) + truncationMarker; return truncated; } @@ -190,7 +198,8 @@ export class LoggerController extends AdaptableController { } static parseOptions(options = {}) { - const from = LoggerController.validDateTime(options.from) || + const from = + LoggerController.validDateTime(options.from) || new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); const until = LoggerController.validDateTime(options.until) || new Date(); const size = Number(options.size) || 10; @@ -215,12 +224,16 @@ export class LoggerController extends AdaptableController { // size (optional) Number of rows returned by search. Defaults to 10 getLogs(options = {}) { if (!this.adapter) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not available'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not available' + ); } if (typeof this.adapter.query !== 'function') { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Querying logs is not supported with this adapter'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Querying logs is not supported with this adapter' + ); } options = LoggerController.parseOptions(options); return this.adapter.query(options); diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js new file mode 100644 index 0000000000..35919d8dd9 --- /dev/null +++ b/src/Controllers/ParseGraphQLController.js @@ -0,0 +1,402 @@ +import requiredParameter from '../../lib/requiredParameter'; +import DatabaseController from './DatabaseController'; +import CacheController from './CacheController'; + +const GraphQLConfigClassName = '_GraphQLConfig'; +const GraphQLConfigId = '1'; +const GraphQLConfigKey = 'config'; + +class ParseGraphQLController { + databaseController: DatabaseController; + cacheController: CacheController; + isMounted: boolean; + configCacheKey: string; + + constructor( + params: { + databaseController: DatabaseController, + cacheController: CacheController, + } = {} + ) { + this.databaseController = + params.databaseController || + requiredParameter( + `ParseGraphQLController requires a "databaseController" to be instantiated.` + ); + this.cacheController = params.cacheController; + this.isMounted = !!params.mountGraphQL; + this.configCacheKey = GraphQLConfigKey; + } + + async getGraphQLConfig(): Promise { + if (this.isMounted) { + const _cachedConfig = await this._getCachedGraphQLConfig(); + if (_cachedConfig) { + return _cachedConfig; + } + } + + const results = await this.databaseController.find( + GraphQLConfigClassName, + { objectId: GraphQLConfigId }, + { limit: 1 } + ); + + let graphQLConfig; + if (results.length != 1) { + // If there is no config in the database - return empty config. + return {}; + } else { + graphQLConfig = results[0][GraphQLConfigKey]; + } + + if (this.isMounted) { + this._putCachedGraphQLConfig(graphQLConfig); + } + + return graphQLConfig; + } + + async updateGraphQLConfig( + graphQLConfig: ParseGraphQLConfig + ): Promise { + // throws if invalid + this._validateGraphQLConfig( + graphQLConfig || requiredParameter('You must provide a graphQLConfig!') + ); + + // Transform in dot notation to make sure it works + const update = Object.keys(graphQLConfig).reduce( + (acc, key) => { + return { + [GraphQLConfigKey]: { + ...acc[GraphQLConfigKey], + [key]: graphQLConfig[key], + }, + }; + }, + { [GraphQLConfigKey]: {} } + ); + + await this.databaseController.update( + GraphQLConfigClassName, + { objectId: GraphQLConfigId }, + update, + { upsert: true } + ); + + if (this.isMounted) { + this._putCachedGraphQLConfig(graphQLConfig); + } + + return { response: { result: true } }; + } + + _getCachedGraphQLConfig() { + return this.cacheController.graphQL.get(this.configCacheKey); + } + + _putCachedGraphQLConfig(graphQLConfig: ParseGraphQLConfig) { + return this.cacheController.graphQL.put( + this.configCacheKey, + graphQLConfig, + 60000 + ); + } + + _validateGraphQLConfig(graphQLConfig: ?ParseGraphQLConfig): void { + const errorMessages: string = []; + if (!graphQLConfig) { + errorMessages.push('cannot be undefined, null or empty'); + } else if (!isValidSimpleObject(graphQLConfig)) { + errorMessages.push('must be a valid object'); + } else { + const { + enabledForClasses = null, + disabledForClasses = null, + classConfigs = null, + ...invalidKeys + } = graphQLConfig; + + if (Object.keys(invalidKeys).length) { + errorMessages.push( + `encountered invalid keys: [${Object.keys(invalidKeys)}]` + ); + } + if ( + enabledForClasses !== null && + !isValidStringArray(enabledForClasses) + ) { + errorMessages.push(`"enabledForClasses" is not a valid array`); + } + if ( + disabledForClasses !== null && + !isValidStringArray(disabledForClasses) + ) { + errorMessages.push(`"disabledForClasses" is not a valid array`); + } + if (classConfigs !== null) { + if (Array.isArray(classConfigs)) { + classConfigs.forEach(classConfig => { + const errorMessage = this._validateClassConfig(classConfig); + if (errorMessage) { + errorMessages.push( + `classConfig:${classConfig.className} is invalid because ${errorMessage}` + ); + } + }); + } else { + errorMessages.push(`"classConfigs" is not a valid array`); + } + } + } + if (errorMessages.length) { + throw new Error(`Invalid graphQLConfig: ${errorMessages.join('; ')}`); + } + } + + _validateClassConfig(classConfig: ?ParseGraphQLClassConfig): string | void { + if (!isValidSimpleObject(classConfig)) { + return 'it must be a valid object'; + } else { + const { + className, + type = null, + query = null, + mutation = null, + ...invalidKeys + } = classConfig; + if (Object.keys(invalidKeys).length) { + return `"invalidKeys" [${Object.keys( + invalidKeys + )}] should not be present`; + } + if (typeof className !== 'string' || !className.trim().length) { + // TODO consider checking class exists in schema? + return `"className" must be a valid string`; + } + if (type !== null) { + if (!isValidSimpleObject(type)) { + return `"type" must be a valid object`; + } + const { + inputFields = null, + outputFields = null, + constraintFields = null, + sortFields = null, + ...invalidKeys + } = type; + if (Object.keys(invalidKeys).length) { + return `"type" contains invalid keys, [${Object.keys(invalidKeys)}]`; + } else if (outputFields !== null && !isValidStringArray(outputFields)) { + return `"outputFields" must be a valid string array`; + } else if ( + constraintFields !== null && + !isValidStringArray(constraintFields) + ) { + return `"constraintFields" must be a valid string array`; + } + if (sortFields !== null) { + if (Array.isArray(sortFields)) { + let errorMessage; + sortFields.every((sortField, index) => { + if (!isValidSimpleObject(sortField)) { + errorMessage = `"sortField" at index ${index} is not a valid object`; + return false; + } else { + const { field, asc, desc, ...invalidKeys } = sortField; + if (Object.keys(invalidKeys).length) { + errorMessage = `"sortField" at index ${index} contains invalid keys, [${Object.keys( + invalidKeys + )}]`; + return false; + } else { + if (typeof field !== 'string' || field.trim().length === 0) { + errorMessage = `"sortField" at index ${index} did not provide the "field" as a string`; + return false; + } else if ( + typeof asc !== 'boolean' || + typeof desc !== 'boolean' + ) { + errorMessage = `"sortField" at index ${index} did not provide "asc" or "desc" as booleans`; + return false; + } + } + } + return true; + }); + if (errorMessage) { + return errorMessage; + } + } else { + return `"sortFields" must be a valid array.`; + } + } + if (inputFields !== null) { + if (isValidSimpleObject(inputFields)) { + const { + create = null, + update = null, + ...invalidKeys + } = inputFields; + if (Object.keys(invalidKeys).length) { + return `"inputFields" contains invalid keys: [${Object.keys( + invalidKeys + )}]`; + } else { + if (update !== null && !isValidStringArray(update)) { + return `"inputFields.update" must be a valid string array`; + } else if (create !== null) { + if (!isValidStringArray(create)) { + return `"inputFields.create" must be a valid string array`; + } else if (className === '_User') { + if ( + !create.includes('username') || + !create.includes('password') + ) { + return `"inputFields.create" must include required fields, username and password`; + } + } + } + } + } else { + return `"inputFields" must be a valid object`; + } + } + } + if (query !== null) { + if (isValidSimpleObject(query)) { + const { + find = null, + get = null, + findAlias = null, + getAlias = null, + ...invalidKeys + } = query; + if (Object.keys(invalidKeys).length) { + return `"query" contains invalid keys, [${Object.keys( + invalidKeys + )}]`; + } else if (find !== null && typeof find !== 'boolean') { + return `"query.find" must be a boolean`; + } else if (get !== null && typeof get !== 'boolean') { + return `"query.get" must be a boolean`; + } else if (findAlias !== null && typeof findAlias !== 'string') { + return `"query.findAlias" must be a string`; + } else if (getAlias !== null && typeof getAlias !== 'string') { + return `"query.getAlias" must be a string`; + } + } else { + return `"query" must be a valid object`; + } + } + if (mutation !== null) { + if (isValidSimpleObject(mutation)) { + const { + create = null, + update = null, + destroy = null, + createAlias = null, + updateAlias = null, + destroyAlias = null, + ...invalidKeys + } = mutation; + if (Object.keys(invalidKeys).length) { + return `"mutation" contains invalid keys, [${Object.keys( + invalidKeys + )}]`; + } + if (create !== null && typeof create !== 'boolean') { + return `"mutation.create" must be a boolean`; + } + if (update !== null && typeof update !== 'boolean') { + return `"mutation.update" must be a boolean`; + } + if (destroy !== null && typeof destroy !== 'boolean') { + return `"mutation.destroy" must be a boolean`; + } + if (createAlias !== null && typeof createAlias !== 'string') { + return `"mutation.createAlias" must be a string`; + } + if (updateAlias !== null && typeof updateAlias !== 'string') { + return `"mutation.updateAlias" must be a string`; + } + if (destroyAlias !== null && typeof destroyAlias !== 'string') { + return `"mutation.destroyAlias" must be a string`; + } + } else { + return `"mutation" must be a valid object`; + } + } + } + } +} + +const isValidStringArray = function(array): boolean { + return Array.isArray(array) + ? !array.some(s => typeof s !== 'string' || s.trim().length < 1) + : false; +}; +/** + * Ensures the obj is a simple JSON/{} + * object, i.e. not an array, null, date + * etc. + */ +const isValidSimpleObject = function(obj): boolean { + return ( + typeof obj === 'object' && + !Array.isArray(obj) && + obj !== null && + obj instanceof Date !== true && + obj instanceof Promise !== true + ); +}; + +export interface ParseGraphQLConfig { + enabledForClasses?: string[]; + disabledForClasses?: string[]; + classConfigs?: ParseGraphQLClassConfig[]; +} + +export interface ParseGraphQLClassConfig { + className: string; + /* The `type` object contains options for how the class types are generated */ + type: ?{ + /* Fields that are allowed when creating or updating an object. */ + inputFields: ?{ + /* Leave blank to allow all available fields in the schema. */ + create?: string[], + update?: string[], + }, + /* Fields on the edges that can be resolved from a query, i.e. the Result Type. */ + outputFields: ?(string[]), + /* Fields by which a query can be filtered, i.e. the `where` object. */ + constraintFields: ?(string[]), + /* Fields by which a query can be sorted; */ + sortFields: ?({ + field: string, + asc: boolean, + desc: boolean, + }[]), + }; + /* The `query` object contains options for which class queries are generated */ + query: ?{ + get: ?boolean, + find: ?boolean, + findAlias: ?String, + getAlias: ?String, + }; + /* The `mutation` object contains options for which class mutations are generated */ + mutation: ?{ + create: ?boolean, + update: ?boolean, + // delete is a reserved key word in js + destroy: ?boolean, + createAlias: ?String, + updateAlias: ?String, + destroyAlias: ?String, + }; +} + +export default ParseGraphQLController; +export { GraphQLConfigClassName, GraphQLConfigId, GraphQLConfigKey }; diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 416b0ea4ff..4739235810 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -1,16 +1,24 @@ -import { Parse } from 'parse/node'; -import RestQuery from '../RestQuery'; -import RestWrite from '../RestWrite'; -import { master } from '../Auth'; -import { pushStatusHandler } from '../StatusHandler'; +import { Parse } from 'parse/node'; +import RestQuery from '../RestQuery'; +import RestWrite from '../RestWrite'; +import { master } from '../Auth'; +import { pushStatusHandler } from '../StatusHandler'; import { applyDeviceTokenExists } from '../Push/utils'; export class PushController { - - sendPush(body = {}, where = {}, config, auth, onPushStatusSaved = () => {}, now = new Date()) { + sendPush( + body = {}, + where = {}, + config, + auth, + onPushStatusSaved = () => {}, + now = new Date() + ) { if (!config.hasPushSupport) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Missing push configuration'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Missing push configuration' + ); } // Replace the expiration_time and push_time with a valid Unix epoch milliseconds time @@ -19,13 +27,17 @@ export class PushController { if (body.expiration_time && body.expiration_interval) { throw new Parse.Error( Parse.Error.PUSH_MISCONFIGURED, - 'Both expiration_time and expiration_interval cannot be set'); + 'Both expiration_time and expiration_interval cannot be set' + ); } // Immediate push - if (body.expiration_interval && !body.hasOwnProperty('push_time')) { + if ( + body.expiration_interval && + !Object.prototype.hasOwnProperty.call(body, 'push_time') + ) { const ttlMs = body.expiration_interval * 1000; - body.expiration_time = (new Date(now.valueOf() + ttlMs)).valueOf(); + body.expiration_time = new Date(now.valueOf() + ttlMs).valueOf(); } const pushTime = PushController.getPushTime(body); @@ -37,61 +49,99 @@ export class PushController { // pushes to be sent. We probably change this behaviour in the future. let badgeUpdate = () => { return Promise.resolve(); - } + }; if (body.data && body.data.badge) { const badge = body.data.badge; let restUpdate = {}; if (typeof badge == 'string' && badge.toLowerCase() === 'increment') { - restUpdate = { badge: { __op: 'Increment', amount: 1 } } + restUpdate = { badge: { __op: 'Increment', amount: 1 } }; + } else if ( + typeof badge == 'object' && + typeof badge.__op == 'string' && + badge.__op.toLowerCase() == 'increment' && + Number(badge.amount) + ) { + restUpdate = { badge: { __op: 'Increment', amount: badge.amount } }; } else if (Number(badge)) { - restUpdate = { badge: badge } + restUpdate = { badge: badge }; } else { - throw "Invalid value for badge, expected number or 'Increment'"; + throw "Invalid value for badge, expected number or 'Increment' or {increment: number}"; } // Force filtering on only valid device tokens const updateWhere = applyDeviceTokenExists(where); badgeUpdate = () => { // Build a real RestQuery so we can use it in RestWrite - const restQuery = new RestQuery(config, master(config), '_Installation', updateWhere); + const restQuery = new RestQuery( + config, + master(config), + '_Installation', + updateWhere + ); return restQuery.buildRestWhere().then(() => { - const write = new RestWrite(config, master(config), '_Installation', restQuery.restWhere, restUpdate); + const write = new RestWrite( + config, + master(config), + '_Installation', + restQuery.restWhere, + restUpdate + ); write.runOptions.many = true; return write.execute(); }); - } + }; } const pushStatus = pushStatusHandler(config); - return Promise.resolve().then(() => { - return pushStatus.setInitial(body, where); - }).then(() => { - onPushStatusSaved(pushStatus.objectId); - return badgeUpdate(); - }).then(() => { - // Update audience lastUsed and timesUsed - if (body.audience_id) { - const audienceId = body.audience_id; - - var updateAudience = { - lastUsed: { __type: "Date", iso: new Date().toISOString() }, - timesUsed: { __op: "Increment", "amount": 1 } - }; - const write = new RestWrite(config, master(config), '_Audience', {objectId: audienceId}, updateAudience); - write.execute(); - } - // Don't wait for the audience update promise to resolve. - return Promise.resolve(); - }).then(() => { - if (body.hasOwnProperty('push_time') && config.hasPushScheduledSupport) { + return Promise.resolve() + .then(() => { + return pushStatus.setInitial(body, where); + }) + .then(() => { + onPushStatusSaved(pushStatus.objectId); + return badgeUpdate(); + }) + .then(() => { + // Update audience lastUsed and timesUsed + if (body.audience_id) { + const audienceId = body.audience_id; + + var updateAudience = { + lastUsed: { __type: 'Date', iso: new Date().toISOString() }, + timesUsed: { __op: 'Increment', amount: 1 }, + }; + const write = new RestWrite( + config, + master(config), + '_Audience', + { objectId: audienceId }, + updateAudience + ); + write.execute(); + } + // Don't wait for the audience update promise to resolve. return Promise.resolve(); - } - return config.pushControllerQueue.enqueue(body, where, config, auth, pushStatus); - }).catch((err) => { - return pushStatus.fail(err).then(() => { - throw err; + }) + .then(() => { + if ( + Object.prototype.hasOwnProperty.call(body, 'push_time') && + config.hasPushScheduledSupport + ) { + return Promise.resolve(); + } + return config.pushControllerQueue.enqueue( + body, + where, + config, + auth, + pushStatus + ); + }) + .catch(err => { + return pushStatus.fail(err).then(() => { + throw err; + }); }); - }); } /** @@ -100,7 +150,10 @@ export class PushController { * @returns {Number|undefined} The expiration time if it exists in the request */ static getExpirationTime(body = {}) { - var hasExpirationTime = body.hasOwnProperty('expiration_time'); + var hasExpirationTime = Object.prototype.hasOwnProperty.call( + body, + 'expiration_time' + ); if (!hasExpirationTime) { return; } @@ -111,27 +164,39 @@ export class PushController { } else if (typeof expirationTimeParam === 'string') { expirationTime = new Date(expirationTimeParam); } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['expiration_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.' + ); } // Check expirationTime is valid or not, if it is not valid, expirationTime is NaN if (!isFinite(expirationTime)) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['expiration_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.' + ); } return expirationTime.valueOf(); } static getExpirationInterval(body = {}) { - const hasExpirationInterval = body.hasOwnProperty('expiration_interval'); + const hasExpirationInterval = Object.prototype.hasOwnProperty.call( + body, + 'expiration_interval' + ); if (!hasExpirationInterval) { return; } var expirationIntervalParam = body['expiration_interval']; - if (typeof expirationIntervalParam !== 'number' || expirationIntervalParam <= 0) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - `expiration_interval must be a number greater than 0`); + if ( + typeof expirationIntervalParam !== 'number' || + expirationIntervalParam <= 0 + ) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + `expiration_interval must be a number greater than 0` + ); } return expirationIntervalParam; } @@ -142,7 +207,7 @@ export class PushController { * @returns {Number|undefined} The push time if it exists in the request */ static getPushTime(body = {}) { - var hasPushTime = body.hasOwnProperty('push_time'); + var hasPushTime = Object.prototype.hasOwnProperty.call(body, 'push_time'); if (!hasPushTime) { return; } @@ -156,13 +221,17 @@ export class PushController { isLocalTime = !PushController.pushTimeHasTimezoneComponent(pushTimeParam); date = new Date(pushTimeParam); } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['push_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['push_time'] + ' is not valid time.' + ); } // Check pushTime is valid or not, if it is not valid, pushTime is NaN if (!isFinite(date)) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['push_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['push_time'] + ' is not valid time.' + ); } return { @@ -178,8 +247,10 @@ export class PushController { */ static pushTimeHasTimezoneComponent(pushTimeParam: string): boolean { const offsetPattern = /(.+)([+-])\d\d:\d\d$/; - return pushTimeParam.indexOf('Z') === pushTimeParam.length - 1 // 2007-04-05T12:30Z - || offsetPattern.test(pushTimeParam); // 2007-04-05T12:30.000+02:00, 2007-04-05T12:30.000-02:00 + return ( + pushTimeParam.indexOf('Z') === pushTimeParam.length - 1 || // 2007-04-05T12:30Z + offsetPattern.test(pushTimeParam) + ); // 2007-04-05T12:30.000+02:00, 2007-04-05T12:30.000-02:00 } /** @@ -188,8 +259,15 @@ export class PushController { * @param isLocalTime {boolean} * @returns {string} */ - static formatPushTime({ date, isLocalTime }: { date: Date, isLocalTime: boolean }) { - if (isLocalTime) { // Strip 'Z' + static formatPushTime({ + date, + isLocalTime, + }: { + date: Date, + isLocalTime: boolean, + }) { + if (isLocalTime) { + // Strip 'Z' const isoString = date.toISOString(); return isoString.substring(0, isoString.indexOf('Z')); } diff --git a/src/Controllers/SchemaCache.js b/src/Controllers/SchemaCache.js index 8284d16f7e..55d70eabaa 100644 --- a/src/Controllers/SchemaCache.js +++ b/src/Controllers/SchemaCache.js @@ -1,6 +1,5 @@ -const MAIN_SCHEMA = "__MAIN_SCHEMA"; -const SCHEMA_CACHE_PREFIX = "__SCHEMA"; -const ALL_KEYS = "__ALL_KEYS"; +const MAIN_SCHEMA = '__MAIN_SCHEMA'; +const SCHEMA_CACHE_PREFIX = '__SCHEMA'; import { randomString } from '../cryptoUtils'; import defaults from '../defaults'; @@ -8,7 +7,11 @@ import defaults from '../defaults'; export default class SchemaCache { cache: Object; - constructor(cacheController, ttl = defaults.schemaCacheTTL, singleCache = false) { + constructor( + cacheController, + ttl = defaults.schemaCacheTTL, + singleCache = false + ) { this.ttl = ttl; if (typeof ttl == 'string') { this.ttl = parseInt(ttl); @@ -20,14 +23,6 @@ export default class SchemaCache { } } - put(key, value) { - return this.cache.get(this.prefix + ALL_KEYS).then((allKeys) => { - allKeys = allKeys || {}; - allKeys[key] = true; - return Promise.all([this.cache.put(this.prefix + ALL_KEYS, allKeys, this.ttl), this.cache.put(key, value, this.ttl)]); - }); - } - getAllClasses() { if (!this.ttl) { return Promise.resolve(null); @@ -39,47 +34,26 @@ export default class SchemaCache { if (!this.ttl) { return Promise.resolve(null); } - return this.put(this.prefix + MAIN_SCHEMA, schema); - } - - setOneSchema(className, schema) { - if (!this.ttl) { - return Promise.resolve(null); - } - return this.put(this.prefix + className, schema); + return this.cache.put(this.prefix + MAIN_SCHEMA, schema); } getOneSchema(className) { if (!this.ttl) { return Promise.resolve(null); } - return this.cache.get(this.prefix + className).then((schema) => { + return this.cache.get(this.prefix + MAIN_SCHEMA).then(cachedSchemas => { + cachedSchemas = cachedSchemas || []; + const schema = cachedSchemas.find(cachedSchema => { + return cachedSchema.className === className; + }); if (schema) { return Promise.resolve(schema); } - return this.cache.get(this.prefix + MAIN_SCHEMA).then((cachedSchemas) => { - cachedSchemas = cachedSchemas || []; - schema = cachedSchemas.find((cachedSchema) => { - return cachedSchema.className === className; - }); - if (schema) { - return Promise.resolve(schema); - } - return Promise.resolve(null); - }); + return Promise.resolve(null); }); } clear() { - // That clears all caches... - return this.cache.get(this.prefix + ALL_KEYS).then((allKeys) => { - if (!allKeys) { - return; - } - const promises = Object.keys(allKeys).map((key) => { - return this.cache.del(key); - }); - return Promise.all(promises); - }); + return this.cache.del(this.prefix + MAIN_SCHEMA); } } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index d1c15fe498..435a4b5570 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -1,3 +1,4 @@ +// @flow // This class handles schema validation, persistence, and modification. // // Each individual Schema object should be immutable. The helpers to @@ -13,185 +14,413 @@ // DatabaseController. This will let us replace the schema logic for // different databases. // TODO: hide all schema logic inside the database adapter. +// @flow-disable-next const Parse = require('parse/node').Parse; - -const defaultColumns = Object.freeze({ +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import DatabaseController from './DatabaseController'; +import Config from '../Config'; +// @flow-disable-next +import deepcopy from 'deepcopy'; +import type { + Schema, + SchemaFields, + ClassLevelPermissions, + SchemaField, + LoadSchemaOptions, +} from './types'; + +const defaultColumns: { [string]: SchemaFields } = Object.freeze({ // Contain the default columns for every parse object type (except _Join collection) _Default: { - "objectId": {type:'String'}, - "createdAt": {type:'Date'}, - "updatedAt": {type:'Date'}, - "ACL": {type:'ACL'}, + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, }, // The additional default columns for the _User collection (in addition to DefaultCols) _User: { - "username": {type:'String'}, - "password": {type:'String'}, - "email": {type:'String'}, - "emailVerified": {type:'Boolean'}, - "authData": {type:'Object'} + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { - "installationId": {type:'String'}, - "deviceToken": {type:'String'}, - "channels": {type:'Array'}, - "deviceType": {type:'String'}, - "pushType": {type:'String'}, - "GCMSenderId": {type:'String'}, - "timeZone": {type:'String'}, - "localeIdentifier": {type:'String'}, - "badge": {type:'Number'}, - "appVersion": {type:'String'}, - "appName": {type:'String'}, - "appIdentifier": {type:'String'}, - "parseVersion": {type:'String'}, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, }, // The additional default columns for the _Role collection (in addition to DefaultCols) _Role: { - "name": {type:'String'}, - "users": {type:'Relation', targetClass:'_User'}, - "roles": {type:'Relation', targetClass:'_Role'} + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, }, // The additional default columns for the _Session collection (in addition to DefaultCols) _Session: { - "restricted": {type:'Boolean'}, - "user": {type:'Pointer', targetClass:'_User'}, - "installationId": {type:'String'}, - "sessionToken": {type:'String'}, - "expiresAt": {type:'Date'}, - "createdWith": {type:'Object'} + restricted: { type: 'Boolean' }, + user: { type: 'Pointer', targetClass: '_User' }, + installationId: { type: 'String' }, + sessionToken: { type: 'String' }, + expiresAt: { type: 'Date' }, + createdWith: { type: 'Object' }, }, _Product: { - "productIdentifier": {type:'String'}, - "download": {type:'File'}, - "downloadName": {type:'String'}, - "icon": {type:'File'}, - "order": {type:'Number'}, - "title": {type:'String'}, - "subtitle": {type:'String'}, + productIdentifier: { type: 'String' }, + download: { type: 'File' }, + downloadName: { type: 'String' }, + icon: { type: 'File' }, + order: { type: 'Number' }, + title: { type: 'String' }, + subtitle: { type: 'String' }, }, _PushStatus: { - "pushTime": {type:'String'}, - "source": {type:'String'}, // rest or webui - "query": {type:'String'}, // the stringified JSON query - "payload": {type:'String'}, // the stringified JSON payload, - "title": {type:'String'}, - "expiry": {type:'Number'}, - "expiration_interval": {type:'Number'}, - "status": {type:'String'}, - "numSent": {type:'Number'}, - "numFailed": {type:'Number'}, - "pushHash": {type:'String'}, - "errorMessage": {type:'Object'}, - "sentPerType": {type:'Object'}, - "failedPerType": {type:'Object'}, - "sentPerUTCOffset": {type:'Object'}, - "failedPerUTCOffset": {type:'Object'}, - "count": {type:'Number'} + pushTime: { type: 'String' }, + source: { type: 'String' }, // rest or webui + query: { type: 'String' }, // the stringified JSON query + payload: { type: 'String' }, // the stringified JSON payload, + title: { type: 'String' }, + expiry: { type: 'Number' }, + expiration_interval: { type: 'Number' }, + status: { type: 'String' }, + numSent: { type: 'Number' }, + numFailed: { type: 'Number' }, + pushHash: { type: 'String' }, + errorMessage: { type: 'Object' }, + sentPerType: { type: 'Object' }, + failedPerType: { type: 'Object' }, + sentPerUTCOffset: { type: 'Object' }, + failedPerUTCOffset: { type: 'Object' }, + count: { type: 'Number' }, // tracks # of batches queued and pending }, _JobStatus: { - "jobName": {type: 'String'}, - "source": {type: 'String'}, - "status": {type: 'String'}, - "message": {type: 'String'}, - "params": {type: 'Object'}, // params received when calling the job - "finishedAt": {type: 'Date'} + jobName: { type: 'String' }, + source: { type: 'String' }, + status: { type: 'String' }, + message: { type: 'String' }, + params: { type: 'Object' }, // params received when calling the job + finishedAt: { type: 'Date' }, }, _JobSchedule: { - "jobName": {type:'String'}, - "description": {type:'String'}, - "params": {type:'String'}, - "startAfter": {type:'String'}, - "daysOfWeek": {type:'Array'}, - "timeOfDay": {type:'String'}, - "lastRun": {type:'Number'}, - "repeatMinutes":{type:'Number'} + jobName: { type: 'String' }, + description: { type: 'String' }, + params: { type: 'String' }, + startAfter: { type: 'String' }, + daysOfWeek: { type: 'Array' }, + timeOfDay: { type: 'String' }, + lastRun: { type: 'Number' }, + repeatMinutes: { type: 'Number' }, }, _Hooks: { - "functionName": {type:'String'}, - "className": {type:'String'}, - "triggerName": {type:'String'}, - "url": {type:'String'} + functionName: { type: 'String' }, + className: { type: 'String' }, + triggerName: { type: 'String' }, + url: { type: 'String' }, }, _GlobalConfig: { - "objectId": {type: 'String'}, - "params": {type: 'Object'} + objectId: { type: 'String' }, + params: { type: 'Object' }, + masterKeyOnly: { type: 'Object' }, + }, + _GraphQLConfig: { + objectId: { type: 'String' }, + config: { 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 - "lastUsed": {type:'Date'}, - "timesUsed": {type:'Number'} - } + 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 + lastUsed: { type: 'Date' }, + timesUsed: { type: 'Number' }, + }, }); const requiredColumns = Object.freeze({ - _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], - _Role: ["name", "ACL"] + _Product: ['productIdentifier', 'icon', 'order', 'title', 'subtitle'], + _Role: ['name', 'ACL'], }); -const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule', '_Audience']); +const systemClasses = Object.freeze([ + '_User', + '_Installation', + '_Role', + '_Session', + '_Product', + '_PushStatus', + '_JobStatus', + '_JobSchedule', + '_Audience', +]); + +const volatileClasses = Object.freeze([ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', +]); -const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule', '_Audience']); - -// 10 alpha numberic chars + uppercase -const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role const roleRegex = /^role:.*/; +// Anything that starts with userField (allowed for protected fields only) +const protectedFieldsPointerRegex = /^userField:.*/; // * permission -const publicRegex = /^\*$/ +const publicRegex = /^\*$/; + +const authenticatedRegex = /^authenticated$/; + +const requiresAuthenticationRegex = /^requiresAuthentication$/; + +const clpPointerRegex = /^pointerFields$/; + +// regex for validating entities in protectedFields object +const protectedFieldsRegex = Object.freeze([ + protectedFieldsPointerRegex, + publicRegex, + authenticatedRegex, + roleRegex, +]); + +// clp regex +const clpFieldsRegex = Object.freeze([ + clpPointerRegex, + publicRegex, + requiresAuthenticationRegex, + roleRegex, +]); + +function validatePermissionKey(key, userIdRegExp) { + let matchesSome = false; + for (const regEx of clpFieldsRegex) { + if (key.match(regEx) !== null) { + matchesSome = true; + break; + } + } -const requireAuthenticationRegex = /^requiresAuthentication$/ + // userId depends on startup options so it's dynamic + const valid = matchesSome || key.match(userIdRegExp) !== null; + if (!valid) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${key}' is not a valid key for class level permissions` + ); + } +} -const permissionKeyRegex = Object.freeze([userIdRegex, roleRegex, publicRegex, requireAuthenticationRegex]); +function validateProtectedFieldsKey(key, userIdRegExp) { + let matchesSome = false; + for (const regEx of protectedFieldsRegex) { + if (key.match(regEx) !== null) { + matchesSome = true; + break; + } + } -function verifyPermissionKey(key) { - const result = permissionKeyRegex.reduce((isGood, regEx) => { - isGood = isGood || key.match(regEx) != null; - return isGood; - }, false); - if (!result) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid key for class level permissions`); + // userId regex depends on launch options so it's dynamic + const valid = matchesSome || key.match(userIdRegExp) !== null; + if (!valid) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${key}' is not a valid key for class level permissions` + ); } } -const CLPValidKeys = Object.freeze(['find', 'count', 'get', 'create', 'update', 'delete', 'addField', 'readUserFields', 'writeUserFields']); -function validateCLP(perms, fields) { +const CLPValidKeys = Object.freeze([ + 'find', + 'count', + 'get', + 'create', + 'update', + 'delete', + 'addField', + 'readUserFields', + 'writeUserFields', + 'protectedFields', +]); + +// validation before setting class-level permissions on collection +function validateCLP( + perms: ClassLevelPermissions, + fields: SchemaFields, + userIdRegExp: RegExp +) { if (!perms) { return; } - Object.keys(perms).forEach((operation) => { - if (CLPValidKeys.indexOf(operation) == -1) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `${operation} is not a valid operation for class level permissions`); + for (const operationKey in perms) { + if (CLPValidKeys.indexOf(operationKey) == -1) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `${operationKey} is not a valid operation for class level permissions` + ); + } + + const operation = perms[operationKey]; + // proceed with next operationKey + + // throws when root fields are of wrong type + validateCLPjson(operation, operationKey); + + if ( + operationKey === 'readUserFields' || + operationKey === 'writeUserFields' + ) { + // validate grouped pointer permissions + // must be an array with field names + for (const fieldName of operation) { + validatePointerPermission(fieldName, fields, operationKey); + } + // readUserFields and writerUserFields do not have nesdted fields + // proceed with next operationKey + continue; } - if (operation === 'readUserFields' || operation === 'writeUserFields') { - if (!Array.isArray(perms[operation])) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perms[operation]}' is not a valid value for class level permissions ${operation}`); - } else { - perms[operation].forEach((key) => { - if (!fields[key] || fields[key].type != 'Pointer' || fields[key].targetClass != '_User') { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid column for class level pointer permissions ${operation}`); + // validate protected fields + if (operationKey === 'protectedFields') { + for (const entity in operation) { + // throws on unexpected key + validateProtectedFieldsKey(entity, userIdRegExp); + + const protectedFields = operation[entity]; + + if (!Array.isArray(protectedFields)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${protectedFields}' is not a valid value for protectedFields[${entity}] - expected an array.` + ); + } + + // if the field is in form of array + for (const field of protectedFields) { + // do not alloow to protect default fields + if (defaultColumns._Default[field]) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Default field '${field}' can not be protected` + ); } - }); + // field should exist on collection + if (!Object.prototype.hasOwnProperty.call(fields, field)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Field '${field}' in protectedFields:${entity} does not exist` + ); + } + } } - return; + // proceed with next operationKey + continue; } - Object.keys(perms[operation]).forEach((key) => { - verifyPermissionKey(key); - const perm = perms[operation][key]; - if (perm !== true) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perm}' is not a valid value for class level permissions ${operation}:${key}:${perm}`); + // validate other fields + // Entity can be: + // "*" - Public, + // "requiresAuthentication" - authenticated users, + // "objectId" - _User id, + // "role:rolename", + // "pointerFields" - array of field names containing pointers to users + for (const entity in operation) { + // throws on unexpected key + validatePermissionKey(entity, userIdRegExp); + + // entity can be either: + // "pointerFields": string[] + if (entity === 'pointerFields') { + const pointerFields = operation[entity]; + + if (Array.isArray(pointerFields)) { + for (const pointerField of pointerFields) { + validatePointerPermission(pointerField, fields, operation); + } + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${pointerFields}' is not a valid value for ${operationKey}[${entity}] - expected an array.` + ); + } + // proceed with next entity key + continue; } - }); - }); + + // or [entity]: boolean + const permit = operation[entity]; + + if (permit !== true) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${permit}' is not a valid value for class level permissions ${operationKey}:${entity}:${permit}` + ); + } + } + } } + +function validateCLPjson(operation: any, operationKey: string) { + if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') { + if (!Array.isArray(operation)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ); + } + } else { + if (typeof operation === 'object' && operation !== null) { + // ok to proceed + return; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ); + } + } +} + +function validatePointerPermission( + fieldName: string, + fields: Object, + operation: string +) { + // Uses collection schema to ensure the field is of type: + // - Pointer<_User> (pointers) + // - Array + // + // It's not possible to enforce type on Array's items in schema + // so we accept any Array field, and later when applying permissions + // only items that are pointers to _User are considered. + if ( + !( + fields[fieldName] && + ((fields[fieldName].type == 'Pointer' && + fields[fieldName].targetClass == '_User') || + fields[fieldName].type == 'Array') + ) + ) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${fieldName}' is not a valid column for class level pointer permissions ${operation}` + ); + } +} + const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; const classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/; -function classNameIsValid(className) { +function classNameIsValid(className: string): boolean { // Valid classes must: return ( // Be one of _User, _Installation, _Role, _Session OR @@ -204,12 +433,15 @@ function classNameIsValid(className) { } // Valid fields must be alpha-numeric, and not start with an underscore or number -function fieldNameIsValid(fieldName) { +function fieldNameIsValid(fieldName: string): boolean { return classAndFieldRegex.test(fieldName); } // Checks that it's not trying to clobber one of the default fields of the class. -function fieldNameIsValidForClass(fieldName, className) { +function fieldNameIsValidForClass( + fieldName: string, + className: string +): boolean { if (!fieldNameIsValid(fieldName)) { return false; } @@ -222,11 +454,18 @@ function fieldNameIsValidForClass(fieldName, className) { return true; } -function invalidClassNameMessage(className) { - return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character '; +function invalidClassNameMessage(className: string): string { + return ( + 'Invalid classname: ' + + className + + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); } -const invalidJsonError = new Parse.Error(Parse.Error.INVALID_JSON, "invalid JSON"); +const invalidJsonError = new Parse.Error( + Parse.Error.INVALID_JSON, + 'invalid JSON' +); const validNonRelationOrPointerTypes = [ 'Number', 'String', @@ -237,7 +476,7 @@ const validNonRelationOrPointerTypes = [ 'GeoPoint', 'File', 'Bytes', - 'Polygon' + 'Polygon', ]; // Returns an error suitable for throwing if the type is invalid const fieldTypeIsInvalid = ({ type, targetClass }) => { @@ -247,7 +486,10 @@ const fieldTypeIsInvalid = ({ type, targetClass }) => { } else if (typeof targetClass !== 'string') { return invalidJsonError; } else if (!classNameIsValid(targetClass)) { - return new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(targetClass)); + return new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + invalidClassNameMessage(targetClass) + ); } else { return undefined; } @@ -256,12 +498,15 @@ const fieldTypeIsInvalid = ({ type, targetClass }) => { return invalidJsonError; } if (validNonRelationOrPointerTypes.indexOf(type) < 0) { - return new Parse.Error(Parse.Error.INCORRECT_TYPE, `invalid field type: ${type}`); + return new Parse.Error( + Parse.Error.INCORRECT_TYPE, + `invalid field type: ${type}` + ); } return undefined; -} +}; -const convertSchemaToAdapterSchema = schema => { +const convertSchemaToAdapterSchema = (schema: any) => { schema = injectDefaultSchema(schema); delete schema.fields.ACL; schema.fields._rperm = { type: 'Array' }; @@ -273,9 +518,9 @@ const convertSchemaToAdapterSchema = schema => { } return schema; -} +}; -const convertAdapterSchemaToParseSchema = ({...schema}) => { +const convertAdapterSchemaToParseSchema = ({ ...schema }) => { delete schema.fields._rperm; delete schema.fields._wperm; @@ -287,157 +532,271 @@ const convertAdapterSchemaToParseSchema = ({...schema}) => { schema.fields.password = { type: 'String' }; } + if (schema.indexes && Object.keys(schema.indexes).length === 0) { + delete schema.indexes; + } + return schema; +}; + +class SchemaData { + __data: any; + __protectedFields: any; + constructor(allSchemas = [], protectedFields = {}) { + this.__data = {}; + this.__protectedFields = protectedFields; + allSchemas.forEach(schema => { + if (volatileClasses.includes(schema.className)) { + return; + } + Object.defineProperty(this, schema.className, { + get: () => { + if (!this.__data[schema.className]) { + const data = {}; + data.fields = injectDefaultSchema(schema).fields; + data.classLevelPermissions = deepcopy(schema.classLevelPermissions); + data.indexes = schema.indexes; + + const classProtectedFields = this.__protectedFields[ + schema.className + ]; + if (classProtectedFields) { + for (const key in classProtectedFields) { + const unq = new Set([ + ...(data.classLevelPermissions.protectedFields[key] || []), + ...classProtectedFields[key], + ]); + data.classLevelPermissions.protectedFields[key] = Array.from( + unq + ); + } + } + + this.__data[schema.className] = data; + } + return this.__data[schema.className]; + }, + }); + }); + + // Inject the in-memory classes + volatileClasses.forEach(className => { + Object.defineProperty(this, className, { + get: () => { + if (!this.__data[className]) { + const schema = injectDefaultSchema({ + className, + fields: {}, + classLevelPermissions: {}, + }); + const data = {}; + data.fields = schema.fields; + data.classLevelPermissions = schema.classLevelPermissions; + data.indexes = schema.indexes; + this.__data[className] = data; + } + return this.__data[className]; + }, + }); + }); + } } -const injectDefaultSchema = ({className, fields, classLevelPermissions}) => ({ +const injectDefaultSchema = ({ className, - fields: { - ...defaultColumns._Default, - ...(defaultColumns[className] || {}), - ...fields, - }, + fields, classLevelPermissions, -}); + indexes, +}: Schema) => { + const defaultSchema: Schema = { + className, + fields: { + ...defaultColumns._Default, + ...(defaultColumns[className] || {}), + ...fields, + }, + classLevelPermissions, + }; + if (indexes && Object.keys(indexes).length !== 0) { + defaultSchema.indexes = indexes; + } + return defaultSchema; +}; + +const _HooksSchema = { className: '_Hooks', fields: defaultColumns._Hooks }; +const _GlobalConfigSchema = { + className: '_GlobalConfig', + fields: defaultColumns._GlobalConfig, +}; +const _GraphQLConfigSchema = { + className: '_GraphQLConfig', + fields: defaultColumns._GraphQLConfig, +}; +const _PushStatusSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_PushStatus', + fields: {}, + classLevelPermissions: {}, + }) +); +const _JobStatusSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_JobStatus', + fields: {}, + classLevelPermissions: {}, + }) +); +const _JobScheduleSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_JobSchedule', + fields: {}, + classLevelPermissions: {}, + }) +); +const _AudienceSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_Audience', + fields: defaultColumns._Audience, + classLevelPermissions: {}, + }) +); +const VolatileClassesSchemas = [ + _HooksSchema, + _JobStatusSchema, + _JobScheduleSchema, + _PushStatusSchema, + _GlobalConfigSchema, + _GraphQLConfigSchema, + _AudienceSchema, +]; -const _HooksSchema = {className: "_Hooks", fields: defaultColumns._Hooks}; -const _GlobalConfigSchema = { className: "_GlobalConfig", fields: defaultColumns._GlobalConfig } -const _PushStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ - className: "_PushStatus", - fields: {}, - classLevelPermissions: {} -})); -const _JobStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ - className: "_JobStatus", - fields: {}, - classLevelPermissions: {} -})); -const _JobScheduleSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ - className: "_JobSchedule", - fields: {}, - classLevelPermissions: {} -})); -const _AudienceSchema = convertSchemaToAdapterSchema(injectDefaultSchema({ - className: "_Audience", - fields: defaultColumns._Audience -})); -const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema, _AudienceSchema]; - -const dbTypeMatchesObjectType = (dbType, objectType) => { +const dbTypeMatchesObjectType = ( + dbType: SchemaField | string, + objectType: SchemaField +) => { if (dbType.type !== objectType.type) return false; if (dbType.targetClass !== objectType.targetClass) return false; if (dbType === objectType.type) return true; if (dbType.type === objectType.type) return true; return false; -} +}; -const typeToString = (type) => { +const typeToString = (type: SchemaField | string): string => { + if (typeof type === 'string') { + return type; + } if (type.targetClass) { return `${type.type}<${type.targetClass}>`; } - return `${type.type || type}`; -} + return `${type.type}`; +}; // Stores the entire schema of the app in a weird hybrid format somewhere between // the mongo format and the Parse format. Soon, this will all be Parse format. export default class SchemaController { - _dbAdapter; - data; - perms; - - constructor(databaseAdapter, schemaCache) { + _dbAdapter: StorageAdapter; + schemaData: { [string]: Schema }; + _cache: any; + reloadDataPromise: ?Promise; + protectedFields: any; + userIdRegEx: RegExp; + + constructor(databaseAdapter: StorageAdapter, schemaCache: any) { this._dbAdapter = databaseAdapter; this._cache = schemaCache; - // this.data[className][fieldName] tells you the type of that field, in mongo format - this.data = {}; - // this.perms[className][operation] tells you the acl-style permissions - this.perms = {}; + this.schemaData = new SchemaData(); + this.protectedFields = Config.get(Parse.applicationId).protectedFields; + + const customIds = Config.get(Parse.applicationId).allowCustomObjectId; + + const customIdRegEx = /^.{1,}$/u; // 1+ chars + const autoIdRegEx = /^[a-zA-Z0-9]{1,}$/; + + this.userIdRegEx = customIds ? customIdRegEx : autoIdRegEx; } - reloadData(options = {clearCache: false}) { - let promise = Promise.resolve(); - if (options.clearCache) { - promise = promise.then(() => { - return this._cache.clear(); - }); - } + reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise { if (this.reloadDataPromise && !options.clearCache) { return this.reloadDataPromise; } - this.reloadDataPromise = promise.then(() => { - return this.getAllClasses(options); - }) - .then(allSchemas => { - const data = {}; - const perms = {}; - allSchemas.forEach(schema => { - data[schema.className] = injectDefaultSchema(schema).fields; - perms[schema.className] = schema.classLevelPermissions; - }); - - // Inject the in-memory classes - volatileClasses.forEach(className => { - const schema = injectDefaultSchema({ className }); - data[className] = schema.fields; - perms[className] = schema.classLevelPermissions; - }); - this.data = data; - this.perms = perms; - delete this.reloadDataPromise; - }, (err) => { - this.data = {}; - this.perms = {}; - delete this.reloadDataPromise; - throw err; - }); + this.reloadDataPromise = this.getAllClasses(options) + .then( + allSchemas => { + this.schemaData = new SchemaData(allSchemas, this.protectedFields); + delete this.reloadDataPromise; + }, + err => { + this.schemaData = new SchemaData(); + delete this.reloadDataPromise; + throw err; + } + ) + .then(() => {}); return this.reloadDataPromise; } - getAllClasses(options = {clearCache: false}) { - let promise = Promise.resolve(); + getAllClasses( + options: LoadSchemaOptions = { clearCache: false } + ): Promise> { if (options.clearCache) { - promise = this._cache.clear(); + return this.setAllClasses(); } - return promise.then(() => { - return this._cache.getAllClasses() - }).then((allClasses) => { - if (allClasses && allClasses.length && !options.clearCache) { + return this._cache.getAllClasses().then(allClasses => { + if (allClasses && allClasses.length) { return Promise.resolve(allClasses); } - return this._dbAdapter.getAllClasses() - .then(allSchemas => allSchemas.map(injectDefaultSchema)) - .then(allSchemas => { - return this._cache.setAllClasses(allSchemas).then(() => { - return allSchemas; - }); - }) + return this.setAllClasses(); }); } - getOneSchema(className, allowVolatileClasses = false, options = {clearCache: false}) { + setAllClasses(): Promise> { + return this._dbAdapter + .getAllClasses() + .then(allSchemas => allSchemas.map(injectDefaultSchema)) + .then(allSchemas => { + /* eslint-disable no-console */ + this._cache + .setAllClasses(allSchemas) + .catch(error => + console.error('Error saving schema to cache:', error) + ); + /* eslint-enable no-console */ + return allSchemas; + }); + } + + getOneSchema( + className: string, + allowVolatileClasses: boolean = false, + options: LoadSchemaOptions = { clearCache: false } + ): Promise { let promise = Promise.resolve(); if (options.clearCache) { promise = this._cache.clear(); } return promise.then(() => { if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) { + const data = this.schemaData[className]; return Promise.resolve({ className, - fields: this.data[className], - classLevelPermissions: this.perms[className] + fields: data.fields, + classLevelPermissions: data.classLevelPermissions, + indexes: data.indexes, }); } - return this._cache.getOneSchema(className).then((cached) => { + return this._cache.getOneSchema(className).then(cached => { if (cached && !options.clearCache) { return Promise.resolve(cached); } - return this._dbAdapter.getClass(className) - .then(injectDefaultSchema) - .then((result) => { - return this._cache.setOneSchema(className, result).then(() => { - return result; - }) - }); + return this.setAllClasses().then(allSchemas => { + const oneSchema = allSchemas.find( + schema => schema.className === className + ); + if (!oneSchema) { + return Promise.reject(undefined); + } + return oneSchema; + }); }); }); } @@ -449,29 +808,58 @@ export default class SchemaController { // 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 = {}, classLevelPermissions) { - var validationError = this.validateNewClass(className, fields, classLevelPermissions); + addClassIfNotExists( + className: string, + fields: SchemaFields = {}, + classLevelPermissions: any, + indexes: any = {} + ): Promise { + var validationError = this.validateNewClass( + className, + fields, + classLevelPermissions + ); if (validationError) { + if (validationError instanceof Parse.Error) { + return Promise.reject(validationError); + } else if (validationError.code && validationError.error) { + return Promise.reject( + new Parse.Error(validationError.code, validationError.error) + ); + } return Promise.reject(validationError); } - return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className })) + return this._dbAdapter + .createClass( + className, + convertSchemaToAdapterSchema({ + fields, + classLevelPermissions, + indexes, + className, + }) + ) .then(convertAdapterSchemaToParseSchema) - .then((res) => { - return this._cache.clear().then(() => { - return Promise.resolve(res); - }); - }) .catch(error => { if (error && error.code === Parse.Error.DUPLICATE_VALUE) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} already exists.` + ); } else { throw error; } }); } - updateClass(className, submittedFields, classLevelPermissions, database) { + updateClass( + className: string, + submittedFields: SchemaFields, + classLevelPermissions: any, + indexes: any, + database: DatabaseController + ) { return this.getOneSchema(className) .then(schema => { const existingFields = schema.fields; @@ -481,21 +869,35 @@ export default class SchemaController { throw new Parse.Error(255, `Field ${name} exists, cannot update.`); } if (!existingFields[name] && field.__op === 'Delete') { - throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); + throw new Parse.Error( + 255, + `Field ${name} does not exist, cannot delete.` + ); } }); delete existingFields._rperm; delete existingFields._wperm; - const newSchema = buildMergedSchemaObject(existingFields, submittedFields); - const validationError = this.validateSchemaData(className, newSchema, classLevelPermissions, Object.keys(existingFields)); + const newSchema = buildMergedSchemaObject( + existingFields, + submittedFields + ); + const defaultFields = + defaultColumns[className] || defaultColumns._Default; + const fullNewSchema = Object.assign({}, newSchema, defaultFields); + const validationError = this.validateSchemaData( + className, + newSchema, + classLevelPermissions, + Object.keys(existingFields) + ); if (validationError) { throw new Parse.Error(validationError.code, validationError.error); } // Finally we have checked to make sure the request is valid and we can start deleting fields. // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. - const deletedFields = []; + const deletedFields: string[] = []; const insertedFields = []; Object.keys(submittedFields).forEach(fieldName => { if (submittedFields[fieldName].__op === 'Delete') { @@ -509,67 +911,111 @@ export default class SchemaController { if (deletedFields.length > 0) { deletePromise = this.deleteFields(deletedFields, className, database); } - - return deletePromise // Delete Everything - .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values - .then(() => { - const promises = insertedFields.map(fieldName => { - const type = submittedFields[fieldName]; - return this.enforceFieldExists(className, fieldName, type); - }); - return Promise.all(promises); - }) - .then(() => this.setPermissions(className, classLevelPermissions, newSchema)) - //TODO: Move this logic into the database adapter - .then(() => ({ - className: className, - fields: this.data[className], - classLevelPermissions: this.perms[className] - })); + let enforceFields = []; + return ( + deletePromise // Delete Everything + .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values + .then(() => { + const promises = insertedFields.map(fieldName => { + const type = submittedFields[fieldName]; + return this.enforceFieldExists(className, fieldName, type); + }); + return Promise.all(promises); + }) + .then(results => { + enforceFields = results.filter(result => !!result); + return this.setPermissions( + className, + classLevelPermissions, + newSchema + ); + }) + .then(() => + this._dbAdapter.setIndexesWithSchemaFormat( + className, + indexes, + schema.indexes, + fullNewSchema + ) + ) + .then(() => this.reloadData({ clearCache: true })) + //TODO: Move this logic into the database adapter + .then(() => { + this.ensureFields(enforceFields); + const schema = this.schemaData[className]; + const reloadedSchema: Schema = { + className: className, + fields: schema.fields, + classLevelPermissions: schema.classLevelPermissions, + }; + if (schema.indexes && Object.keys(schema.indexes).length !== 0) { + reloadedSchema.indexes = schema.indexes; + } + return reloadedSchema; + }) + ); }) .catch(error => { if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} does not exist.` + ); } else { throw error; } - }) + }); } // Returns a promise that resolves successfully to the new schema // object or fails with a reason. - enforceClassExists(className) { - if (this.data[className]) { + enforceClassExists(className: string): Promise { + if (this.schemaData[className]) { return Promise.resolve(this); } // We don't have this class. Update the schema - return this.addClassIfNotExists(className) - // The schema update succeeded. Reload the schema - .then(() => this.reloadData({ clearCache: true })) - .catch(() => { - // 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({ clearCache: true }); - }) - .then(() => { - // Ensure that the schema now validates - if (this.data[className]) { - return this; - } else { - throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`); - } - }) - .catch(() => { - // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate'); - }); + return ( + this.addClassIfNotExists(className) + // The schema update succeeded. Reload the schema + .then(() => this.reloadData({ clearCache: true })) + .catch(() => { + // 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({ clearCache: true }); + }) + .then(() => { + // Ensure that the schema now validates + if (this.schemaData[className]) { + return this; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Failed to add ${className}` + ); + } + }) + .catch(() => { + // The schema still doesn't validate. Give up + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'schema class name does not revalidate' + ); + }) + ); } - validateNewClass(className, fields = {}, classLevelPermissions) { - if (this.data[className]) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + validateNewClass( + className: string, + fields: SchemaFields = {}, + classLevelPermissions: any + ): any { + if (this.schemaData[className]) { + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} already exists.` + ); } if (!classNameIsValid(className)) { return { @@ -577,10 +1023,20 @@ export default class SchemaController { error: invalidClassNameMessage(className), }; } - return this.validateSchemaData(className, fields, classLevelPermissions, []); + return this.validateSchemaData( + className, + fields, + classLevelPermissions, + [] + ); } - validateSchemaData(className, fields, classLevelPermissions, existingFieldNames) { + validateSchemaData( + className: string, + fields: SchemaFields, + classLevelPermissions: ClassLevelPermissions, + existingFieldNames: Array + ) { for (const fieldName in fields) { if (existingFieldNames.indexOf(fieldName) < 0) { if (!fieldNameIsValid(fieldName)) { @@ -595,8 +1051,42 @@ export default class SchemaController { error: 'field ' + fieldName + ' cannot be added', }; } - const error = fieldTypeIsInvalid(fields[fieldName]); + const fieldType = fields[fieldName]; + const error = fieldTypeIsInvalid(fieldType); if (error) return { code: error.code, error: error.message }; + if (fieldType.defaultValue !== undefined) { + let defaultValueType = getType(fieldType.defaultValue); + if (typeof defaultValueType === 'string') { + defaultValueType = { type: defaultValueType }; + } else if ( + typeof defaultValueType === 'object' && + fieldType.type === 'Relation' + ) { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `The 'default value' option is not applicable for ${typeToString( + fieldType + )}`, + }; + } + if (!dbTypeMatchesObjectType(fieldType, defaultValueType)) { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString( + fieldType + )} but got ${typeToString(defaultValueType)}`, + }; + } + } else if (fieldType.required) { + if (typeof fieldType === 'object' && fieldType.type === 'Relation') { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `The 'required' option is not applicable for ${typeToString( + fieldType + )}`, + }; + } + } } } @@ -604,65 +1094,93 @@ export default class SchemaController { fields[fieldName] = defaultColumns[className][fieldName]; } - const geoPoints = Object.keys(fields).filter(key => fields[key] && fields[key].type === 'GeoPoint'); + const geoPoints = Object.keys(fields).filter( + key => fields[key] && fields[key].type === 'GeoPoint' + ); if (geoPoints.length > 1) { return { code: Parse.Error.INCORRECT_TYPE, - error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', + error: + 'currently, only one GeoPoint field may exist in an object. Adding ' + + geoPoints[1] + + ' when ' + + geoPoints[0] + + ' already exists.', }; } - validateCLP(classLevelPermissions, fields); + validateCLP(classLevelPermissions, fields, this.userIdRegEx); } // Sets the Class-level permissions for a given className, which must exist. - setPermissions(className, perms, newSchema) { + setPermissions(className: string, perms: any, newSchema: SchemaFields) { if (typeof perms === 'undefined') { return Promise.resolve(); } - validateCLP(perms, newSchema); - return this._dbAdapter.setClassLevelPermissions(className, perms) - .then(() => this.reloadData({ clearCache: true })); + validateCLP(perms, newSchema, this.userIdRegEx); + return this._dbAdapter.setClassLevelPermissions(className, perms); } // Returns a promise that resolves successfully to the new schema // object if the provided className-fieldName-type tuple is valid. // The className must already be validated. // If 'freeze' is true, refuse to update the schema for this field. - enforceFieldExists(className, fieldName, type) { - if (fieldName.indexOf(".") > 0) { + enforceFieldExists( + className: string, + fieldName: string, + type: string | SchemaField + ) { + if (fieldName.indexOf('.') > 0) { // subdocument key (x.y) => ok if x is of type 'object' - fieldName = fieldName.split(".")[ 0 ]; + fieldName = fieldName.split('.')[0]; type = 'Object'; } if (!fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name: ${fieldName}.` + ); } // If someone tries to create a new field with null/undefined as the value, return; if (!type) { - return Promise.resolve(this); + return undefined; } - return this.reloadData().then(() => { - const expectedType = this.getExpectedType(className, fieldName); - if (typeof type === 'string') { - type = { type }; + const expectedType = this.getExpectedType(className, fieldName); + if (typeof type === 'string') { + type = ({ type }: SchemaField); + } + + if (type.defaultValue !== undefined) { + let defaultValueType = getType(type.defaultValue); + if (typeof defaultValueType === 'string') { + defaultValueType = { type: defaultValueType }; + } + if (!dbTypeMatchesObjectType(type, defaultValueType)) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString( + type + )} but got ${typeToString(defaultValueType)}` + ); } + } - if (expectedType) { - if (!dbTypeMatchesObjectType(expectedType, type)) { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - `schema mismatch for ${className}.${fieldName}; expected ${typeToString(expectedType)} but got ${typeToString(type)}` - ); - } - return this; + if (expectedType) { + if (!dbTypeMatchesObjectType(expectedType, type)) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + `schema mismatch for ${className}.${fieldName}; expected ${typeToString( + expectedType + )} but got ${typeToString(type)}` + ); } + return undefined; + } - return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => { - // The update succeeded. Reload the schema - return this.reloadData({ clearCache: true }); - }, (error) => { + return this._dbAdapter + .addFieldIfNotExists(className, fieldName, type) + .catch(error => { if (error.code == Parse.Error.INCORRECT_TYPE) { // Make sure that we throw errors when it is appropriate to do so. throw error; @@ -670,21 +1188,40 @@ export default class SchemaController { // 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({ clearCache: true }); - }).then(() => { - // Ensure that the schema now validates - if (!dbTypeMatchesObjectType(this.getExpectedType(className, fieldName), type)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`); - } - // Remove the cached schema - this._cache.clear(); - return this; + return Promise.resolve(); + }) + .then(() => { + return { + className, + fieldName, + type, + }; }); - }); + } + + ensureFields(fields: any) { + for (let i = 0; i < fields.length; i += 1) { + const { className, fieldName } = fields[i]; + let { type } = fields[i]; + const expectedType = this.getExpectedType(className, fieldName); + if (typeof type === 'string') { + type = { type: type }; + } + if (!expectedType || !dbTypeMatchesObjectType(expectedType, type)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Could not add field ${fieldName}` + ); + } + } } // maintain compatibility - deleteField(fieldName, className, database) { + deleteField( + fieldName: string, + className: string, + database: DatabaseController + ) { return this.deleteFields([fieldName], className, database); } @@ -695,14 +1232,24 @@ export default class SchemaController { // 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. - deleteFields(fieldNames, className, database) { + deleteFields( + fieldNames: Array, + className: string, + database: DatabaseController + ) { if (!classNameIsValid(className)) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + invalidClassNameMessage(className) + ); } fieldNames.forEach(fieldName => { if (!fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${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)) { @@ -710,10 +1257,13 @@ export default class SchemaController { } }); - return this.getOneSchema(className, false, {clearCache: true}) + return this.getOneSchema(className, false, { clearCache: true }) .catch(error => { if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} does not exist.` + ); } else { throw error; } @@ -721,33 +1271,42 @@ export default class SchemaController { .then(schema => { fieldNames.forEach(fieldName => { if (!schema.fields[fieldName]) { - throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + throw new Parse.Error( + 255, + `Field ${fieldName} does not exist, cannot delete.` + ); } }); const schemaFields = { ...schema.fields }; - return database.adapter.deleteFields(className, schema, fieldNames) + return database.adapter + .deleteFields(className, schema, fieldNames) .then(() => { - return Promise.all(fieldNames.map(fieldName => { - const field = schemaFields[fieldName]; - if (field && field.type === 'Relation') { - //For relations, drop the _Join table - return database.adapter.deleteClass(`_Join:${fieldName}:${className}`); - } - return Promise.resolve(); - })); + return Promise.all( + fieldNames.map(fieldName => { + const field = schemaFields[fieldName]; + if (field && field.type === 'Relation') { + //For relations, drop the _Join table + return database.adapter.deleteClass( + `_Join:${fieldName}:${className}` + ); + } + return Promise.resolve(); + }) + ); }); - }).then(() => { - this._cache.clear(); - }); + }) + .then(() => this._cache.clear()); } // 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) { + async validateObject(className: string, object: any, query: any) { let geocount = 0; - let promise = this.enforceClassExists(className); + const schema = await this.enforceClassExists(className); + const promises = []; + for (const fieldName in object) { if (object[fieldName] === undefined) { continue; @@ -759,10 +1318,12 @@ export default class SchemaController { if (geocount > 1) { // Make sure all field validation operations run before we return. // If not - we are continuing to run logic, but already provided response from the server. - return promise.then(() => { - return Promise.reject(new Parse.Error(Parse.Error.INCORRECT_TYPE, - 'there can only be one geopoint field in a class')); - }); + return Promise.reject( + new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class' + ) + ); } if (!expected) { continue; @@ -771,81 +1332,116 @@ export default class SchemaController { // Every object has ACL implicitly. continue; } + promises.push(schema.enforceFieldExists(className, fieldName, expected)); + } + const results = await Promise.all(promises); + const enforceFields = results.filter(result => !!result); - promise = promise.then(schema => schema.enforceFieldExists(className, fieldName, expected)); + if (enforceFields.length !== 0) { + await this.reloadData({ clearCache: true }); } - promise = thenValidateRequiredColumns(promise, className, object, query); - return promise; + this.ensureFields(enforceFields); + + const promise = Promise.resolve(schema); + return thenValidateRequiredColumns(promise, className, object, query); } // Validates that all the properties are set for the object - validateRequiredColumns(className, object, query) { + validateRequiredColumns(className: string, object: any, query: any) { const columns = requiredColumns[className]; if (!columns || columns.length == 0) { return Promise.resolve(this); } - const missingColumns = columns.filter(function(column){ + const missingColumns = columns.filter(function(column) { if (query && query.objectId) { - if (object[column] && typeof object[column] === "object") { + 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] + return !object[column]; }); if (missingColumns.length > 0) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, - missingColumns[0] + ' is required.'); + missingColumns[0] + ' is required.' + ); } return Promise.resolve(this); } - // Validates the base CLP for an operation - testBaseCLP(className, aclGroup, operation) { - if (!this.perms[className] || !this.perms[className][operation]) { + testPermissionsForClassName( + className: string, + aclGroup: string[], + operation: string + ) { + return SchemaController.testPermissions( + this.getClassLevelPermissions(className), + aclGroup, + operation + ); + } + + // Tests that the class level permission let pass the operation for a given aclGroup + static testPermissions( + classPermissions: ?any, + aclGroup: string[], + operation: string + ): boolean { + if (!classPermissions || !classPermissions[operation]) { return true; } - const classPerms = this.perms[className]; - const perms = classPerms[operation]; - // Handle the public scenario quickly + const perms = classPermissions[operation]; if (perms['*']) { return true; } // Check permissions against the aclGroup provided (array of userId/roles) - if (aclGroup.some(acl => { return perms[acl] === true })) { + if ( + aclGroup.some(acl => { + return perms[acl] === true; + }) + ) { return true; } return false; } // Validates an operation passes class-level-permissions set in the schema - validatePermission(className, aclGroup, operation) { - - if (this.testBaseCLP(className, aclGroup, operation)) { + static validatePermission( + classPermissions: ?any, + className: string, + aclGroup: string[], + operation: string, + action?: string + ) { + if ( + SchemaController.testPermissions(classPermissions, aclGroup, operation) + ) { return Promise.resolve(); } - if (!this.perms[className] || !this.perms[className][operation]) { + if (!classPermissions || !classPermissions[operation]) { return true; } - const classPerms = this.perms[className]; - const perms = classPerms[operation]; - + const perms = classPermissions[operation]; // If only for authenticated users // make sure we have an aclGroup if (perms['requiresAuthentication']) { // If aclGroup has * (public) if (!aclGroup || aclGroup.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Permission denied, user needs to be authenticated.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied, user needs to be authenticated.' + ); } else if (aclGroup.indexOf('*') > -1 && aclGroup.length == 1) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Permission denied, user needs to be authenticated.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied, user needs to be authenticated.' + ); } // requiresAuthentication passed, just move forward // probably would be wise at some point to rename to 'authenticatedUser' @@ -854,58 +1450,128 @@ export default class SchemaController { // No matching CLP, let's check the Pointer permissions // And handle those later - const permissionField = ['get', 'find', 'count'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; + const permissionField = + ['get', 'find', 'count'].indexOf(operation) > -1 + ? 'readUserFields' + : 'writeUserFields'; // Reject create when write lockdown if (permissionField == 'writeUserFields' && operation == 'create') { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - `Permission denied for action ${operation} on class ${className}.`); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Permission denied for action ${operation} on class ${className}.` + ); } // Process the readUserFields later - if (Array.isArray(classPerms[permissionField]) && classPerms[permissionField].length > 0) { + if ( + Array.isArray(classPermissions[permissionField]) && + classPermissions[permissionField].length > 0 + ) { return Promise.resolve(); } - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - `Permission denied for action ${operation} on class ${className}.`); + + const pointerFields = classPermissions[operation].pointerFields; + if (Array.isArray(pointerFields) && pointerFields.length > 0) { + // any op except 'addField as part of create' is ok. + if (operation !== 'addField' || action === 'update') { + // We can allow adding field on update flow only. + return Promise.resolve(); + } + } + + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Permission denied for action ${operation} on class ${className}.` + ); + } + + // Validates an operation passes class-level-permissions set in the schema + validatePermission( + className: string, + aclGroup: string[], + operation: string, + action?: string + ) { + return SchemaController.validatePermission( + this.getClassLevelPermissions(className), + className, + aclGroup, + operation, + action + ); + } + + getClassLevelPermissions(className: string): any { + return ( + this.schemaData[className] && + this.schemaData[className].classLevelPermissions + ); } // Returns the expected type for a className+key combination // or undefined if the schema is not set - getExpectedType(className, fieldName) { - if (this.data && this.data[className]) { - const expectedType = this.data[className][fieldName] + getExpectedType( + className: string, + fieldName: string + ): ?(SchemaField | string) { + if (this.schemaData[className]) { + const expectedType = this.schemaData[className].fields[fieldName]; return expectedType === 'map' ? 'Object' : expectedType; } return undefined; } // Checks if a given class is in the schema. - hasClass(className) { - return this.reloadData().then(() => !!(this.data[className])); + hasClass(className: string) { + if (this.schemaData[className]) { + return Promise.resolve(true); + } + return this.reloadData().then(() => !!this.schemaData[className]); } } // Returns a promise for a new Schema. -const load = (dbAdapter, schemaCache, options) => { +const load = ( + dbAdapter: StorageAdapter, + schemaCache: any, + options: any +): Promise => { const schema = new SchemaController(dbAdapter, schemaCache); return schema.reloadData(options).then(() => schema); -} +}; // Builds a new schema (in schema API response format) out of an // existing mongo schema + a schemas API put request. This response // does not include the default fields, as it is intended to be passed // to mongoSchemaFromFieldsAndClassName. No validation is done here, it // is done in mongoSchemaFromFieldsAndClassName. -function buildMergedSchemaObject(existingFields, putRequest) { +function buildMergedSchemaObject( + existingFields: SchemaFields, + putRequest: any +): SchemaFields { const newSchema = {}; - const sysSchemaField = Object.keys(defaultColumns).indexOf(existingFields._id) === -1 ? [] : Object.keys(defaultColumns[existingFields._id]); + // @flow-disable-next + const sysSchemaField = + Object.keys(defaultColumns).indexOf(existingFields._id) === -1 + ? [] + : Object.keys(defaultColumns[existingFields._id]); for (const oldField in existingFields) { - if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') { - if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) { + if ( + oldField !== '_id' && + oldField !== 'ACL' && + oldField !== 'updatedAt' && + oldField !== 'createdAt' && + oldField !== 'objectId' + ) { + if ( + sysSchemaField.length > 0 && + sysSchemaField.indexOf(oldField) !== -1 + ) { continue; } - const fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete' + const fieldIsDeleted = + putRequest[oldField] && putRequest[oldField].__op === 'Delete'; if (!fieldIsDeleted) { newSchema[oldField] = existingFields[oldField]; } @@ -913,7 +1579,10 @@ function buildMergedSchemaObject(existingFields, putRequest) { } for (const newField in putRequest) { if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { - if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) { + if ( + sysSchemaField.length > 0 && + sysSchemaField.indexOf(newField) !== -1 + ) { continue; } newSchema[newField] = putRequest[newField]; @@ -925,7 +1594,7 @@ function buildMergedSchemaObject(existingFields, putRequest) { // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateRequiredColumns(schemaPromise, className, object, query) { - return schemaPromise.then((schema) => { + return schemaPromise.then(schema => { return schema.validateRequiredColumns(className, object, query); }); } @@ -935,105 +1604,108 @@ function thenValidateRequiredColumns(schemaPromise, className, object, query) { // type system. // The output should be a valid schema value. // TODO: ensure that this is compatible with the format used in Open DB -function getType(obj) { +function getType(obj: any): ?(SchemaField | string) { const type = typeof obj; - switch(type) { - case 'boolean': - return 'Boolean'; - case 'string': - return 'String'; - case 'number': - return 'Number'; - case 'map': - case 'object': - if (!obj) { - return undefined; - } - return getObjectType(obj); - case 'function': - case 'symbol': - case 'undefined': - default: - throw 'bad obj: ' + obj; + switch (type) { + case 'boolean': + return 'Boolean'; + case 'string': + return 'String'; + case 'number': + return 'Number'; + case 'map': + case 'object': + if (!obj) { + return undefined; + } + return getObjectType(obj); + case 'function': + case 'symbol': + case 'undefined': + default: + throw 'bad obj: ' + obj; } } // This gets the type for non-JSON types like pointers and files, but // also gets the appropriate type for $ operators. // Returns null if the type is unknown. -function getObjectType(obj) { +function getObjectType(obj): ?(SchemaField | string) { if (obj instanceof Array) { return 'Array'; } - if (obj.__type){ - switch(obj.__type) { - case 'Pointer' : - if(obj.className) { - return { - type: 'Pointer', - targetClass: obj.className + if (obj.__type) { + switch (obj.__type) { + case 'Pointer': + if (obj.className) { + return { + type: 'Pointer', + targetClass: obj.className, + }; } - } - break; - case 'Relation' : - if(obj.className) { - return { - type: 'Relation', - targetClass: obj.className + break; + case 'Relation': + if (obj.className) { + return { + type: 'Relation', + targetClass: obj.className, + }; } - } - break; - case 'File' : - if(obj.name) { - return 'File'; - } - break; - case 'Date' : - if(obj.iso) { - return 'Date'; - } - break; - case 'GeoPoint' : - if(obj.latitude != null && obj.longitude != null) { - return 'GeoPoint'; - } - break; - case 'Bytes' : - if(obj.base64) { - return 'Bytes'; - } - break; - case 'Polygon' : - if(obj.coordinates) { - return 'Polygon'; - } - break; + break; + case 'File': + if (obj.name) { + return 'File'; + } + break; + case 'Date': + if (obj.iso) { + return 'Date'; + } + break; + case 'GeoPoint': + if (obj.latitude != null && obj.longitude != null) { + return 'GeoPoint'; + } + break; + case 'Bytes': + if (obj.base64) { + return 'Bytes'; + } + break; + case 'Polygon': + if (obj.coordinates) { + return 'Polygon'; + } + break; } - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid " + obj.__type); + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'This is not a valid ' + obj.__type + ); } if (obj['$ne']) { return getObjectType(obj['$ne']); } 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 { - type: 'Relation', - targetClass: obj.objects[0].className - } - case 'Batch': - return getObjectType(obj.ops[0]); - default: - throw 'unexpected op: ' + 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 { + type: 'Relation', + targetClass: obj.objects[0].className, + }; + case 'Batch': + return getObjectType(obj.ops[0]); + default: + throw 'unexpected op: ' + obj.__op; } } return 'Object'; @@ -1049,4 +1721,5 @@ export { defaultColumns, convertSchemaToAdapterSchema, VolatileClassesSchemas, + SchemaController, }; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index bf7934c492..2d7b444428 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -1,15 +1,14 @@ -import { randomString } from '../cryptoUtils'; -import { inflate } from '../triggers'; +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; import AdaptableController from './AdaptableController'; -import MailAdapter from '../Adapters/Email/MailAdapter'; -import rest from '../rest'; -import Parse from 'parse/node'; +import MailAdapter from '../Adapters/Email/MailAdapter'; +import rest from '../rest'; +import Parse from 'parse/node'; var RestQuery = require('../RestQuery'); var Auth = require('../Auth'); export class UserController extends AdaptableController { - constructor(adapter, appId, options = {}) { super(adapter, appId, options); } @@ -36,7 +35,9 @@ export class UserController extends AdaptableController { user.emailVerified = false; if (this.config.emailVerifyTokenValidityDuration) { - user._email_verify_token_expires_at = Parse._encode(this.config.generateEmailVerifyTokenExpiresAt()); + user._email_verify_token_expires_at = Parse._encode( + this.config.generateEmailVerifyTokenExpiresAt() + ); } } } @@ -48,8 +49,11 @@ export class UserController extends AdaptableController { throw undefined; } - const query = {username: username, _email_verify_token: token}; - const updateFields = { emailVerified: true, _email_verify_token: {__op: 'Delete'}}; + const query = { username: username, _email_verify_token: token }; + const updateFields = { + emailVerified: true, + _email_verify_token: { __op: 'Delete' }, + }; // if the email verify token needs to be validated then // add additional query params and additional fields that need to be updated @@ -57,10 +61,15 @@ export class UserController extends AdaptableController { query.emailVerified = false; query._email_verify_token_expires_at = { $gt: Parse._encode(new Date()) }; - updateFields._email_verify_token_expires_at = {__op: 'Delete'}; + updateFields._email_verify_token_expires_at = { __op: 'Delete' }; } const masterAuth = Auth.master(this.config); - var checkIfAlreadyVerified = new RestQuery(this.config, Auth.master(this.config), '_User', {username: username, emailVerified: true}); + var checkIfAlreadyVerified = new RestQuery( + this.config, + Auth.master(this.config), + '_User', + { username: username, emailVerified: true } + ); return checkIfAlreadyVerified.execute().then(result => { if (result.results.length) { return Promise.resolve(result.results.length[0]); @@ -70,25 +79,34 @@ export class UserController extends AdaptableController { } checkResetTokenValidity(username, token) { - return this.config.database.find('_User', { - username: username, - _perishable_token: token - }, {limit: 1}).then(results => { - if (results.length != 1) { - throw undefined; - } + return this.config.database + .find( + '_User', + { + username: username, + _perishable_token: token, + }, + { limit: 1 } + ) + .then(results => { + if (results.length != 1) { + throw 'Failed to reset password: username / email / token is invalid'; + } - if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { - let expiresDate = results[0]._perishable_token_expires_at; - if (expiresDate && expiresDate.__type == 'Date') { - expiresDate = new Date(expiresDate.iso); + if ( + this.config.passwordPolicy && + this.config.passwordPolicy.resetTokenValidityDuration + ) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); + } + if (expiresDate < new Date()) + throw 'The password reset link has expired'; } - if (expiresDate < new Date()) - throw 'The password reset link has expired'; - } - return results[0]; - }); + return results[0]; + }); } getUserIfNeeded(user) { @@ -103,13 +121,18 @@ export class UserController extends AdaptableController { where.email = user.email; } - var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); - return query.execute().then(function(result){ + var query = new RestQuery( + this.config, + Auth.master(this.config), + '_User', + where + ); + return query.execute().then(function(result) { if (result.results.length != 1) { throw undefined; } return result.results[0]; - }) + }); } sendVerificationEmail(user) { @@ -118,10 +141,15 @@ export class UserController extends AdaptableController { } const token = encodeURIComponent(user._email_verify_token); // We may need to fetch the user in case of update email - this.getUserIfNeeded(user).then((user) => { + this.getUserIfNeeded(user).then(user => { const username = encodeURIComponent(user.username); - const link = buildEmailLink(this.config.verifyEmailURL, username, token, this.config); + const link = buildEmailLink( + this.config.verifyEmailURL, + username, + token, + this.config + ); const options = { appName: this.config.appName, link: link, @@ -135,13 +163,27 @@ export class UserController extends AdaptableController { }); } + /** + * Regenerates the given user's email verification token + * + * @param user + * @returns {*} + */ + regenerateEmailVerifyToken(user) { + this.setEmailVerifyToken(user); + return this.config.database.update( + '_User', + { username: user.username }, + user + ); + } + resendVerificationEmail(username) { - return this.getUserIfNeeded({username: username}).then((aUser) => { + return this.getUserIfNeeded({ username: username }).then(aUser => { if (!aUser || aUser.emailVerified) { throw undefined; } - this.setEmailVerifyToken(aUser); - return this.config.database.update('_User', {username}, aUser).then(() => { + return this.regenerateEmailVerifyToken(aUser).then(() => { this.sendVerificationEmail(aUser); }); }); @@ -150,50 +192,62 @@ export class UserController extends AdaptableController { setPasswordResetToken(email) { const token = { _perishable_token: randomString(25) }; - if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { - token._perishable_token_expires_at = Parse._encode(this.config.generatePasswordResetTokenExpiresAt()); + if ( + this.config.passwordPolicy && + this.config.passwordPolicy.resetTokenValidityDuration + ) { + token._perishable_token_expires_at = Parse._encode( + this.config.generatePasswordResetTokenExpiresAt() + ); } - return this.config.database.update('_User', { $or: [{email}, {username: email, email: {$exists: false}}] }, token, {}, true) + return this.config.database.update( + '_User', + { $or: [{ email }, { username: email, email: { $exists: false } }] }, + token, + {}, + true + ); } sendPasswordResetEmail(email) { if (!this.adapter) { - throw "Trying to send a reset password but no adapter is set"; + throw 'Trying to send a reset password but no adapter is set'; // TODO: No adapter? } - return this.setPasswordResetToken(email) - .then(user => { - const token = encodeURIComponent(user._perishable_token); - const username = encodeURIComponent(user.username); + return this.setPasswordResetToken(email).then(user => { + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); - const link = buildEmailLink(this.config.requestResetPasswordURL, username, token, this.config); - const options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; + const link = buildEmailLink( + this.config.requestResetPasswordURL, + username, + token, + this.config + ); + const options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; - if (this.adapter.sendPasswordResetEmail) { - this.adapter.sendPasswordResetEmail(options); - } else { - this.adapter.sendMail(this.defaultResetPasswordEmail(options)); - } + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } - return Promise.resolve(user); - }); + return Promise.resolve(user); + }); } updatePassword(username, token, password) { return this.checkResetTokenValidity(username, token) .then(user => updateUserPassword(user.objectId, password, this.config)) - // clear reset password token - .then(() => this.config.database.update('_User', {username}, { - _perishable_token: {__op: 'Delete'}, - _perishable_token_expires_at: {__op: 'Delete'} - })).catch((error) => { - if (error.message) { // in case of Parse.Error, fail with the error message only + .catch(error => { + if (error && error.message) { + // in case of Parse.Error, fail with the error message only return Promise.reject(error.message); } else { return Promise.reject(error); @@ -201,42 +255,65 @@ export class UserController extends AdaptableController { }); } - defaultVerificationEmail({link, user, appName, }) { - const text = "Hi,\n\n" + - "You are being asked to confirm the e-mail address " + user.get("email") + " with " + appName + "\n\n" + - "" + - "Click here to confirm it:\n" + link; - const to = user.get("email"); + defaultVerificationEmail({ link, user, appName }) { + const text = + 'Hi,\n\n' + + 'You are being asked to confirm the e-mail address ' + + user.get('email') + + ' with ' + + appName + + '\n\n' + + '' + + 'Click here to confirm it:\n' + + link; + const to = user.get('email'); const subject = 'Please verify your e-mail for ' + appName; return { text, to, subject }; } - defaultResetPasswordEmail({link, user, appName, }) { - const text = "Hi,\n\n" + - "You requested to reset your password for " + appName + - (user.get('username') ? (" (your username is '" + user.get('username') + "')") : "") + ".\n\n" + - "" + - "Click here to reset it:\n" + link; - const to = user.get("email") || user.get('username'); - const subject = 'Password Reset for ' + appName; + defaultResetPasswordEmail({ link, user, appName }) { + const text = + 'Hi,\n\n' + + 'You requested to reset your password for ' + + appName + + (user.get('username') + ? " (your username is '" + user.get('username') + "')" + : '') + + '.\n\n' + + '' + + 'Click here to reset it:\n' + + link; + const to = user.get('email') || user.get('username'); + const subject = 'Password Reset for ' + appName; return { text, to, subject }; } } // Mark this private function updateUserPassword(userId, password, config) { - return rest.update(config, Auth.master(config), '_User', { objectId: userId }, { - password: password - }); + return rest.update( + config, + Auth.master(config), + '_User', + { objectId: userId }, + { + password: password, + } + ); } function buildEmailLink(destination, username, token, config) { - const usernameAndToken = `token=${token}&username=${username}` + const usernameAndToken = `token=${token}&username=${username}`; if (config.parseFrameURL) { - const destinationWithoutHost = destination.replace(config.publicServerURL, ''); - - return `${config.parseFrameURL}?link=${encodeURIComponent(destinationWithoutHost)}&${usernameAndToken}`; + const destinationWithoutHost = destination.replace( + config.publicServerURL, + '' + ); + + return `${config.parseFrameURL}?link=${encodeURIComponent( + destinationWithoutHost + )}&${usernameAndToken}`; } else { return `${destination}?${usernameAndToken}`; } diff --git a/src/Controllers/index.js b/src/Controllers/index.js index 70b95524eb..d10ad8001c 100644 --- a/src/Controllers/index.js +++ b/src/Controllers/index.js @@ -1,30 +1,31 @@ -import authDataManager from '../Adapters/Auth'; -import { ParseServerOptions } from '../Options'; -import { loadAdapter } from '../Adapters/AdapterLoader'; -import defaults from '../defaults'; -import url from 'url'; +import authDataManager from '../Adapters/Auth'; +import { ParseServerOptions } from '../Options'; +import { loadAdapter } from '../Adapters/AdapterLoader'; +import defaults from '../defaults'; +import url from 'url'; // Controllers -import { LoggerController } from './LoggerController'; -import { FilesController } from './FilesController'; -import { HooksController } from './HooksController'; -import { UserController } from './UserController'; -import { CacheController } from './CacheController'; -import { LiveQueryController } from './LiveQueryController'; -import { AnalyticsController } from './AnalyticsController'; -import { PushController } from './PushController'; -import { PushQueue } from '../Push/PushQueue'; -import { PushWorker } from '../Push/PushWorker'; -import DatabaseController from './DatabaseController'; -import SchemaCache from './SchemaCache'; +import { LoggerController } from './LoggerController'; +import { FilesController } from './FilesController'; +import { HooksController } from './HooksController'; +import { UserController } from './UserController'; +import { CacheController } from './CacheController'; +import { LiveQueryController } from './LiveQueryController'; +import { AnalyticsController } from './AnalyticsController'; +import { PushController } from './PushController'; +import { PushQueue } from '../Push/PushQueue'; +import { PushWorker } from '../Push/PushWorker'; +import DatabaseController from './DatabaseController'; +import SchemaCache from './SchemaCache'; // Adapters -import { GridStoreAdapter } from '../Adapters/Files/GridStoreAdapter'; +import { GridFSBucketAdapter } from '../Adapters/Files/GridFSBucketAdapter'; import { WinstonLoggerAdapter } from '../Adapters/Logger/WinstonLoggerAdapter'; import { InMemoryCacheAdapter } from '../Adapters/Cache/InMemoryCacheAdapter'; -import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; -import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; -import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; -import ParsePushAdapter from 'parse-server-push-adapter'; +import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; +import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; +import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; +import ParsePushAdapter from '@parse/push-adapter'; +import ParseGraphQLController from './ParseGraphQLController'; export function getControllers(options: ParseServerOptions) { const loggerController = getLoggerController(options); @@ -35,7 +36,7 @@ export function getControllers(options: ParseServerOptions) { hasPushScheduledSupport, hasPushSupport, pushControllerQueue, - pushWorker + pushWorker, } = getPushController(options); const cacheController = getCacheController(options); const analyticsController = getAnalyticsController(options); @@ -43,6 +44,10 @@ export function getControllers(options: ParseServerOptions) { const databaseController = getDatabaseController(options, cacheController); const hooksController = getHooksController(options, databaseController); const authDataManager = getAuthDataManager(options); + const parseGraphQLController = getParseGraphQLController(options, { + databaseController, + cacheController, + }); return { loggerController, filesController, @@ -54,6 +59,7 @@ export function getControllers(options: ParseServerOptions) { pushControllerQueue, analyticsController, cacheController, + parseGraphQLController, liveQueryController, databaseController, hooksController, @@ -61,71 +67,107 @@ export function getControllers(options: ParseServerOptions) { }; } -export function getLoggerController(options: ParseServerOptions): LoggerController { +export function getLoggerController( + options: ParseServerOptions +): LoggerController { const { appId, jsonLogs, logsFolder, verbose, logLevel, + maxLogFiles, silent, loggerAdapter, } = options; - const loggerOptions = { jsonLogs, logsFolder, verbose, logLevel, silent }; - const loggerControllerAdapter = loadAdapter(loggerAdapter, WinstonLoggerAdapter, loggerOptions); + const loggerOptions = { + jsonLogs, + logsFolder, + verbose, + logLevel, + silent, + maxLogFiles, + }; + const loggerControllerAdapter = loadAdapter( + loggerAdapter, + WinstonLoggerAdapter, + loggerOptions + ); return new LoggerController(loggerControllerAdapter, appId, loggerOptions); } -export function getFilesController(options: ParseServerOptions): FilesController { +export function getFilesController( + options: ParseServerOptions +): FilesController { const { appId, databaseURI, filesAdapter, databaseAdapter, + preserveFileName, } = options; if (!filesAdapter && databaseAdapter) { throw 'When using an explicit database adapter, you must also use an explicit filesAdapter.'; } const filesControllerAdapter = loadAdapter(filesAdapter, () => { - return new GridStoreAdapter(databaseURI); + return new GridFSBucketAdapter(databaseURI); + }); + return new FilesController(filesControllerAdapter, appId, { + preserveFileName, }); - return new FilesController(filesControllerAdapter, appId); } export function getUserController(options: ParseServerOptions): UserController { - const { - appId, - emailAdapter, - verifyUserEmails, - } = options; + const { appId, emailAdapter, verifyUserEmails } = options; const emailControllerAdapter = loadAdapter(emailAdapter); - return new UserController(emailControllerAdapter, appId, { verifyUserEmails }); + return new UserController(emailControllerAdapter, appId, { + verifyUserEmails, + }); } -export function getCacheController(options: ParseServerOptions): CacheController { - const { - appId, +export function getCacheController( + options: ParseServerOptions +): CacheController { + const { appId, cacheAdapter, cacheTTL, cacheMaxSize } = options; + const cacheControllerAdapter = loadAdapter( cacheAdapter, - cacheTTL, - cacheMaxSize, - } = options; - const cacheControllerAdapter = loadAdapter(cacheAdapter, InMemoryCacheAdapter, {appId: appId, ttl: cacheTTL, maxSize: cacheMaxSize }); + InMemoryCacheAdapter, + { appId: appId, ttl: cacheTTL, maxSize: cacheMaxSize } + ); return new CacheController(cacheControllerAdapter, appId); } -export function getAnalyticsController(options: ParseServerOptions): AnalyticsController { - const { +export function getParseGraphQLController( + options: ParseServerOptions, + controllerDeps +): ParseGraphQLController { + return new ParseGraphQLController({ + mountGraphQL: options.mountGraphQL, + ...controllerDeps, + }); +} + +export function getAnalyticsController( + options: ParseServerOptions +): AnalyticsController { + const { analyticsAdapter } = options; + const analyticsControllerAdapter = loadAdapter( analyticsAdapter, - } = options; - const analyticsControllerAdapter = loadAdapter(analyticsAdapter, AnalyticsAdapter); + AnalyticsAdapter + ); return new AnalyticsController(analyticsControllerAdapter); } -export function getLiveQueryController(options: ParseServerOptions): LiveQueryController { +export function getLiveQueryController( + options: ParseServerOptions +): LiveQueryController { return new LiveQueryController(options.liveQuery); } -export function getDatabaseController(options: ParseServerOptions, cacheController: CacheController): DatabaseController { +export function getDatabaseController( + options: ParseServerOptions, + cacheController: CacheController +): DatabaseController { const { databaseURI, databaseOptions, @@ -133,39 +175,48 @@ export function getDatabaseController(options: ParseServerOptions, cacheControll schemaCacheTTL, enableSingleSchemaCache, } = options; - let { + let { databaseAdapter } = options; + if ( + (databaseOptions || + (databaseURI && databaseURI !== defaults.databaseURI) || + collectionPrefix !== defaults.collectionPrefix) && databaseAdapter - } = options; - if ((databaseOptions || (databaseURI && databaseURI !== defaults.databaseURI) || collectionPrefix !== defaults.collectionPrefix) && databaseAdapter) { + ) { throw 'You cannot specify both a databaseAdapter and a databaseURI/databaseOptions/collectionPrefix.'; } else if (!databaseAdapter) { - databaseAdapter = getDatabaseAdapter(databaseURI, collectionPrefix, databaseOptions) + databaseAdapter = getDatabaseAdapter( + databaseURI, + collectionPrefix, + databaseOptions + ); } else { - databaseAdapter = loadAdapter(databaseAdapter) + databaseAdapter = loadAdapter(databaseAdapter); } - return new DatabaseController(databaseAdapter, new SchemaCache(cacheController, schemaCacheTTL, enableSingleSchemaCache)); + return new DatabaseController( + databaseAdapter, + new SchemaCache(cacheController, schemaCacheTTL, enableSingleSchemaCache) + ); } -export function getHooksController(options: ParseServerOptions, databaseController: DatabaseController): HooksController { - const { - appId, - webhookKey - } = options; +export function getHooksController( + options: ParseServerOptions, + databaseController: DatabaseController +): HooksController { + const { appId, webhookKey } = options; return new HooksController(appId, databaseController, webhookKey); } interface PushControlling { - pushController: PushController, - hasPushScheduledSupport: boolean, - pushControllerQueue: PushQueue, - pushWorker: PushWorker + pushController: PushController; + hasPushScheduledSupport: boolean; + pushControllerQueue: PushQueue; + pushWorker: PushWorker; } -export function getPushController(options: ParseServerOptions): PushControlling { - const { - scheduledPush, - push, - } = options; +export function getPushController( + options: ParseServerOptions +): PushControlling { + const { scheduledPush, push } = options; const pushOptions = Object.assign({}, push); const pushQueueOptions = pushOptions.queueOptions || {}; @@ -174,16 +225,18 @@ export function getPushController(options: ParseServerOptions): PushControlling } // Pass the push options too as it works with the default - const pushAdapter = loadAdapter(pushOptions && pushOptions.adapter, ParsePushAdapter, pushOptions); + const pushAdapter = loadAdapter( + pushOptions && pushOptions.adapter, + ParsePushAdapter, + pushOptions + ); // We pass the options and the base class for the adatper, // Note that passing an instance would work too const pushController = new PushController(); const hasPushSupport = !!(pushAdapter && push); - const hasPushScheduledSupport = hasPushSupport && (scheduledPush === true); + const hasPushScheduledSupport = hasPushSupport && scheduledPush === true; - const { - disablePushWorker - } = pushQueueOptions; + const { disablePushWorker } = pushQueueOptions; const pushControllerQueue = new PushQueue(pushQueueOptions); let pushWorker; @@ -195,37 +248,39 @@ export function getPushController(options: ParseServerOptions): PushControlling hasPushSupport, hasPushScheduledSupport, pushControllerQueue, - pushWorker - } + pushWorker, + }; } export function getAuthDataManager(options: ParseServerOptions) { - const { - auth, - enableAnonymousUsers - } = options; - return authDataManager(auth, enableAnonymousUsers) + const { auth, enableAnonymousUsers } = options; + return authDataManager(auth, enableAnonymousUsers); } -export function getDatabaseAdapter(databaseURI, collectionPrefix, databaseOptions) { +export function getDatabaseAdapter( + databaseURI, + collectionPrefix, + databaseOptions +) { let protocol; try { const parsedURI = url.parse(databaseURI); protocol = parsedURI.protocol ? parsedURI.protocol.toLowerCase() : null; - } catch(e) { /* */ } + } catch (e) { + /* */ + } switch (protocol) { - case 'postgres:': - return new PostgresStorageAdapter({ - uri: databaseURI, - collectionPrefix, - databaseOptions - }); - default: - return new MongoStorageAdapter({ - uri: databaseURI, - collectionPrefix, - mongoOptions: databaseOptions, - }); + case 'postgres:': + return new PostgresStorageAdapter({ + uri: databaseURI, + collectionPrefix, + databaseOptions, + }); + default: + return new MongoStorageAdapter({ + uri: databaseURI, + collectionPrefix, + mongoOptions: databaseOptions, + }); } } - diff --git a/src/Controllers/types.js b/src/Controllers/types.js new file mode 100644 index 0000000000..2bd3298935 --- /dev/null +++ b/src/Controllers/types.js @@ -0,0 +1,32 @@ +export type LoadSchemaOptions = { + clearCache: boolean, +}; + +export type SchemaField = { + type: string, + targetClass?: ?string, + required?: ?boolean, + defaultValue?: ?any, +}; + +export type SchemaFields = { [string]: SchemaField }; + +export type Schema = { + className: string, + fields: SchemaFields, + classLevelPermissions: ClassLevelPermissions, + indexes?: ?any, +}; + +export type ClassLevelPermissions = { + find?: { [string]: boolean }, + count?: { [string]: boolean }, + get?: { [string]: boolean }, + create?: { [string]: boolean }, + update?: { [string]: boolean }, + delete?: { [string]: boolean }, + addField?: { [string]: boolean }, + readUserFields?: string[], + writeUserFields?: string[], + protectedFields?: { [string]: string[] }, +}; diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js new file mode 100644 index 0000000000..dd071b1f64 --- /dev/null +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -0,0 +1,532 @@ +import Parse from 'parse/node'; +import { + GraphQLSchema, + GraphQLObjectType, + DocumentNode, + GraphQLNamedType, +} from 'graphql'; +import { mergeSchemas, SchemaDirectiveVisitor } from 'graphql-tools'; +import requiredParameter from '../requiredParameter'; +import * as defaultGraphQLTypes from './loaders/defaultGraphQLTypes'; +import * as parseClassTypes from './loaders/parseClassTypes'; +import * as parseClassQueries from './loaders/parseClassQueries'; +import * as parseClassMutations from './loaders/parseClassMutations'; +import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; +import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; +import ParseGraphQLController, { + ParseGraphQLConfig, +} from '../Controllers/ParseGraphQLController'; +import DatabaseController from '../Controllers/DatabaseController'; +import { toGraphQLError } from './parseGraphQLUtils'; +import * as schemaDirectives from './loaders/schemaDirectives'; +import * as schemaTypes from './loaders/schemaTypes'; +import { getFunctionNames } from '../triggers'; +import * as defaultRelaySchema from './loaders/defaultRelaySchema'; + +const RESERVED_GRAPHQL_TYPE_NAMES = [ + 'String', + 'Boolean', + 'Int', + 'Float', + 'ID', + 'ArrayResult', + 'Query', + 'Mutation', + 'Subscription', + 'CreateFileInput', + 'CreateFilePayload', + 'Viewer', + 'SignUpInput', + 'SignUpPayload', + 'LogInInput', + 'LogInPayload', + 'LogOutInput', + 'LogOutPayload', + 'CloudCodeFunction', + 'CallCloudCodeInput', + 'CallCloudCodePayload', + 'CreateClassInput', + 'CreateClassPayload', + 'UpdateClassInput', + 'UpdateClassPayload', + 'DeleteClassInput', + 'DeleteClassPayload', + 'PageInfo', +]; +const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes']; +const RESERVED_GRAPHQL_MUTATION_NAMES = [ + 'signUp', + 'logIn', + 'logOut', + 'createFile', + 'callCloudCode', + 'createClass', + 'updateClass', + 'deleteClass', +]; + +class ParseGraphQLSchema { + databaseController: DatabaseController; + parseGraphQLController: ParseGraphQLController; + parseGraphQLConfig: ParseGraphQLConfig; + log: any; + appId: string; + graphQLCustomTypeDefs: ?( + | string + | GraphQLSchema + | DocumentNode + | GraphQLNamedType[] + ); + + constructor( + params: { + databaseController: DatabaseController, + parseGraphQLController: ParseGraphQLController, + log: any, + appId: string, + graphQLCustomTypeDefs: ?( + | string + | GraphQLSchema + | DocumentNode + | GraphQLNamedType[] + ), + } = {} + ) { + this.parseGraphQLController = + params.parseGraphQLController || + requiredParameter('You must provide a parseGraphQLController instance!'); + this.databaseController = + params.databaseController || + requiredParameter('You must provide a databaseController instance!'); + this.log = + params.log || requiredParameter('You must provide a log instance!'); + this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs; + this.appId = + params.appId || requiredParameter('You must provide the appId!'); + } + + async load() { + const { parseGraphQLConfig } = await this._initializeSchemaAndConfig(); + const parseClasses = await this._getClassesForSchema(parseGraphQLConfig); + const parseClassesString = JSON.stringify(parseClasses); + const functionNames = await this._getFunctionNames(); + const functionNamesString = JSON.stringify(functionNames); + + if ( + this.graphQLSchema && + !this._hasSchemaInputChanged({ + parseClasses, + parseClassesString, + parseGraphQLConfig, + functionNamesString, + }) + ) { + return this.graphQLSchema; + } + + this.parseClasses = parseClasses; + this.parseClassesString = parseClassesString; + this.parseGraphQLConfig = parseGraphQLConfig; + this.functionNames = functionNames; + this.functionNamesString = functionNamesString; + this.parseClassTypes = {}; + this.viewerType = null; + this.graphQLAutoSchema = null; + this.graphQLSchema = null; + this.graphQLTypes = []; + this.graphQLQueries = {}; + this.graphQLMutations = {}; + this.graphQLSubscriptions = {}; + this.graphQLSchemaDirectivesDefinitions = null; + this.graphQLSchemaDirectives = {}; + this.relayNodeInterface = null; + + defaultGraphQLTypes.load(this); + defaultRelaySchema.load(this); + schemaTypes.load(this); + + this._getParseClassesWithConfig(parseClasses, parseGraphQLConfig).forEach( + ([parseClass, parseClassConfig]) => { + parseClassTypes.load(this, parseClass, parseClassConfig); + parseClassQueries.load(this, parseClass, parseClassConfig); + parseClassMutations.load(this, parseClass, parseClassConfig); + } + ); + + defaultGraphQLTypes.loadArrayResult(this, parseClasses); + defaultGraphQLQueries.load(this); + defaultGraphQLMutations.load(this); + + let graphQLQuery = undefined; + if (Object.keys(this.graphQLQueries).length > 0) { + graphQLQuery = new GraphQLObjectType({ + name: 'Query', + description: 'Query is the top level type for queries.', + fields: this.graphQLQueries, + }); + this.addGraphQLType(graphQLQuery, true, true); + } + + let graphQLMutation = undefined; + if (Object.keys(this.graphQLMutations).length > 0) { + graphQLMutation = new GraphQLObjectType({ + name: 'Mutation', + description: 'Mutation is the top level type for mutations.', + fields: this.graphQLMutations, + }); + this.addGraphQLType(graphQLMutation, true, true); + } + + let graphQLSubscription = undefined; + if (Object.keys(this.graphQLSubscriptions).length > 0) { + graphQLSubscription = new GraphQLObjectType({ + name: 'Subscription', + description: 'Subscription is the top level type for subscriptions.', + fields: this.graphQLSubscriptions, + }); + this.addGraphQLType(graphQLSubscription, true, true); + } + + this.graphQLAutoSchema = new GraphQLSchema({ + types: this.graphQLTypes, + query: graphQLQuery, + mutation: graphQLMutation, + subscription: graphQLSubscription, + }); + + if (this.graphQLCustomTypeDefs) { + schemaDirectives.load(this); + + if (typeof this.graphQLCustomTypeDefs.getTypeMap === 'function') { + const customGraphQLSchemaTypeMap = this.graphQLCustomTypeDefs.getTypeMap(); + Object.values(customGraphQLSchemaTypeMap).forEach( + customGraphQLSchemaType => { + if ( + !customGraphQLSchemaType || + !customGraphQLSchemaType.name || + customGraphQLSchemaType.name.startsWith('__') + ) { + return; + } + const autoGraphQLSchemaType = this.graphQLAutoSchema.getType( + customGraphQLSchemaType.name + ); + if ( + autoGraphQLSchemaType && + typeof customGraphQLSchemaType.getFields === 'function' + ) { + const findAndAddLastType = type => { + if (type.name) { + if (!this.graphQLAutoSchema.getType(type)) { + // To avoid schema stitching (Unknow type) bug on variables + // transfer the final type to the Auto Schema + this.graphQLAutoSchema._typeMap[type.name] = type; + } + } else { + if (type.ofType) { + findAndAddLastType(type.ofType); + } + } + }; + Object.values(customGraphQLSchemaType.getFields()).forEach( + field => { + findAndAddLastType(field.type); + if (field.args) { + field.args.forEach(arg => { + findAndAddLastType(arg.type); + }); + } + } + ); + autoGraphQLSchemaType._fields = { + ...autoGraphQLSchemaType._fields, + ...customGraphQLSchemaType._fields, + }; + } + } + ); + this.graphQLSchema = mergeSchemas({ + schemas: [ + this.graphQLSchemaDirectivesDefinitions, + this.graphQLCustomTypeDefs, + this.graphQLAutoSchema, + ], + mergeDirectives: true, + }); + } else if (typeof this.graphQLCustomTypeDefs === 'function') { + this.graphQLSchema = await this.graphQLCustomTypeDefs({ + directivesDefinitionsSchema: this.graphQLSchemaDirectivesDefinitions, + autoSchema: this.graphQLAutoSchema, + mergeSchemas, + }); + } else { + this.graphQLSchema = mergeSchemas({ + schemas: [ + this.graphQLSchemaDirectivesDefinitions, + this.graphQLAutoSchema, + this.graphQLCustomTypeDefs, + ], + mergeDirectives: true, + }); + } + + const graphQLSchemaTypeMap = this.graphQLSchema.getTypeMap(); + Object.keys(graphQLSchemaTypeMap).forEach(graphQLSchemaTypeName => { + const graphQLSchemaType = graphQLSchemaTypeMap[graphQLSchemaTypeName]; + if ( + typeof graphQLSchemaType.getFields === 'function' && + this.graphQLCustomTypeDefs.definitions + ) { + const graphQLCustomTypeDef = this.graphQLCustomTypeDefs.definitions.find( + definition => definition.name.value === graphQLSchemaTypeName + ); + if (graphQLCustomTypeDef) { + const graphQLSchemaTypeFieldMap = graphQLSchemaType.getFields(); + Object.keys(graphQLSchemaTypeFieldMap).forEach( + graphQLSchemaTypeFieldName => { + const graphQLSchemaTypeField = + graphQLSchemaTypeFieldMap[graphQLSchemaTypeFieldName]; + if (!graphQLSchemaTypeField.astNode) { + const astNode = graphQLCustomTypeDef.fields.find( + field => field.name.value === graphQLSchemaTypeFieldName + ); + if (astNode) { + graphQLSchemaTypeField.astNode = astNode; + } + } + } + ); + } + } + }); + + SchemaDirectiveVisitor.visitSchemaDirectives( + this.graphQLSchema, + this.graphQLSchemaDirectives + ); + } else { + this.graphQLSchema = this.graphQLAutoSchema; + } + + return this.graphQLSchema; + } + + addGraphQLType( + type, + throwError = false, + ignoreReserved = false, + ignoreConnection = false + ) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_TYPE_NAMES.includes(type.name)) || + this.graphQLTypes.find(existingType => existingType.name === type.name) || + (!ignoreConnection && type.name.endsWith('Connection')) + ) { + const message = `Type ${type.name} could not be added to the auto schema because it collided with an existing type.`; + if (throwError) { + throw new Error(message); + } + this.log.warn(message); + return undefined; + } + this.graphQLTypes.push(type); + return type; + } + + addGraphQLQuery( + fieldName, + field, + throwError = false, + ignoreReserved = false + ) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_QUERY_NAMES.includes(fieldName)) || + this.graphQLQueries[fieldName] + ) { + const message = `Query ${fieldName} could not be added to the auto schema because it collided with an existing field.`; + if (throwError) { + throw new Error(message); + } + this.log.warn(message); + return undefined; + } + this.graphQLQueries[fieldName] = field; + return field; + } + + addGraphQLMutation( + fieldName, + field, + throwError = false, + ignoreReserved = false + ) { + if ( + (!ignoreReserved && + RESERVED_GRAPHQL_MUTATION_NAMES.includes(fieldName)) || + this.graphQLMutations[fieldName] + ) { + const message = `Mutation ${fieldName} could not be added to the auto schema because it collided with an existing field.`; + if (throwError) { + throw new Error(message); + } + this.log.warn(message); + return undefined; + } + this.graphQLMutations[fieldName] = field; + return field; + } + + handleError(error) { + if (error instanceof Parse.Error) { + this.log.error('Parse error: ', error); + } else { + this.log.error('Uncaught internal server error.', error, error.stack); + } + throw toGraphQLError(error); + } + + async _initializeSchemaAndConfig() { + const [schemaController, parseGraphQLConfig] = await Promise.all([ + this.databaseController.loadSchema(), + this.parseGraphQLController.getGraphQLConfig(), + ]); + + this.schemaController = schemaController; + + return { + parseGraphQLConfig, + }; + } + + /** + * Gets all classes found by the `schemaController` + * minus those filtered out by the app's parseGraphQLConfig. + */ + async _getClassesForSchema(parseGraphQLConfig: ParseGraphQLConfig) { + const { enabledForClasses, disabledForClasses } = parseGraphQLConfig; + const allClasses = await this.schemaController.getAllClasses(); + + if (Array.isArray(enabledForClasses) || Array.isArray(disabledForClasses)) { + let includedClasses = allClasses; + if (enabledForClasses) { + includedClasses = allClasses.filter(clazz => { + return enabledForClasses.includes(clazz.className); + }); + } + if (disabledForClasses) { + // Classes included in `enabledForClasses` that + // are also present in `disabledForClasses` will + // still be filtered out + includedClasses = includedClasses.filter(clazz => { + return !disabledForClasses.includes(clazz.className); + }); + } + + this.isUsersClassDisabled = !includedClasses.some(clazz => { + return clazz.className === '_User'; + }); + + return includedClasses; + } else { + return allClasses; + } + } + + /** + * This method returns a list of tuples + * that provide the parseClass along with + * its parseClassConfig where provided. + */ + _getParseClassesWithConfig( + parseClasses, + parseGraphQLConfig: ParseGraphQLConfig + ) { + const { classConfigs } = parseGraphQLConfig; + + // Make sures that the default classes and classes that + // starts with capitalized letter will be generated first. + const sortClasses = (a, b) => { + a = a.className; + b = b.className; + if (a[0] === '_') { + if (b[0] !== '_') { + return -1; + } + } + if (b[0] === '_') { + if (a[0] !== '_') { + return 1; + } + } + if (a === b) { + return 0; + } else if (a < b) { + return -1; + } else { + return 1; + } + }; + + return parseClasses.sort(sortClasses).map(parseClass => { + let parseClassConfig; + if (classConfigs) { + parseClassConfig = classConfigs.find( + c => c.className === parseClass.className + ); + } + return [parseClass, parseClassConfig]; + }); + } + + async _getFunctionNames() { + return await getFunctionNames(this.appId).filter(functionName => { + if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) { + return true; + } else { + this.log.warn( + `Function ${functionName} could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.` + ); + return false; + } + }); + } + + /** + * Checks for changes to the parseClasses + * objects (i.e. database schema) or to + * the parseGraphQLConfig object. If no + * changes are found, return true; + */ + _hasSchemaInputChanged(params: { + parseClasses: any, + parseClassesString: string, + parseGraphQLConfig: ?ParseGraphQLConfig, + functionNamesString: string, + }): boolean { + const { + parseClasses, + parseClassesString, + parseGraphQLConfig, + functionNamesString, + } = params; + + if ( + JSON.stringify(this.parseGraphQLConfig) === + JSON.stringify(parseGraphQLConfig) && + this.functionNamesString === functionNamesString + ) { + if (this.parseClasses === parseClasses) { + return false; + } + + if (this.parseClassesString === parseClassesString) { + this.parseClasses = parseClasses; + return false; + } + } + + return true; + } +} + +export { ParseGraphQLSchema }; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js new file mode 100644 index 0000000000..a041721887 --- /dev/null +++ b/src/GraphQL/ParseGraphQLServer.js @@ -0,0 +1,152 @@ +import corsMiddleware from 'cors'; +import bodyParser from 'body-parser'; +import { graphqlUploadExpress } from 'graphql-upload'; +import { graphqlExpress } from 'apollo-server-express/dist/expressApollo'; +import { renderPlaygroundPage } from '@apollographql/graphql-playground-html'; +import { execute, subscribe } from 'graphql'; +import { SubscriptionServer } from 'subscriptions-transport-ws'; +import { handleParseErrors, handleParseHeaders } from '../middlewares'; +import requiredParameter from '../requiredParameter'; +import defaultLogger from '../logger'; +import { ParseGraphQLSchema } from './ParseGraphQLSchema'; +import ParseGraphQLController, { + ParseGraphQLConfig, +} from '../Controllers/ParseGraphQLController'; + +class ParseGraphQLServer { + parseGraphQLController: ParseGraphQLController; + + constructor(parseServer, config) { + this.parseServer = + parseServer || + requiredParameter('You must provide a parseServer instance!'); + if (!config || !config.graphQLPath) { + requiredParameter('You must provide a config.graphQLPath!'); + } + this.config = config; + this.parseGraphQLController = this.parseServer.config.parseGraphQLController; + this.log = + (this.parseServer.config && this.parseServer.config.loggerController) || + defaultLogger; + this.parseGraphQLSchema = new ParseGraphQLSchema({ + parseGraphQLController: this.parseGraphQLController, + databaseController: this.parseServer.config.databaseController, + log: this.log, + graphQLCustomTypeDefs: this.config.graphQLCustomTypeDefs, + appId: this.parseServer.config.appId, + }); + } + + async _getGraphQLOptions(req) { + try { + return { + schema: await this.parseGraphQLSchema.load(), + context: { + info: req.info, + config: req.config, + auth: req.auth, + }, + formatError: error => { + // Allow to console.log here to debug + return error; + }, + }; + } catch (e) { + this.log.error( + e.stack || (typeof e.toString === 'function' && e.toString()) || e + ); + throw e; + } + } + + _transformMaxUploadSizeToBytes(maxUploadSize) { + const unitMap = { + kb: 1, + mb: 2, + gb: 3, + }; + + return ( + Number(maxUploadSize.slice(0, -2)) * + Math.pow(1024, unitMap[maxUploadSize.slice(-2).toLowerCase()]) + ); + } + + applyGraphQL(app) { + if (!app || !app.use) { + requiredParameter('You must provide an Express.js app instance!'); + } + + app.use( + this.config.graphQLPath, + graphqlUploadExpress({ + maxFileSize: this._transformMaxUploadSizeToBytes( + this.parseServer.config.maxUploadSize || '20mb' + ), + }) + ); + app.use(this.config.graphQLPath, corsMiddleware()); + app.use(this.config.graphQLPath, bodyParser.json()); + app.use(this.config.graphQLPath, handleParseHeaders); + app.use(this.config.graphQLPath, handleParseErrors); + app.use( + this.config.graphQLPath, + graphqlExpress(async req => await this._getGraphQLOptions(req)) + ); + } + + applyPlayground(app) { + if (!app || !app.get) { + requiredParameter('You must provide an Express.js app instance!'); + } + app.get( + this.config.playgroundPath || + requiredParameter( + 'You must provide a config.playgroundPath to applyPlayground!' + ), + (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.write( + renderPlaygroundPage({ + endpoint: this.config.graphQLPath, + subscriptionEndpoint: this.config.subscriptionsPath, + headers: { + 'X-Parse-Application-Id': this.parseServer.config.appId, + 'X-Parse-Master-Key': this.parseServer.config.masterKey, + }, + }) + ); + res.end(); + } + ); + } + + createSubscriptions(server) { + SubscriptionServer.create( + { + execute, + subscribe, + onOperation: async (_message, params, webSocket) => + Object.assign( + {}, + params, + await this._getGraphQLOptions(webSocket.upgradeReq) + ), + }, + { + server, + path: + this.config.subscriptionsPath || + requiredParameter( + 'You must provide a config.subscriptionsPath to createSubscriptions!' + ), + } + ); + } + + setGraphQLConfig(graphQLConfig: ParseGraphQLConfig): Promise { + return this.parseGraphQLController.updateGraphQLConfig(graphQLConfig); + } +} + +export { ParseGraphQLServer }; diff --git a/src/GraphQL/helpers/objectsMutations.js b/src/GraphQL/helpers/objectsMutations.js new file mode 100644 index 0000000000..aa74e1c8f8 --- /dev/null +++ b/src/GraphQL/helpers/objectsMutations.js @@ -0,0 +1,39 @@ +import rest from '../../rest'; + +const createObject = async (className, fields, config, auth, info) => { + if (!fields) { + fields = {}; + } + + return (await rest.create(config, auth, className, fields, info.clientSDK)) + .response; +}; + +const updateObject = async ( + className, + objectId, + fields, + config, + auth, + info +) => { + if (!fields) { + fields = {}; + } + + return (await rest.update( + config, + auth, + className, + { objectId }, + fields, + info.clientSDK + )).response; +}; + +const deleteObject = async (className, objectId, config, auth, info) => { + await rest.del(config, auth, className, objectId, info.clientSDK); + return true; +}; + +export { createObject, updateObject, deleteObject }; diff --git a/src/GraphQL/helpers/objectsQueries.js b/src/GraphQL/helpers/objectsQueries.js new file mode 100644 index 0000000000..c1237deaea --- /dev/null +++ b/src/GraphQL/helpers/objectsQueries.js @@ -0,0 +1,329 @@ +import Parse from 'parse/node'; +import { offsetToCursor, cursorToOffset } from 'graphql-relay'; +import rest from '../../rest'; +import { transformQueryInputToParse } from '../transformers/query'; + +const needToGetAllKeys = (fields, keys) => + keys + ? !!keys.split(',').find(keyName => !fields[keyName.split('.')[0]]) + : true; + +const getObject = async ( + className, + objectId, + keys, + include, + readPreference, + includeReadPreference, + config, + auth, + info, + parseClass +) => { + const options = {}; + if (!needToGetAllKeys(parseClass.fields, keys)) { + options.keys = keys; + } + if (include) { + options.include = include; + if (includeReadPreference) { + options.includeReadPreference = includeReadPreference; + } + } + if (readPreference) { + options.readPreference = readPreference; + } + + const response = await rest.get( + config, + auth, + className, + objectId, + options, + info.clientSDK + ); + + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + + const object = response.results[0]; + if (className === '_User') { + delete object.sessionToken; + } + return object; +}; + +const findObjects = async ( + className, + where, + order, + skipInput, + first, + after, + last, + before, + keys, + include, + includeAll, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseClasses +) => { + if (!where) { + where = {}; + } + transformQueryInputToParse(where, className, parseClasses); + const skipAndLimitCalculation = calculateSkipAndLimit( + skipInput, + first, + after, + last, + before, + config.maxLimit + ); + let { skip } = skipAndLimitCalculation; + const { limit, needToPreCount } = skipAndLimitCalculation; + let preCount = undefined; + if (needToPreCount) { + const preCountOptions = { + limit: 0, + count: true, + }; + if (readPreference) { + preCountOptions.readPreference = readPreference; + } + if (Object.keys(where).length > 0 && subqueryReadPreference) { + preCountOptions.subqueryReadPreference = subqueryReadPreference; + } + preCount = ( + await rest.find( + config, + auth, + className, + where, + preCountOptions, + info.clientSDK + ) + ).count; + if ((skip || 0) + limit < preCount) { + skip = preCount - limit; + } + } + + const options = {}; + + if ( + selectedFields.find( + field => field.startsWith('edges.') || field.startsWith('pageInfo.') + ) + ) { + if (limit || limit === 0) { + options.limit = limit; + } else { + options.limit = 100; + } + if (options.limit !== 0) { + if (order) { + options.order = order; + } + if (skip) { + options.skip = skip; + } + if (config.maxLimit && options.limit > config.maxLimit) { + // Silently replace the limit on the query with the max configured + options.limit = config.maxLimit; + } + if ( + !needToGetAllKeys( + parseClasses.find( + ({ className: parseClassName }) => className === parseClassName + ).fields, + keys + ) + ) { + options.keys = keys; + } + if (includeAll === true) { + options.includeAll = includeAll; + } + if (!options.includeAll && include) { + options.include = include; + } + if ((options.includeAll || options.include) && includeReadPreference) { + options.includeReadPreference = includeReadPreference; + } + } + } else { + options.limit = 0; + } + + if ( + (selectedFields.includes('count') || + selectedFields.includes('pageInfo.hasPreviousPage') || + selectedFields.includes('pageInfo.hasNextPage')) && + !needToPreCount + ) { + options.count = true; + } + + if (readPreference) { + options.readPreference = readPreference; + } + if (Object.keys(where).length > 0 && subqueryReadPreference) { + options.subqueryReadPreference = subqueryReadPreference; + } + + let results, count; + if (options.count || !options.limit || (options.limit && options.limit > 0)) { + const findResult = await rest.find( + config, + auth, + className, + where, + options, + info.clientSDK + ); + results = findResult.results; + count = findResult.count; + } + + let edges = null; + let pageInfo = null; + if (results) { + edges = results.map((result, index) => ({ + cursor: offsetToCursor((skip || 0) + index), + node: result, + })); + + pageInfo = { + hasPreviousPage: + ((preCount && preCount > 0) || (count && count > 0)) && + skip !== undefined && + skip > 0, + startCursor: offsetToCursor(skip || 0), + endCursor: offsetToCursor((skip || 0) + (results.length || 1) - 1), + hasNextPage: (preCount || count) > (skip || 0) + results.length, + }; + } + + return { + edges, + pageInfo, + count: preCount || count, + }; +}; + +const calculateSkipAndLimit = ( + skipInput, + first, + after, + last, + before, + maxLimit +) => { + let skip = undefined; + let limit = undefined; + let needToPreCount = false; + + // Validates the skip input + if (skipInput || skipInput === 0) { + if (skipInput < 0) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Skip should be a positive number' + ); + } + skip = skipInput; + } + + // Validates the after param + if (after) { + after = cursorToOffset(after); + if ((!after && after !== 0) || after < 0) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'After is not a valid cursor' + ); + } + + // If skip and after are passed, a new skip is calculated by adding them + skip = (skip || 0) + (after + 1); + } + + // Validates the first param + if (first || first === 0) { + if (first < 0) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'First should be a positive number' + ); + } + + // The first param is translated to the limit param of the Parse legacy API + limit = first; + } + + // Validates the before param + if (before || before === 0) { + // This method converts the cursor to the index of the object + before = cursorToOffset(before); + if ((!before && before !== 0) || before < 0) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Before is not a valid cursor' + ); + } + + if ((skip || 0) >= before) { + // If the before index is less then the skip, no objects will be returned + limit = 0; + } else if ((!limit && limit !== 0) || (skip || 0) + limit > before) { + // If there is no limit set, the limit is calculated. Or, if the limit (plus skip) is bigger than the before index, the new limit is set. + limit = before - (skip || 0); + } + } + + // Validates the last param + if (last || last === 0) { + if (last < 0) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Last should be a positive number' + ); + } + + if (last > maxLimit) { + // Last can't be bigger than Parse server maxLimit config. + last = maxLimit; + } + + if (limit || limit === 0) { + // If there is a previous limit set, it may be adjusted + if (last < limit) { + // if last is less than the current limit + skip = (skip || 0) + (limit - last); // The skip is adjusted + limit = last; // the limit is adjusted + } + } else if (last === 0) { + // No objects will be returned + limit = 0; + } else { + // No previous limit set, the limit will be equal to last and pre count is needed. + limit = last; + needToPreCount = true; + } + } + return { + skip, + limit, + needToPreCount, + }; +}; + +export { getObject, findObjects, calculateSkipAndLimit, needToGetAllKeys }; diff --git a/src/GraphQL/loaders/defaultGraphQLMutations.js b/src/GraphQL/loaders/defaultGraphQLMutations.js new file mode 100644 index 0000000000..4d997a4822 --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLMutations.js @@ -0,0 +1,13 @@ +import * as filesMutations from './filesMutations'; +import * as usersMutations from './usersMutations'; +import * as functionsMutations from './functionsMutations'; +import * as schemaMutations from './schemaMutations'; + +const load = parseGraphQLSchema => { + filesMutations.load(parseGraphQLSchema); + usersMutations.load(parseGraphQLSchema); + functionsMutations.load(parseGraphQLSchema); + schemaMutations.load(parseGraphQLSchema); +}; + +export { load }; diff --git a/src/GraphQL/loaders/defaultGraphQLQueries.js b/src/GraphQL/loaders/defaultGraphQLQueries.js new file mode 100644 index 0000000000..8e8616ca5f --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLQueries.js @@ -0,0 +1,22 @@ +import { GraphQLNonNull, GraphQLBoolean } from 'graphql'; +import * as usersQueries from './usersQueries'; +import * as schemaQueries from './schemaQueries'; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLQuery( + 'health', + { + description: + 'The health query can be used to check if the server is up and running.', + type: new GraphQLNonNull(GraphQLBoolean), + resolve: () => true, + }, + true, + true + ); + + usersQueries.load(parseGraphQLSchema); + schemaQueries.load(parseGraphQLSchema); +}; + +export { load }; diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js new file mode 100644 index 0000000000..581a59a45a --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -0,0 +1,1391 @@ +import { + Kind, + GraphQLNonNull, + GraphQLScalarType, + GraphQLID, + GraphQLString, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLEnumType, + GraphQLInt, + GraphQLFloat, + GraphQLList, + GraphQLInputObjectType, + GraphQLBoolean, + GraphQLUnionType, +} from 'graphql'; +import { GraphQLUpload } from 'graphql-upload'; + +class TypeValidationError extends Error { + constructor(value, type) { + super(`${value} is not a valid ${type}`); + } +} + +const parseStringValue = value => { + if (typeof value === 'string') { + return value; + } + + throw new TypeValidationError(value, 'String'); +}; + +const parseIntValue = value => { + if (typeof value === 'string') { + const int = Number(value); + if (Number.isInteger(int)) { + return int; + } + } + + throw new TypeValidationError(value, 'Int'); +}; + +const parseFloatValue = value => { + if (typeof value === 'string') { + const float = Number(value); + if (!isNaN(float)) { + return float; + } + } + + throw new TypeValidationError(value, 'Float'); +}; + +const parseBooleanValue = value => { + if (typeof value === 'boolean') { + return value; + } + + throw new TypeValidationError(value, 'Boolean'); +}; + +const parseValue = value => { + switch (value.kind) { + case Kind.STRING: + return parseStringValue(value.value); + + case Kind.INT: + return parseIntValue(value.value); + + case Kind.FLOAT: + return parseFloatValue(value.value); + + case Kind.BOOLEAN: + return parseBooleanValue(value.value); + + case Kind.LIST: + return parseListValues(value.values); + + case Kind.OBJECT: + return parseObjectFields(value.fields); + + default: + return value.value; + } +}; + +const parseListValues = values => { + if (Array.isArray(values)) { + return values.map(value => parseValue(value)); + } + + throw new TypeValidationError(values, 'List'); +}; + +const parseObjectFields = fields => { + if (Array.isArray(fields)) { + return fields.reduce( + (object, field) => ({ + ...object, + [field.name.value]: parseValue(field.value), + }), + {} + ); + } + + throw new TypeValidationError(fields, 'Object'); +}; + +const ANY = new GraphQLScalarType({ + name: 'Any', + description: + 'The Any scalar type is used in operations and types that involve any type of value.', + parseValue: value => value, + serialize: value => value, + parseLiteral: ast => parseValue(ast), +}); + +const OBJECT = new GraphQLScalarType({ + name: 'Object', + description: + 'The Object scalar type is used in operations and types that involve objects.', + parseValue(value) { + if (typeof value === 'object') { + return value; + } + + throw new TypeValidationError(value, 'Object'); + }, + serialize(value) { + if (typeof value === 'object') { + return value; + } + + throw new TypeValidationError(value, 'Object'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.OBJECT) { + return parseObjectFields(ast.fields); + } + + throw new TypeValidationError(ast.kind, 'Object'); + }, +}); + +const parseDateIsoValue = value => { + if (typeof value === 'string') { + const date = new Date(value); + if (!isNaN(date)) { + return date; + } + } else if (value instanceof Date) { + return value; + } + + throw new TypeValidationError(value, 'Date'); +}; + +const serializeDateIso = value => { + if (typeof value === 'string') { + return value; + } + if (value instanceof Date) { + return value.toUTCString(); + } + + throw new TypeValidationError(value, 'Date'); +}; + +const parseDateIsoLiteral = ast => { + if (ast.kind === Kind.STRING) { + return parseDateIsoValue(ast.value); + } + + throw new TypeValidationError(ast.kind, 'Date'); +}; + +const DATE = new GraphQLScalarType({ + name: 'Date', + description: + 'The Date scalar type is used in operations and types that involve dates.', + parseValue(value) { + if (typeof value === 'string' || value instanceof Date) { + return { + __type: 'Date', + iso: parseDateIsoValue(value), + }; + } else if ( + typeof value === 'object' && + value.__type === 'Date' && + value.iso + ) { + return { + __type: value.__type, + iso: parseDateIsoValue(value.iso), + }; + } + + throw new TypeValidationError(value, 'Date'); + }, + serialize(value) { + if (typeof value === 'string' || value instanceof Date) { + return serializeDateIso(value); + } else if ( + typeof value === 'object' && + value.__type === 'Date' && + value.iso + ) { + return serializeDateIso(value.iso); + } + + throw new TypeValidationError(value, 'Date'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return { + __type: 'Date', + iso: parseDateIsoLiteral(ast), + }; + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const iso = ast.fields.find(field => field.name.value === 'iso'); + if (__type && __type.value && __type.value.value === 'Date' && iso) { + return { + __type: __type.value.value, + iso: parseDateIsoLiteral(iso.value), + }; + } + } + + throw new TypeValidationError(ast.kind, 'Date'); + }, +}); + +const BYTES = new GraphQLScalarType({ + name: 'Bytes', + description: + 'The Bytes scalar type is used in operations and types that involve base 64 binary data.', + parseValue(value) { + if (typeof value === 'string') { + return { + __type: 'Bytes', + base64: value, + }; + } else if ( + typeof value === 'object' && + value.__type === 'Bytes' && + typeof value.base64 === 'string' + ) { + return value; + } + + throw new TypeValidationError(value, 'Bytes'); + }, + serialize(value) { + if (typeof value === 'string') { + return value; + } else if ( + typeof value === 'object' && + value.__type === 'Bytes' && + typeof value.base64 === 'string' + ) { + return value.base64; + } + + throw new TypeValidationError(value, 'Bytes'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return { + __type: 'Bytes', + base64: ast.value, + }; + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const base64 = ast.fields.find(field => field.name.value === 'base64'); + if ( + __type && + __type.value && + __type.value.value === 'Bytes' && + base64 && + base64.value && + typeof base64.value.value === 'string' + ) { + return { + __type: __type.value.value, + base64: base64.value.value, + }; + } + } + + throw new TypeValidationError(ast.kind, 'Bytes'); + }, +}); + +const parseFileValue = value => { + if (typeof value === 'string') { + return { + __type: 'File', + name: value, + }; + } else if ( + typeof value === 'object' && + value.__type === 'File' && + typeof value.name === 'string' && + (value.url === undefined || typeof value.url === 'string') + ) { + return value; + } + + throw new TypeValidationError(value, 'File'); +}; + +const FILE = new GraphQLScalarType({ + name: 'File', + description: + 'The File scalar type is used in operations and types that involve files.', + parseValue: parseFileValue, + serialize: value => { + if (typeof value === 'string') { + return value; + } else if ( + typeof value === 'object' && + value.__type === 'File' && + typeof value.name === 'string' && + (value.url === undefined || typeof value.url === 'string') + ) { + return value.name; + } + + throw new TypeValidationError(value, 'File'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return parseFileValue(ast.value); + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const name = ast.fields.find(field => field.name.value === 'name'); + const url = ast.fields.find(field => field.name.value === 'url'); + if (__type && __type.value && name && name.value) { + return parseFileValue({ + __type: __type.value.value, + name: name.value.value, + url: url && url.value ? url.value.value : undefined, + }); + } + } + + throw new TypeValidationError(ast.kind, 'File'); + }, +}); + +const FILE_INFO = new GraphQLObjectType({ + name: 'FileInfo', + description: + 'The FileInfo object type is used to return the information about files.', + fields: { + name: { + description: 'This is the file name.', + type: new GraphQLNonNull(GraphQLString), + }, + url: { + description: 'This is the url in which the file can be downloaded.', + type: new GraphQLNonNull(GraphQLString), + }, + }, +}); + +const FILE_INPUT = new GraphQLInputObjectType({ + name: 'FileInput', + fields: { + file: { + description: 'A File Scalar can be an url or a FileInfo object.', + type: FILE, + }, + upload: { + description: 'Use this field if you want to create a new file.', + type: GraphQLUpload, + }, + }, +}); + +const GEO_POINT_FIELDS = { + latitude: { + description: 'This is the latitude.', + type: new GraphQLNonNull(GraphQLFloat), + }, + longitude: { + description: 'This is the longitude.', + type: new GraphQLNonNull(GraphQLFloat), + }, +}; + +const GEO_POINT_INPUT = new GraphQLInputObjectType({ + name: 'GeoPointInput', + description: + 'The GeoPointInput type is used in operations that involve inputting fields of type geo point.', + fields: GEO_POINT_FIELDS, +}); + +const GEO_POINT = new GraphQLObjectType({ + name: 'GeoPoint', + description: + 'The GeoPoint object type is used to return the information about geo point fields.', + fields: GEO_POINT_FIELDS, +}); + +const POLYGON_INPUT = new GraphQLList(new GraphQLNonNull(GEO_POINT_INPUT)); + +const POLYGON = new GraphQLList(new GraphQLNonNull(GEO_POINT)); + +const USER_ACL_INPUT = new GraphQLInputObjectType({ + name: 'UserACLInput', + description: 'Allow to manage users in ACL.', + fields: { + userId: { + description: 'ID of the targetted User.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: 'Allow the user to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow the user to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ROLE_ACL_INPUT = new GraphQLInputObjectType({ + name: 'RoleACLInput', + description: 'Allow to manage roles in ACL.', + fields: { + roleName: { + description: 'Name of the targetted Role.', + type: new GraphQLNonNull(GraphQLString), + }, + read: { + description: + 'Allow users who are members of the role to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: + 'Allow users who are members of the role to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const PUBLIC_ACL_INPUT = new GraphQLInputObjectType({ + name: 'PublicACLInput', + description: 'Allow to manage public rights.', + fields: { + read: { + description: 'Allow anyone to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow anyone to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ACL_INPUT = new GraphQLInputObjectType({ + name: 'ACLInput', + description: + 'Allow to manage access rights. If not provided object will be publicly readable and writable', + fields: { + users: { + description: 'Access control list for users.', + type: new GraphQLList(new GraphQLNonNull(USER_ACL_INPUT)), + }, + roles: { + description: 'Access control list for roles.', + type: new GraphQLList(new GraphQLNonNull(ROLE_ACL_INPUT)), + }, + public: { + description: 'Public access control list.', + type: PUBLIC_ACL_INPUT, + }, + }, +}); + +const USER_ACL = new GraphQLObjectType({ + name: 'UserACL', + description: + 'Allow to manage users in ACL. If read and write are null the users have read and write rights.', + fields: { + userId: { + description: 'ID of the targetted User.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: 'Allow the user to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow the user to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ROLE_ACL = new GraphQLObjectType({ + name: 'RoleACL', + description: + 'Allow to manage roles in ACL. If read and write are null the role have read and write rights.', + fields: { + roleName: { + description: 'Name of the targetted Role.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: + 'Allow users who are members of the role to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: + 'Allow users who are members of the role to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const PUBLIC_ACL = new GraphQLObjectType({ + name: 'PublicACL', + description: 'Allow to manage public rights.', + fields: { + read: { + description: 'Allow anyone to read the current object.', + type: GraphQLBoolean, + }, + write: { + description: 'Allow anyone to write on the current object.', + type: GraphQLBoolean, + }, + }, +}); + +const ACL = new GraphQLObjectType({ + name: 'ACL', + description: 'Current access control list of the current object.', + fields: { + users: { + description: 'Access control list for users.', + type: new GraphQLList(new GraphQLNonNull(USER_ACL)), + resolve(p) { + const users = []; + Object.keys(p).forEach(rule => { + if (rule !== '*' && rule.indexOf('role:') !== 0) { + users.push({ + userId: rule, + read: p[rule].read ? true : false, + write: p[rule].write ? true : false, + }); + } + }); + return users.length ? users : null; + }, + }, + roles: { + description: 'Access control list for roles.', + type: new GraphQLList(new GraphQLNonNull(ROLE_ACL)), + resolve(p) { + const roles = []; + Object.keys(p).forEach(rule => { + if (rule.indexOf('role:') === 0) { + roles.push({ + roleName: rule.replace('role:', ''), + read: p[rule].read ? true : false, + write: p[rule].write ? true : false, + }); + } + }); + return roles.length ? roles : null; + }, + }, + public: { + description: 'Public access control list.', + type: PUBLIC_ACL, + resolve(p) { + /* eslint-disable */ + return p['*'] + ? { + read: p['*'].read ? true : false, + write: p['*'].write ? true : false, + } + : null; + }, + }, + }, +}); + +const OBJECT_ID = new GraphQLNonNull(GraphQLID); + +const CLASS_NAME_ATT = { + description: 'This is the class name of the object.', + type: new GraphQLNonNull(GraphQLString), +}; + +const GLOBAL_OR_OBJECT_ID_ATT = { + description: + 'This is the object id. You can use either the global or the object id.', + type: OBJECT_ID, +}; + +const OBJECT_ID_ATT = { + description: 'This is the object id.', + type: OBJECT_ID, +}; + +const CREATED_AT_ATT = { + description: 'This is the date in which the object was created.', + type: new GraphQLNonNull(DATE), +}; + +const UPDATED_AT_ATT = { + description: 'This is the date in which the object was las updated.', + type: new GraphQLNonNull(DATE), +}; + +const INPUT_FIELDS = { + ACL: { + type: ACL, + }, +}; + +const CREATE_RESULT_FIELDS = { + objectId: OBJECT_ID_ATT, + createdAt: CREATED_AT_ATT, +}; + +const UPDATE_RESULT_FIELDS = { + updatedAt: UPDATED_AT_ATT, +}; + +const PARSE_OBJECT_FIELDS = { + ...CREATE_RESULT_FIELDS, + ...UPDATE_RESULT_FIELDS, + ...INPUT_FIELDS, + ACL: { + type: new GraphQLNonNull(ACL), + resolve: ({ ACL }) => (ACL ? ACL : { '*': { read: true, write: true } }), + }, +}; + +const PARSE_OBJECT = new GraphQLInterfaceType({ + name: 'ParseObject', + description: + 'The ParseObject interface type is used as a base type for the auto generated object types.', + fields: PARSE_OBJECT_FIELDS, +}); + +const SESSION_TOKEN_ATT = { + description: 'The current user session token.', + type: new GraphQLNonNull(GraphQLString), +}; + +const READ_PREFERENCE = new GraphQLEnumType({ + name: 'ReadPreference', + description: + 'The ReadPreference enum type is used in queries in order to select in which database replica the operation must run.', + values: { + PRIMARY: { value: 'PRIMARY' }, + PRIMARY_PREFERRED: { value: 'PRIMARY_PREFERRED' }, + SECONDARY: { value: 'SECONDARY' }, + SECONDARY_PREFERRED: { value: 'SECONDARY_PREFERRED' }, + NEAREST: { value: 'NEAREST' }, + }, +}); + +const READ_PREFERENCE_ATT = { + description: 'The read preference for the main query to be executed.', + type: READ_PREFERENCE, +}; + +const INCLUDE_READ_PREFERENCE_ATT = { + description: + 'The read preference for the queries to be executed to include fields.', + type: READ_PREFERENCE, +}; + +const SUBQUERY_READ_PREFERENCE_ATT = { + description: 'The read preference for the subqueries that may be required.', + type: READ_PREFERENCE, +}; + +const READ_OPTIONS_INPUT = new GraphQLInputObjectType({ + name: 'ReadOptionsInput', + description: + 'The ReadOptionsInputt type is used in queries in order to set the read preferences.', + fields: { + readPreference: READ_PREFERENCE_ATT, + includeReadPreference: INCLUDE_READ_PREFERENCE_ATT, + subqueryReadPreference: SUBQUERY_READ_PREFERENCE_ATT, + }, +}); + +const READ_OPTIONS_ATT = { + description: 'The read options for the query to be executed.', + type: READ_OPTIONS_INPUT, +}; + +const WHERE_ATT = { + description: + 'These are the conditions that the objects need to match in order to be found', + type: OBJECT, +}; + +const SKIP_ATT = { + description: 'This is the number of objects that must be skipped to return.', + type: GraphQLInt, +}; + +const LIMIT_ATT = { + description: 'This is the limit number of objects that must be returned.', + type: GraphQLInt, +}; + +const COUNT_ATT = { + description: + 'This is the total matched objecs count that is returned when the count flag is set.', + type: new GraphQLNonNull(GraphQLInt), +}; + +const SEARCH_INPUT = new GraphQLInputObjectType({ + name: 'SearchInput', + description: + 'The SearchInput type is used to specifiy a search operation on a full text search.', + fields: { + term: { + description: 'This is the term to be searched.', + type: new GraphQLNonNull(GraphQLString), + }, + language: { + description: + 'This is the language to tetermine the list of stop words and the rules for tokenizer.', + type: GraphQLString, + }, + caseSensitive: { + description: + 'This is the flag to enable or disable case sensitive search.', + type: GraphQLBoolean, + }, + diacriticSensitive: { + description: + 'This is the flag to enable or disable diacritic sensitive search.', + type: GraphQLBoolean, + }, + }, +}); + +const TEXT_INPUT = new GraphQLInputObjectType({ + name: 'TextInput', + description: + 'The TextInput type is used to specify a text operation on a constraint.', + fields: { + search: { + description: 'This is the search to be executed.', + type: new GraphQLNonNull(SEARCH_INPUT), + }, + }, +}); + +const BOX_INPUT = new GraphQLInputObjectType({ + name: 'BoxInput', + description: + 'The BoxInput type is used to specifiy a box operation on a within geo query.', + fields: { + bottomLeft: { + description: 'This is the bottom left coordinates of the box.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + upperRight: { + description: 'This is the upper right coordinates of the box.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + }, +}); + +const WITHIN_INPUT = new GraphQLInputObjectType({ + name: 'WithinInput', + description: + 'The WithinInput type is used to specify a within operation on a constraint.', + fields: { + box: { + description: 'This is the box to be specified.', + type: new GraphQLNonNull(BOX_INPUT), + }, + }, +}); + +const CENTER_SPHERE_INPUT = new GraphQLInputObjectType({ + name: 'CenterSphereInput', + description: + 'The CenterSphereInput type is used to specifiy a centerSphere operation on a geoWithin query.', + fields: { + center: { + description: 'This is the center of the sphere.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + distance: { + description: 'This is the radius of the sphere.', + type: new GraphQLNonNull(GraphQLFloat), + }, + }, +}); + +const GEO_WITHIN_INPUT = new GraphQLInputObjectType({ + name: 'GeoWithinInput', + description: + 'The GeoWithinInput type is used to specify a geoWithin operation on a constraint.', + fields: { + polygon: { + description: 'This is the polygon to be specified.', + type: POLYGON_INPUT, + }, + centerSphere: { + description: 'This is the sphere to be specified.', + type: CENTER_SPHERE_INPUT, + }, + }, +}); + +const GEO_INTERSECTS_INPUT = new GraphQLInputObjectType({ + name: 'GeoIntersectsInput', + description: + 'The GeoIntersectsInput type is used to specify a geoIntersects operation on a constraint.', + fields: { + point: { + description: 'This is the point to be specified.', + type: GEO_POINT_INPUT, + }, + }, +}); + +const equalTo = type => ({ + description: + 'This is the equalTo operator to specify a constraint to select the objects where the value of a field equals to a specified value.', + type, +}); + +const notEqualTo = type => ({ + description: + 'This is the notEqualTo operator to specify a constraint to select the objects where the value of a field do not equal to a specified value.', + type, +}); + +const lessThan = type => ({ + description: + 'This is the lessThan operator to specify a constraint to select the objects where the value of a field is less than a specified value.', + type, +}); + +const lessThanOrEqualTo = type => ({ + description: + 'This is the lessThanOrEqualTo operator to specify a constraint to select the objects where the value of a field is less than or equal to a specified value.', + type, +}); + +const greaterThan = type => ({ + description: + 'This is the greaterThan operator to specify a constraint to select the objects where the value of a field is greater than a specified value.', + type, +}); + +const greaterThanOrEqualTo = type => ({ + description: + 'This is the greaterThanOrEqualTo operator to specify a constraint to select the objects where the value of a field is greater than or equal to a specified value.', + type, +}); + +const inOp = type => ({ + description: + 'This is the in operator to specify a constraint to select the objects where the value of a field equals any value in the specified array.', + type: new GraphQLList(type), +}); + +const notIn = type => ({ + description: + 'This is the notIn operator to specify a constraint to select the objects where the value of a field do not equal any value in the specified array.', + type: new GraphQLList(type), +}); + +const exists = { + description: + 'This is the exists operator to specify a constraint to select the objects where a field exists (or do not exist).', + type: GraphQLBoolean, +}; + +const matchesRegex = { + description: + 'This is the matchesRegex operator to specify a constraint to select the objects where the value of a field matches a specified regular expression.', + type: GraphQLString, +}; + +const options = { + description: + 'This is the options operator to specify optional flags (such as "i" and "m") to be added to a matchesRegex operation in the same set of constraints.', + type: GraphQLString, +}; + +const SUBQUERY_INPUT = new GraphQLInputObjectType({ + name: 'SubqueryInput', + description: + 'The SubqueryInput type is used to specify a sub query to another class.', + fields: { + className: CLASS_NAME_ATT, + where: Object.assign({}, WHERE_ATT, { + type: new GraphQLNonNull(WHERE_ATT.type), + }), + }, +}); + +const SELECT_INPUT = new GraphQLInputObjectType({ + name: 'SelectInput', + description: + 'The SelectInput type is used to specify an inQueryKey or a notInQueryKey operation on a constraint.', + fields: { + query: { + description: 'This is the subquery to be executed.', + type: new GraphQLNonNull(SUBQUERY_INPUT), + }, + key: { + description: + 'This is the key in the result of the subquery that must match (not match) the field.', + type: new GraphQLNonNull(GraphQLString), + }, + }, +}); + +const inQueryKey = { + description: + 'This is the inQueryKey operator to specify a constraint to select the objects where a field equals to a key in the result of a different query.', + type: SELECT_INPUT, +}; + +const notInQueryKey = { + description: + 'This is the notInQueryKey operator to specify a constraint to select the objects where a field do not equal to a key in the result of a different query.', + type: SELECT_INPUT, +}; + +const ID_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'IdWhereInput', + description: + 'The IdWhereInput input type is used in operations that involve filtering objects by an id.', + fields: { + equalTo: equalTo(GraphQLID), + notEqualTo: notEqualTo(GraphQLID), + lessThan: lessThan(GraphQLID), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLID), + greaterThan: greaterThan(GraphQLID), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLID), + in: inOp(GraphQLID), + notIn: notIn(GraphQLID), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const STRING_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'StringWhereInput', + description: + 'The StringWhereInput input type is used in operations that involve filtering objects by a field of type String.', + fields: { + equalTo: equalTo(GraphQLString), + notEqualTo: notEqualTo(GraphQLString), + lessThan: lessThan(GraphQLString), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLString), + greaterThan: greaterThan(GraphQLString), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLString), + in: inOp(GraphQLString), + notIn: notIn(GraphQLString), + exists, + matchesRegex, + options, + text: { + description: + 'This is the $text operator to specify a full text search constraint.', + type: TEXT_INPUT, + }, + inQueryKey, + notInQueryKey, + }, +}); + +const NUMBER_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'NumberWhereInput', + description: + 'The NumberWhereInput input type is used in operations that involve filtering objects by a field of type Number.', + fields: { + equalTo: equalTo(GraphQLFloat), + notEqualTo: notEqualTo(GraphQLFloat), + lessThan: lessThan(GraphQLFloat), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLFloat), + greaterThan: greaterThan(GraphQLFloat), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLFloat), + in: inOp(GraphQLFloat), + notIn: notIn(GraphQLFloat), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const BOOLEAN_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'BooleanWhereInput', + description: + 'The BooleanWhereInput input type is used in operations that involve filtering objects by a field of type Boolean.', + fields: { + equalTo: equalTo(GraphQLBoolean), + notEqualTo: notEqualTo(GraphQLBoolean), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const ARRAY_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'ArrayWhereInput', + description: + 'The ArrayWhereInput input type is used in operations that involve filtering objects by a field of type Array.', + fields: { + equalTo: equalTo(ANY), + notEqualTo: notEqualTo(ANY), + lessThan: lessThan(ANY), + lessThanOrEqualTo: lessThanOrEqualTo(ANY), + greaterThan: greaterThan(ANY), + greaterThanOrEqualTo: greaterThanOrEqualTo(ANY), + in: inOp(ANY), + notIn: notIn(ANY), + exists, + containedBy: { + description: + 'This is the containedBy operator to specify a constraint to select the objects where the values of an array field is contained by another specified array.', + type: new GraphQLList(ANY), + }, + contains: { + description: + 'This is the contains operator to specify a constraint to select the objects where the values of an array field contain all elements of another specified array.', + type: new GraphQLList(ANY), + }, + inQueryKey, + notInQueryKey, + }, +}); + +const KEY_VALUE_INPUT = new GraphQLInputObjectType({ + name: 'KeyValueInput', + description: 'An entry from an object, i.e., a pair of key and value.', + fields: { + key: { + description: 'The key used to retrieve the value of this entry.', + type: new GraphQLNonNull(GraphQLString), + }, + value: { + description: 'The value of the entry. Could be any type of scalar data.', + type: new GraphQLNonNull(ANY), + }, + }, +}); + +const OBJECT_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'ObjectWhereInput', + description: + 'The ObjectWhereInput input type is used in operations that involve filtering result by a field of type Object.', + fields: { + equalTo: equalTo(KEY_VALUE_INPUT), + notEqualTo: notEqualTo(KEY_VALUE_INPUT), + in: inOp(KEY_VALUE_INPUT), + notIn: notIn(KEY_VALUE_INPUT), + lessThan: lessThan(KEY_VALUE_INPUT), + lessThanOrEqualTo: lessThanOrEqualTo(KEY_VALUE_INPUT), + greaterThan: greaterThan(KEY_VALUE_INPUT), + greaterThanOrEqualTo: greaterThanOrEqualTo(KEY_VALUE_INPUT), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const DATE_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'DateWhereInput', + description: + 'The DateWhereInput input type is used in operations that involve filtering objects by a field of type Date.', + fields: { + equalTo: equalTo(DATE), + notEqualTo: notEqualTo(DATE), + lessThan: lessThan(DATE), + lessThanOrEqualTo: lessThanOrEqualTo(DATE), + greaterThan: greaterThan(DATE), + greaterThanOrEqualTo: greaterThanOrEqualTo(DATE), + in: inOp(DATE), + notIn: notIn(DATE), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const BYTES_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'BytesWhereInput', + description: + 'The BytesWhereInput input type is used in operations that involve filtering objects by a field of type Bytes.', + fields: { + equalTo: equalTo(BYTES), + notEqualTo: notEqualTo(BYTES), + lessThan: lessThan(BYTES), + lessThanOrEqualTo: lessThanOrEqualTo(BYTES), + greaterThan: greaterThan(BYTES), + greaterThanOrEqualTo: greaterThanOrEqualTo(BYTES), + in: inOp(BYTES), + notIn: notIn(BYTES), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const FILE_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'FileWhereInput', + description: + 'The FileWhereInput input type is used in operations that involve filtering objects by a field of type File.', + fields: { + equalTo: equalTo(FILE), + notEqualTo: notEqualTo(FILE), + lessThan: lessThan(FILE), + lessThanOrEqualTo: lessThanOrEqualTo(FILE), + greaterThan: greaterThan(FILE), + greaterThanOrEqualTo: greaterThanOrEqualTo(FILE), + in: inOp(FILE), + notIn: notIn(FILE), + exists, + matchesRegex, + options, + inQueryKey, + notInQueryKey, + }, +}); + +const GEO_POINT_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'GeoPointWhereInput', + description: + 'The GeoPointWhereInput input type is used in operations that involve filtering objects by a field of type GeoPoint.', + fields: { + exists, + nearSphere: { + description: + 'This is the nearSphere operator to specify a constraint to select the objects where the values of a geo point field is near to another geo point.', + type: GEO_POINT_INPUT, + }, + maxDistance: { + description: + 'This is the maxDistance operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in radians) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInRadians: { + description: + 'This is the maxDistanceInRadians operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in radians) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInMiles: { + description: + 'This is the maxDistanceInMiles operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in miles) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInKilometers: { + description: + 'This is the maxDistanceInKilometers operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in kilometers) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + within: { + description: + 'This is the within operator to specify a constraint to select the objects where the values of a geo point field is within a specified box.', + type: WITHIN_INPUT, + }, + geoWithin: { + description: + 'This is the geoWithin operator to specify a constraint to select the objects where the values of a geo point field is within a specified polygon or sphere.', + type: GEO_WITHIN_INPUT, + }, + }, +}); + +const POLYGON_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'PolygonWhereInput', + description: + 'The PolygonWhereInput input type is used in operations that involve filtering objects by a field of type Polygon.', + fields: { + exists, + geoIntersects: { + description: + 'This is the geoIntersects operator to specify a constraint to select the objects where the values of a polygon field intersect a specified point.', + type: GEO_INTERSECTS_INPUT, + }, + }, +}); + +const ELEMENT = new GraphQLObjectType({ + name: 'Element', + description: "The Element object type is used to return array items' value.", + fields: { + value: { + description: 'Return the value of the element in the array', + type: new GraphQLNonNull(ANY), + }, + }, +}); + +// Default static union type, we update types and resolveType function later +let ARRAY_RESULT; + +const loadArrayResult = (parseGraphQLSchema, parseClasses) => { + const classTypes = parseClasses + .filter(parseClass => + parseGraphQLSchema.parseClassTypes[parseClass.className] + .classGraphQLOutputType + ? true + : false + ) + .map( + parseClass => + parseGraphQLSchema.parseClassTypes[parseClass.className] + .classGraphQLOutputType + ); + ARRAY_RESULT = new GraphQLUnionType({ + name: 'ArrayResult', + description: + 'Use Inline Fragment on Array to get results: https://graphql.org/learn/queries/#inline-fragments', + types: () => [ELEMENT, ...classTypes], + resolveType: value => { + if (value.__type === 'Object' && value.className && value.objectId) { + if (parseGraphQLSchema.parseClassTypes[value.className]) { + return parseGraphQLSchema.parseClassTypes[value.className] + .classGraphQLOutputType; + } else { + return ELEMENT; + } + } else { + return ELEMENT; + } + }, + }); + parseGraphQLSchema.graphQLTypes.push(ARRAY_RESULT); +}; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLType(GraphQLUpload, true); + parseGraphQLSchema.addGraphQLType(ANY, true); + parseGraphQLSchema.addGraphQLType(OBJECT, true); + parseGraphQLSchema.addGraphQLType(DATE, true); + parseGraphQLSchema.addGraphQLType(BYTES, true); + parseGraphQLSchema.addGraphQLType(FILE, true); + parseGraphQLSchema.addGraphQLType(FILE_INFO, true); + parseGraphQLSchema.addGraphQLType(FILE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT, true); + parseGraphQLSchema.addGraphQLType(PARSE_OBJECT, true); + parseGraphQLSchema.addGraphQLType(READ_PREFERENCE, true); + parseGraphQLSchema.addGraphQLType(READ_OPTIONS_INPUT, true); + parseGraphQLSchema.addGraphQLType(SEARCH_INPUT, true); + parseGraphQLSchema.addGraphQLType(TEXT_INPUT, true); + parseGraphQLSchema.addGraphQLType(BOX_INPUT, true); + parseGraphQLSchema.addGraphQLType(WITHIN_INPUT, true); + parseGraphQLSchema.addGraphQLType(CENTER_SPHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_WITHIN_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_INTERSECTS_INPUT, true); + parseGraphQLSchema.addGraphQLType(ID_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(STRING_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(NUMBER_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(BOOLEAN_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(ARRAY_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(KEY_VALUE_INPUT, true); + parseGraphQLSchema.addGraphQLType(OBJECT_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(DATE_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(BYTES_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(FILE_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(POLYGON_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(ELEMENT, true); + parseGraphQLSchema.addGraphQLType(ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(USER_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(ROLE_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(PUBLIC_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(ACL, true); + parseGraphQLSchema.addGraphQLType(USER_ACL, true); + parseGraphQLSchema.addGraphQLType(ROLE_ACL, true); + parseGraphQLSchema.addGraphQLType(PUBLIC_ACL, true); + parseGraphQLSchema.addGraphQLType(SUBQUERY_INPUT, true); + parseGraphQLSchema.addGraphQLType(SELECT_INPUT, true); +}; + +export { + TypeValidationError, + parseStringValue, + parseIntValue, + parseFloatValue, + parseBooleanValue, + parseValue, + parseListValues, + parseObjectFields, + ANY, + OBJECT, + parseDateIsoValue, + serializeDateIso, + DATE, + BYTES, + parseFileValue, + SUBQUERY_INPUT, + SELECT_INPUT, + FILE, + FILE_INFO, + FILE_INPUT, + GEO_POINT_FIELDS, + GEO_POINT_INPUT, + GEO_POINT, + POLYGON_INPUT, + POLYGON, + OBJECT_ID, + CLASS_NAME_ATT, + GLOBAL_OR_OBJECT_ID_ATT, + OBJECT_ID_ATT, + UPDATED_AT_ATT, + CREATED_AT_ATT, + INPUT_FIELDS, + CREATE_RESULT_FIELDS, + UPDATE_RESULT_FIELDS, + PARSE_OBJECT_FIELDS, + PARSE_OBJECT, + SESSION_TOKEN_ATT, + READ_PREFERENCE, + READ_PREFERENCE_ATT, + INCLUDE_READ_PREFERENCE_ATT, + SUBQUERY_READ_PREFERENCE_ATT, + READ_OPTIONS_INPUT, + READ_OPTIONS_ATT, + WHERE_ATT, + SKIP_ATT, + LIMIT_ATT, + COUNT_ATT, + SEARCH_INPUT, + TEXT_INPUT, + BOX_INPUT, + WITHIN_INPUT, + CENTER_SPHERE_INPUT, + GEO_WITHIN_INPUT, + GEO_INTERSECTS_INPUT, + equalTo, + notEqualTo, + lessThan, + lessThanOrEqualTo, + greaterThan, + greaterThanOrEqualTo, + inOp, + notIn, + exists, + matchesRegex, + options, + inQueryKey, + notInQueryKey, + ID_WHERE_INPUT, + STRING_WHERE_INPUT, + NUMBER_WHERE_INPUT, + BOOLEAN_WHERE_INPUT, + ARRAY_WHERE_INPUT, + KEY_VALUE_INPUT, + OBJECT_WHERE_INPUT, + DATE_WHERE_INPUT, + BYTES_WHERE_INPUT, + FILE_WHERE_INPUT, + GEO_POINT_WHERE_INPUT, + POLYGON_WHERE_INPUT, + ARRAY_RESULT, + ELEMENT, + ACL_INPUT, + USER_ACL_INPUT, + ROLE_ACL_INPUT, + PUBLIC_ACL_INPUT, + ACL, + USER_ACL, + ROLE_ACL, + PUBLIC_ACL, + load, + loadArrayResult, +}; diff --git a/src/GraphQL/loaders/defaultRelaySchema.js b/src/GraphQL/loaders/defaultRelaySchema.js new file mode 100644 index 0000000000..c3c2d9ccce --- /dev/null +++ b/src/GraphQL/loaders/defaultRelaySchema.js @@ -0,0 +1,54 @@ +import { nodeDefinitions, fromGlobalId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { extractKeysAndInclude } from './parseClassTypes'; + +const GLOBAL_ID_ATT = { + description: 'This is the global id.', + type: defaultGraphQLTypes.OBJECT_ID, +}; + +const load = parseGraphQLSchema => { + const { nodeInterface, nodeField } = nodeDefinitions( + async (globalId, context, queryInfo) => { + try { + const { type, id } = fromGlobalId(globalId); + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude(selectedFields); + + return { + className: type, + ...(await objectsQueries.getObject( + type, + id, + keys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses.find( + ({ className }) => type === className + ) + )), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + obj => { + return parseGraphQLSchema.parseClassTypes[obj.className] + .classGraphQLOutputType; + } + ); + + parseGraphQLSchema.addGraphQLType(nodeInterface, true); + parseGraphQLSchema.relayNodeInterface = nodeInterface; + parseGraphQLSchema.addGraphQLQuery('node', nodeField, true); +}; + +export { GLOBAL_ID_ATT, load }; diff --git a/src/GraphQL/loaders/filesMutations.js b/src/GraphQL/loaders/filesMutations.js new file mode 100644 index 0000000000..7e3cef78c3 --- /dev/null +++ b/src/GraphQL/loaders/filesMutations.js @@ -0,0 +1,97 @@ +import { GraphQLNonNull } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import { GraphQLUpload } from 'graphql-upload'; +import Parse from 'parse/node'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import logger from '../../logger'; + +const handleUpload = async (upload, config) => { + const { createReadStream, filename, mimetype } = await upload; + let data = null; + if (createReadStream) { + const stream = createReadStream(); + data = await new Promise((resolve, reject) => { + const chunks = []; + stream + .on('error', reject) + .on('data', chunk => chunks.push(chunk)) + .on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + if (!data || !data.length) { + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'); + } + + if (filename.length > 128) { + throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.'); + } + + if (!filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { + throw new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'Filename contains invalid characters.' + ); + } + + try { + return { + fileInfo: await config.filesController.createFile( + config, + filename, + data, + mimetype + ), + }; + } catch (e) { + logger.error('Error creating a file: ', e); + throw new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `Could not store file: ${filename}.` + ); + } +}; + +const load = parseGraphQLSchema => { + const createMutation = mutationWithClientMutationId({ + name: 'CreateFile', + description: + 'The createFile mutation can be used to create and upload a new file.', + inputFields: { + upload: { + description: 'This is the new file to be created and uploaded.', + type: new GraphQLNonNull(GraphQLUpload), + }, + }, + outputFields: { + fileInfo: { + description: 'This is the created file info.', + type: new GraphQLNonNull(defaultGraphQLTypes.FILE_INFO), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { upload } = args; + const { config } = context; + return handleUpload(upload, config); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + createMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(createMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'createFile', + createMutation, + true, + true + ); +}; + +export { load, handleUpload }; diff --git a/src/GraphQL/loaders/functionsMutations.js b/src/GraphQL/loaders/functionsMutations.js new file mode 100644 index 0000000000..418791583e --- /dev/null +++ b/src/GraphQL/loaders/functionsMutations.js @@ -0,0 +1,83 @@ +import { GraphQLNonNull, GraphQLEnumType } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import { FunctionsRouter } from '../../Routers/FunctionsRouter'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.functionNames.length > 0) { + const cloudCodeFunctionEnum = parseGraphQLSchema.addGraphQLType( + new GraphQLEnumType({ + name: 'CloudCodeFunction', + description: + 'The CloudCodeFunction enum type contains a list of all available cloud code functions.', + values: parseGraphQLSchema.functionNames.reduce( + (values, functionName) => ({ + ...values, + [functionName]: { value: functionName }, + }), + {} + ), + }), + true, + true + ); + + const callCloudCodeMutation = mutationWithClientMutationId({ + name: 'CallCloudCode', + description: + 'The callCloudCode mutation can be used to invoke a cloud code function.', + inputFields: { + functionName: { + description: 'This is the function to be called.', + type: new GraphQLNonNull(cloudCodeFunctionEnum), + }, + params: { + description: 'These are the params to be passed to the function.', + type: defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + result: { + description: + 'This is the result value of the cloud code function execution.', + type: defaultGraphQLTypes.ANY, + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { functionName, params } = args; + const { config, auth, info } = context; + + return { + result: (await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: params, + })).response.result, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + callCloudCodeMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(callCloudCodeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'callCloudCode', + callCloudCodeMutation, + true, + true + ); + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassMutations.js b/src/GraphQL/loaders/parseClassMutations.js new file mode 100644 index 0000000000..f41cccf5ed --- /dev/null +++ b/src/GraphQL/loaders/parseClassMutations.js @@ -0,0 +1,376 @@ +import { GraphQLNonNull } from 'graphql'; +import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import { + extractKeysAndInclude, + getParseClassMutationConfig, +} from '../parseGraphQLUtils'; +import * as objectsMutations from '../helpers/objectsMutations'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { transformTypes } from '../transformers/mutation'; + +const getOnlyRequiredFields = ( + updatedFields, + selectedFieldsString, + includedFieldsString, + nativeObjectFields +) => { + const includedFields = includedFieldsString + ? includedFieldsString.split(',') + : []; + const selectedFields = selectedFieldsString + ? selectedFieldsString.split(',') + : []; + const missingFields = selectedFields + .filter( + field => + !nativeObjectFields.includes(field) || includedFields.includes(field) + ) + .join(','); + if (!missingFields.length) { + return { needGet: false, keys: '' }; + } else { + return { needGet: true, keys: missingFields }; + } +}; + +const load = function( + parseGraphQLSchema, + parseClass, + parseClassConfig: ?ParseGraphQLClassConfig +) { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const getGraphQLQueryName = + graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const { + create: isCreateEnabled = true, + update: isUpdateEnabled = true, + destroy: isDestroyEnabled = true, + createAlias: createAlias = '', + updateAlias: updateAlias = '', + destroyAlias: destroyAlias = '', + } = getParseClassMutationConfig(parseClassConfig); + + const { + classGraphQLCreateType, + classGraphQLUpdateType, + classGraphQLOutputType, + } = parseGraphQLSchema.parseClassTypes[className]; + + if (isCreateEnabled) { + const createGraphQLMutationName = + createAlias || `create${graphQLClassName}`; + const createGraphQLMutation = mutationWithClientMutationId({ + name: `Create${graphQLClassName}`, + description: `The ${createGraphQLMutationName} mutation can be used to create a new object of the ${graphQLClassName} class.`, + inputFields: { + fields: { + description: + 'These are the fields that will be used to create the new object.', + type: classGraphQLCreateType || defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the created object.', + type: new GraphQLNonNull( + classGraphQLOutputType || defaultGraphQLTypes.OBJECT + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { fields } = args; + if (!fields) fields = {}; + const { config, auth, info } = context; + + const parseFields = await transformTypes('create', fields, { + className, + parseGraphQLSchema, + req: { config, auth, info }, + }); + + const createdObject = await objectsMutations.createObject( + className, + parseFields, + config, + auth, + info + ); + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields( + fields, + keys, + include, + ['id', 'objectId', 'createdAt', 'updatedAt'] + ); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseClass + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseClass + ); + } + return { + [getGraphQLQueryName]: { + ...createdObject, + updatedAt: createdObject.createdAt, + ...parseFields, + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType( + createGraphQLMutation.args.input.type.ofType + ) && + parseGraphQLSchema.addGraphQLType(createGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation( + createGraphQLMutationName, + createGraphQLMutation + ); + } + } + + if (isUpdateEnabled) { + const updateGraphQLMutationName = + updateAlias || `update${graphQLClassName}`; + const updateGraphQLMutation = mutationWithClientMutationId({ + name: `Update${graphQLClassName}`, + description: `The ${updateGraphQLMutationName} mutation can be used to update an object of the ${graphQLClassName} class.`, + inputFields: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + fields: { + description: + 'These are the fields that will be used to update the object.', + type: classGraphQLUpdateType || defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the updated object.', + type: new GraphQLNonNull( + classGraphQLOutputType || defaultGraphQLTypes.OBJECT + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { id, fields } = args; + if (!fields) fields = {}; + const { config, auth, info } = context; + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === className) { + id = globalIdObject.id; + } + + const parseFields = await transformTypes('update', fields, { + className, + parseGraphQLSchema, + req: { config, auth, info }, + }); + + const updatedObject = await objectsMutations.updateObject( + className, + id, + parseFields, + config, + auth, + info + ); + + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields( + fields, + keys, + include, + ['id', 'objectId', 'updatedAt'] + ); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseClass + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseClass + ); + } + return { + [getGraphQLQueryName]: { + objectId: id, + ...updatedObject, + ...parseFields, + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType( + updateGraphQLMutation.args.input.type.ofType + ) && + parseGraphQLSchema.addGraphQLType(updateGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation( + updateGraphQLMutationName, + updateGraphQLMutation + ); + } + } + + if (isDestroyEnabled) { + const deleteGraphQLMutationName = + destroyAlias || `delete${graphQLClassName}`; + const deleteGraphQLMutation = mutationWithClientMutationId({ + name: `Delete${graphQLClassName}`, + description: `The ${deleteGraphQLMutationName} mutation can be used to delete an object of the ${graphQLClassName} class.`, + inputFields: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the deleted object.', + type: new GraphQLNonNull( + classGraphQLOutputType || defaultGraphQLTypes.OBJECT + ), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { id } = args; + const { config, auth, info } = context; + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === className) { + id = globalIdObject.id; + } + + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + let optimizedObject = {}; + if ( + keys && + keys.split(',').filter(key => !['id', 'objectId'].includes(key)) + .length > 0 + ) { + optimizedObject = await objectsQueries.getObject( + className, + id, + keys, + include, + undefined, + undefined, + config, + auth, + info, + parseClass + ); + } + await objectsMutations.deleteObject( + className, + id, + config, + auth, + info + ); + return { + [getGraphQLQueryName]: { + objectId: id, + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType( + deleteGraphQLMutation.args.input.type.ofType + ) && + parseGraphQLSchema.addGraphQLType(deleteGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation( + deleteGraphQLMutationName, + deleteGraphQLMutation + ); + } + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js new file mode 100644 index 0000000000..cd1e71d922 --- /dev/null +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -0,0 +1,159 @@ +import { GraphQLNonNull } from 'graphql'; +import { fromGlobalId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import pluralize from 'pluralize'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { extractKeysAndInclude } from '../parseGraphQLUtils'; + +const getParseClassQueryConfig = function( + parseClassConfig: ?ParseGraphQLClassConfig +) { + return (parseClassConfig && parseClassConfig.query) || {}; +}; + +const getQuery = async (parseClass, _source, args, context, queryInfo) => { + let { id } = args; + const { options } = args; + const { readPreference, includeReadPreference } = options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === parseClass.className) { + id = globalIdObject.id; + } + + const { keys, include } = extractKeysAndInclude(selectedFields); + + return await objectsQueries.getObject( + parseClass.className, + id, + keys, + include, + readPreference, + includeReadPreference, + config, + auth, + info, + parseClass + ); +}; + +const load = function( + parseGraphQLSchema, + parseClass, + parseClassConfig: ?ParseGraphQLClassConfig +) { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const { + get: isGetEnabled = true, + find: isFindEnabled = true, + getAlias: getAlias = '', + findAlias: findAlias = '', + } = getParseClassQueryConfig(parseClassConfig); + + const { + classGraphQLOutputType, + classGraphQLFindArgs, + classGraphQLFindResultType, + } = parseGraphQLSchema.parseClassTypes[className]; + + if (isGetEnabled) { + const lowerCaseClassName = + graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const getGraphQLQueryName = getAlias || lowerCaseClassName; + + parseGraphQLSchema.addGraphQLQuery(getGraphQLQueryName, { + description: `The ${getGraphQLQueryName} query can be used to get an object of the ${graphQLClassName} class by its id.`, + args: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + options: defaultGraphQLTypes.READ_OPTIONS_ATT, + }, + type: new GraphQLNonNull( + classGraphQLOutputType || defaultGraphQLTypes.OBJECT + ), + async resolve(_source, args, context, queryInfo) { + try { + return await getQuery(parseClass, _source, args, context, queryInfo); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + } + + if (isFindEnabled) { + const lowerCaseClassName = + graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const findGraphQLQueryName = findAlias || pluralize(lowerCaseClassName); + + parseGraphQLSchema.addGraphQLQuery(findGraphQLQueryName, { + description: `The ${findGraphQLQueryName} query can be used to find objects of the ${graphQLClassName} class.`, + args: classGraphQLFindArgs, + type: new GraphQLNonNull( + classGraphQLFindResultType || defaultGraphQLTypes.OBJECT + ), + async resolve(_source, args, context, queryInfo) { + try { + const { + where, + order, + skip, + first, + after, + last, + before, + options, + } = args; + const { + readPreference, + includeReadPreference, + subqueryReadPreference, + } = options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('edges.node.')) + .map(field => field.replace('edges.node.', '')) + ); + const parseOrder = order && order.join(','); + + return await objectsQueries.findObjects( + className, + where, + parseOrder, + skip, + first, + after, + last, + before, + keys, + include, + false, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseGraphQLSchema.parseClasses + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js new file mode 100644 index 0000000000..b0272c4de6 --- /dev/null +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -0,0 +1,594 @@ +import { + GraphQLID, + GraphQLObjectType, + GraphQLString, + GraphQLList, + GraphQLInputObjectType, + GraphQLNonNull, + GraphQLBoolean, + GraphQLEnumType, +} from 'graphql'; +import { + globalIdField, + connectionArgs, + connectionDefinitions, +} from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { transformInputTypeToGraphQL } from '../transformers/inputType'; +import { transformOutputTypeToGraphQL } from '../transformers/outputType'; +import { transformConstraintTypeToGraphQL } from '../transformers/constraintType'; +import { + extractKeysAndInclude, + getParseClassMutationConfig, +} from '../parseGraphQLUtils'; + +const getParseClassTypeConfig = function( + parseClassConfig: ?ParseGraphQLClassConfig +) { + return (parseClassConfig && parseClassConfig.type) || {}; +}; + +const getInputFieldsAndConstraints = function( + parseClass, + parseClassConfig: ?ParseGraphQLClassConfig +) { + const classFields = Object.keys(parseClass.fields).concat('id'); + const { + inputFields: allowedInputFields, + outputFields: allowedOutputFields, + constraintFields: allowedConstraintFields, + sortFields: allowedSortFields, + } = getParseClassTypeConfig(parseClassConfig); + + let classOutputFields; + let classCreateFields; + let classUpdateFields; + let classConstraintFields; + let classSortFields; + + // All allowed customs fields + const classCustomFields = classFields.filter(field => { + return ( + !Object.keys(defaultGraphQLTypes.PARSE_OBJECT_FIELDS).includes(field) && + field !== 'id' + ); + }); + + if (allowedInputFields && allowedInputFields.create) { + classCreateFields = classCustomFields.filter(field => { + return allowedInputFields.create.includes(field); + }); + } else { + classCreateFields = classCustomFields; + } + if (allowedInputFields && allowedInputFields.update) { + classUpdateFields = classCustomFields.filter(field => { + return allowedInputFields.update.includes(field); + }); + } else { + classUpdateFields = classCustomFields; + } + + if (allowedOutputFields) { + classOutputFields = classCustomFields.filter(field => { + return allowedOutputFields.includes(field); + }); + } else { + classOutputFields = classCustomFields; + } + // Filters the "password" field from class _User + if (parseClass.className === '_User') { + classOutputFields = classOutputFields.filter( + outputField => outputField !== 'password' + ); + } + + if (allowedConstraintFields) { + classConstraintFields = classCustomFields.filter(field => { + return allowedConstraintFields.includes(field); + }); + } else { + classConstraintFields = classFields; + } + + if (allowedSortFields) { + classSortFields = allowedSortFields; + if (!classSortFields.length) { + // must have at least 1 order field + // otherwise the FindArgs Input Type will throw. + classSortFields.push({ + field: 'id', + asc: true, + desc: true, + }); + } + } else { + classSortFields = classFields.map(field => { + return { field, asc: true, desc: true }; + }); + } + + return { + classCreateFields, + classUpdateFields, + classConstraintFields, + classOutputFields, + classSortFields, + }; +}; + +const load = ( + parseGraphQLSchema, + parseClass, + parseClassConfig: ?ParseGraphQLClassConfig +) => { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const { + classCreateFields, + classUpdateFields, + classOutputFields, + classConstraintFields, + classSortFields, + } = getInputFieldsAndConstraints(parseClass, parseClassConfig); + + const { + create: isCreateEnabled = true, + update: isUpdateEnabled = true, + } = getParseClassMutationConfig(parseClassConfig); + + const classGraphQLCreateTypeName = `Create${graphQLClassName}FieldsInput`; + let classGraphQLCreateType = new GraphQLInputObjectType({ + name: classGraphQLCreateTypeName, + description: `The ${classGraphQLCreateTypeName} input type is used in operations that involve creation of objects in the ${graphQLClassName} class.`, + fields: () => + classCreateFields.reduce( + (fields, field) => { + const type = transformInputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: + (className === '_User' && + (field === 'username' || field === 'password')) || + parseClass.fields[field].required + ? new GraphQLNonNull(type) + : type, + }, + }; + } else { + return fields; + } + }, + { + ACL: { type: defaultGraphQLTypes.ACL_INPUT }, + } + ), + }); + classGraphQLCreateType = parseGraphQLSchema.addGraphQLType( + classGraphQLCreateType + ); + + const classGraphQLUpdateTypeName = `Update${graphQLClassName}FieldsInput`; + let classGraphQLUpdateType = new GraphQLInputObjectType({ + name: classGraphQLUpdateTypeName, + description: `The ${classGraphQLUpdateTypeName} input type is used in operations that involve creation of objects in the ${graphQLClassName} class.`, + fields: () => + classUpdateFields.reduce( + (fields, field) => { + const type = transformInputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type, + }, + }; + } else { + return fields; + } + }, + { + ACL: { type: defaultGraphQLTypes.ACL_INPUT }, + } + ), + }); + classGraphQLUpdateType = parseGraphQLSchema.addGraphQLType( + classGraphQLUpdateType + ); + + const classGraphQLPointerTypeName = `${graphQLClassName}PointerInput`; + let classGraphQLPointerType = new GraphQLInputObjectType({ + name: classGraphQLPointerTypeName, + description: `Allow to link OR add and link an object of the ${graphQLClassName} class.`, + fields: () => { + const fields = { + link: { + description: `Link an existing object from ${graphQLClassName} class. You can use either the global or the object id.`, + type: GraphQLID, + }, + }; + if (isCreateEnabled) { + fields['createAndLink'] = { + description: `Create and link an object from ${graphQLClassName} class.`, + type: classGraphQLCreateType, + }; + } + return fields; + }, + }); + classGraphQLPointerType = + parseGraphQLSchema.addGraphQLType(classGraphQLPointerType) || + defaultGraphQLTypes.OBJECT; + + const classGraphQLRelationTypeName = `${graphQLClassName}RelationInput`; + let classGraphQLRelationType = new GraphQLInputObjectType({ + name: classGraphQLRelationTypeName, + description: `Allow to add, remove, createAndAdd objects of the ${graphQLClassName} class into a relation field.`, + fields: () => { + const fields = { + add: { + description: `Add existing objects from the ${graphQLClassName} class into the relation. You can use either the global or the object ids.`, + type: new GraphQLList(defaultGraphQLTypes.OBJECT_ID), + }, + remove: { + description: `Remove existing objects from the ${graphQLClassName} class out of the relation. You can use either the global or the object ids.`, + type: new GraphQLList(defaultGraphQLTypes.OBJECT_ID), + }, + }; + if (isCreateEnabled) { + fields['createAndAdd'] = { + description: `Create and add objects of the ${graphQLClassName} class into the relation.`, + type: new GraphQLList(new GraphQLNonNull(classGraphQLCreateType)), + }; + } + return fields; + }, + }); + classGraphQLRelationType = + parseGraphQLSchema.addGraphQLType(classGraphQLRelationType) || + defaultGraphQLTypes.OBJECT; + + const classGraphQLConstraintsTypeName = `${graphQLClassName}WhereInput`; + let classGraphQLConstraintsType = new GraphQLInputObjectType({ + name: classGraphQLConstraintsTypeName, + description: `The ${classGraphQLConstraintsTypeName} input type is used in operations that involve filtering objects of ${graphQLClassName} class.`, + fields: () => ({ + ...classConstraintFields.reduce((fields, field) => { + if (['OR', 'AND', 'NOR'].includes(field)) { + parseGraphQLSchema.log.warn( + `Field ${field} could not be added to the auto schema ${classGraphQLConstraintsTypeName} because it collided with an existing one.` + ); + return fields; + } + const parseField = field === 'id' ? 'objectId' : field; + const type = transformConstraintTypeToGraphQL( + parseClass.fields[parseField].type, + parseClass.fields[parseField].targetClass, + parseGraphQLSchema.parseClassTypes, + field + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type, + }, + }; + } else { + return fields; + } + }, {}), + OR: { + description: 'This is the OR operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + AND: { + description: 'This is the AND operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + NOR: { + description: 'This is the NOR operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + }), + }); + classGraphQLConstraintsType = + parseGraphQLSchema.addGraphQLType(classGraphQLConstraintsType) || + defaultGraphQLTypes.OBJECT; + + const classGraphQLRelationConstraintsTypeName = `${graphQLClassName}RelationWhereInput`; + let classGraphQLRelationConstraintsType = new GraphQLInputObjectType({ + name: classGraphQLRelationConstraintsTypeName, + description: `The ${classGraphQLRelationConstraintsTypeName} input type is used in operations that involve filtering objects of ${graphQLClassName} class.`, + fields: () => ({ + have: { + description: + 'Run a relational/pointer query where at least one child object can match.', + type: classGraphQLConstraintsType, + }, + haveNot: { + description: + 'Run an inverted relational/pointer query where at least one child object can match.', + type: classGraphQLConstraintsType, + }, + exists: { + description: 'Check if the relation/pointer contains objects.', + type: GraphQLBoolean, + }, + }), + }); + classGraphQLRelationConstraintsType = + parseGraphQLSchema.addGraphQLType(classGraphQLRelationConstraintsType) || + defaultGraphQLTypes.OBJECT; + + const classGraphQLOrderTypeName = `${graphQLClassName}Order`; + let classGraphQLOrderType = new GraphQLEnumType({ + name: classGraphQLOrderTypeName, + description: `The ${classGraphQLOrderTypeName} input type is used when sorting objects of the ${graphQLClassName} class.`, + values: classSortFields.reduce((sortFields, fieldConfig) => { + const { field, asc, desc } = fieldConfig; + const updatedSortFields = { + ...sortFields, + }; + const value = field === 'id' ? 'objectId' : field; + if (asc) { + updatedSortFields[`${field}_ASC`] = { value }; + } + if (desc) { + updatedSortFields[`${field}_DESC`] = { value: `-${value}` }; + } + return updatedSortFields; + }, {}), + }); + classGraphQLOrderType = parseGraphQLSchema.addGraphQLType( + classGraphQLOrderType + ); + + const classGraphQLFindArgs = { + where: { + description: + 'These are the conditions that the objects need to match in order to be found.', + type: classGraphQLConstraintsType, + }, + order: { + description: 'The fields to be used when sorting the data fetched.', + type: classGraphQLOrderType + ? new GraphQLList(new GraphQLNonNull(classGraphQLOrderType)) + : GraphQLString, + }, + skip: defaultGraphQLTypes.SKIP_ATT, + ...connectionArgs, + options: defaultGraphQLTypes.READ_OPTIONS_ATT, + }; + const classGraphQLOutputTypeName = `${graphQLClassName}`; + const interfaces = [ + defaultGraphQLTypes.PARSE_OBJECT, + parseGraphQLSchema.relayNodeInterface, + ]; + const parseObjectFields = { + id: globalIdField(className, obj => obj.objectId), + ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, + }; + const outputFields = () => { + return classOutputFields.reduce((fields, field) => { + const type = transformOutputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (parseClass.fields[field].type === 'Relation') { + const targetParseClassTypes = + parseGraphQLSchema.parseClassTypes[ + parseClass.fields[field].targetClass + ]; + const args = targetParseClassTypes + ? targetParseClassTypes.classGraphQLFindArgs + : undefined; + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + args, + type: parseClass.fields[field].required + ? new GraphQLNonNull(type) + : type, + async resolve(source, args, context, queryInfo) { + try { + const { + where, + order, + skip, + first, + after, + last, + before, + options, + } = args; + const { + readPreference, + includeReadPreference, + subqueryReadPreference, + } = options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('edges.node.')) + .map(field => field.replace('edges.node.', '')) + ); + const parseOrder = order && order.join(','); + + return objectsQueries.findObjects( + source[field].className, + { + $relatedTo: { + object: { + __type: 'Pointer', + className: className, + objectId: source.objectId, + }, + key: field, + }, + ...(where || {}), + }, + parseOrder, + skip, + first, + after, + last, + before, + keys, + include, + false, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseGraphQLSchema.parseClasses + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + }; + } else if (parseClass.fields[field].type === 'Polygon') { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: parseClass.fields[field].required + ? new GraphQLNonNull(type) + : type, + async resolve(source) { + if (source[field] && source[field].coordinates) { + return source[field].coordinates.map(coordinate => ({ + latitude: coordinate[0], + longitude: coordinate[1], + })); + } else { + return null; + } + }, + }, + }; + } else if (parseClass.fields[field].type === 'Array') { + return { + ...fields, + [field]: { + description: `Use Inline Fragment on Array to get results: https://graphql.org/learn/queries/#inline-fragments`, + type: parseClass.fields[field].required + ? new GraphQLNonNull(type) + : type, + async resolve(source) { + if (!source[field]) return null; + return source[field].map(async elem => { + if ( + elem.className && + elem.objectId && + elem.__type === 'Object' + ) { + return elem; + } else { + return { value: elem }; + } + }); + }, + }, + }; + } else if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: parseClass.fields[field].required + ? new GraphQLNonNull(type) + : type, + }, + }; + } else { + return fields; + } + }, parseObjectFields); + }; + let classGraphQLOutputType = new GraphQLObjectType({ + name: classGraphQLOutputTypeName, + description: `The ${classGraphQLOutputTypeName} object type is used in operations that involve outputting objects of ${graphQLClassName} class.`, + interfaces, + fields: outputFields, + }); + classGraphQLOutputType = parseGraphQLSchema.addGraphQLType( + classGraphQLOutputType + ); + + const { connectionType, edgeType } = connectionDefinitions({ + name: graphQLClassName, + connectionFields: { + count: defaultGraphQLTypes.COUNT_ATT, + }, + nodeType: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }); + let classGraphQLFindResultType = undefined; + if ( + parseGraphQLSchema.addGraphQLType(edgeType) && + parseGraphQLSchema.addGraphQLType(connectionType, false, false, true) + ) { + classGraphQLFindResultType = connectionType; + } + + parseGraphQLSchema.parseClassTypes[className] = { + classGraphQLPointerType, + classGraphQLRelationType, + classGraphQLCreateType, + classGraphQLUpdateType, + classGraphQLConstraintsType, + classGraphQLRelationConstraintsType, + classGraphQLFindArgs, + classGraphQLOutputType, + classGraphQLFindResultType, + config: { + parseClassConfig, + isCreateEnabled, + isUpdateEnabled, + }, + }; + + if (className === '_User') { + const viewerType = new GraphQLObjectType({ + name: 'Viewer', + description: `The Viewer object type is used in operations that involve outputting the current user data.`, + fields: () => ({ + sessionToken: defaultGraphQLTypes.SESSION_TOKEN_ATT, + user: { + description: 'This is the current user.', + type: new GraphQLNonNull(classGraphQLOutputType), + }, + }), + }); + parseGraphQLSchema.addGraphQLType(viewerType, true, true); + parseGraphQLSchema.viewerType = viewerType; + } +}; + +export { extractKeysAndInclude, load }; diff --git a/src/GraphQL/loaders/schemaDirectives.js b/src/GraphQL/loaders/schemaDirectives.js new file mode 100644 index 0000000000..d8ff5ad552 --- /dev/null +++ b/src/GraphQL/loaders/schemaDirectives.js @@ -0,0 +1,53 @@ +import gql from 'graphql-tag'; +import { SchemaDirectiveVisitor } from 'graphql-tools'; +import { FunctionsRouter } from '../../Routers/FunctionsRouter'; + +export const definitions = gql` + directive @resolve(to: String) on FIELD_DEFINITION + directive @mock(with: Any!) on FIELD_DEFINITION +`; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.graphQLSchemaDirectivesDefinitions = definitions; + + class ResolveDirectiveVisitor extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + field.resolve = async (_source, args, context) => { + try { + const { config, auth, info } = context; + + let functionName = field.name; + if (this.args.to) { + functionName = this.args.to; + } + + return (await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: args, + })).response.result; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }; + } + } + + parseGraphQLSchema.graphQLSchemaDirectives.resolve = ResolveDirectiveVisitor; + + class MockDirectiveVisitor extends SchemaDirectiveVisitor { + visitFieldDefinition(field) { + field.resolve = () => { + return this.args.with; + }; + } + } + + parseGraphQLSchema.graphQLSchemaDirectives.mock = MockDirectiveVisitor; +}; + +export { load }; diff --git a/src/GraphQL/loaders/schemaMutations.js b/src/GraphQL/loaders/schemaMutations.js new file mode 100644 index 0000000000..12a828e9d7 --- /dev/null +++ b/src/GraphQL/loaders/schemaMutations.js @@ -0,0 +1,195 @@ +import Parse from 'parse/node'; +import { GraphQLNonNull } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import * as schemaTypes from './schemaTypes'; +import { + transformToParse, + transformToGraphQL, +} from '../transformers/schemaFields'; +import { enforceMasterKeyAccess } from '../parseGraphQLUtils'; +import { getClass } from './schemaQueries'; + +const load = parseGraphQLSchema => { + const createClassMutation = mutationWithClientMutationId({ + name: 'CreateClass', + description: + 'The createClass mutation can be used to create the schema for a new object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: schemaTypes.SCHEMA_FIELDS_INPUT, + }, + }, + outputFields: { + class: { + description: 'This is the created class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name, schemaFields } = args; + const { config, auth } = context; + + enforceMasterKeyAccess(auth); + + if (auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to create a schema." + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const parseClass = await schema.addClassIfNotExists( + name, + transformToParse(schemaFields) + ); + return { + class: { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + createClassMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(createClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'createClass', + createClassMutation, + true, + true + ); + + const updateClassMutation = mutationWithClientMutationId({ + name: 'UpdateClass', + description: + 'The updateClass mutation can be used to update the schema for an existing object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: schemaTypes.SCHEMA_FIELDS_INPUT, + }, + }, + outputFields: { + class: { + description: 'This is the updated class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name, schemaFields } = args; + const { config, auth } = context; + + enforceMasterKeyAccess(auth); + + if (auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update a schema." + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const existingParseClass = await getClass(name, schema); + const parseClass = await schema.updateClass( + name, + transformToParse(schemaFields, existingParseClass.fields), + undefined, + undefined, + config.database + ); + return { + class: { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + updateClassMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(updateClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'updateClass', + updateClassMutation, + true, + true + ); + + const deleteClassMutation = mutationWithClientMutationId({ + name: 'DeleteClass', + description: + 'The deleteClass mutation can be used to delete an existing object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + }, + outputFields: { + class: { + description: 'This is the deleted class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name } = args; + const { config, auth } = context; + + enforceMasterKeyAccess(auth); + + if (auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to delete a schema." + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const existingParseClass = await getClass(name, schema); + await config.database.deleteSchema(name); + return { + class: { + name: existingParseClass.className, + schemaFields: transformToGraphQL(existingParseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + deleteClassMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(deleteClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'deleteClass', + deleteClassMutation, + true, + true + ); +}; + +export { load }; diff --git a/src/GraphQL/loaders/schemaQueries.js b/src/GraphQL/loaders/schemaQueries.js new file mode 100644 index 0000000000..f5a166433a --- /dev/null +++ b/src/GraphQL/loaders/schemaQueries.js @@ -0,0 +1,86 @@ +import Parse from 'parse/node'; +import { GraphQLNonNull, GraphQLList } from 'graphql'; +import { transformToGraphQL } from '../transformers/schemaFields'; +import * as schemaTypes from './schemaTypes'; +import { enforceMasterKeyAccess } from '../parseGraphQLUtils'; + +const getClass = async (name, schema) => { + try { + return await schema.getOneSchema(name, true); + } catch (e) { + if (e === undefined) { + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${name} does not exist.` + ); + } else { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Database adapter error.' + ); + } + } +}; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLQuery( + 'class', + { + description: + 'The class query can be used to retrieve an existing object class.', + args: { + name: schemaTypes.CLASS_NAME_ATT, + }, + type: new GraphQLNonNull(schemaTypes.CLASS), + resolve: async (_source, args, context) => { + try { + const { name } = args; + const { config, auth } = context; + + enforceMasterKeyAccess(auth); + + const schema = await config.database.loadSchema({ clearCache: true }); + const parseClass = await getClass(name, schema); + return { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); + + parseGraphQLSchema.addGraphQLQuery( + 'classes', + { + description: + 'The classes query can be used to retrieve the existing object classes.', + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(schemaTypes.CLASS)) + ), + resolve: async (_source, _args, context) => { + try { + const { config, auth } = context; + + enforceMasterKeyAccess(auth); + + const schema = await config.database.loadSchema({ clearCache: true }); + return (await schema.getAllClasses(true)).map(parseClass => ({ + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + })); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); +}; + +export { getClass, load }; diff --git a/src/GraphQL/loaders/schemaTypes.js b/src/GraphQL/loaders/schemaTypes.js new file mode 100644 index 0000000000..6572969787 --- /dev/null +++ b/src/GraphQL/loaders/schemaTypes.js @@ -0,0 +1,448 @@ +import { + GraphQLNonNull, + GraphQLString, + GraphQLInputObjectType, + GraphQLList, + GraphQLObjectType, + GraphQLInterfaceType, +} from 'graphql'; + +const SCHEMA_FIELD_NAME_ATT = { + description: 'This is the field name.', + type: new GraphQLNonNull(GraphQLString), +}; + +const SCHEMA_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFieldInput', + description: + 'The SchemaFieldInput is used to specify a field of an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FIELD = new GraphQLInterfaceType({ + name: 'SchemaField', + description: + 'The SchemaField interface type is used as a base type for the different supported fields of an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, + resolveType: value => + ({ + String: SCHEMA_STRING_FIELD, + Number: SCHEMA_NUMBER_FIELD, + Boolean: SCHEMA_BOOLEAN_FIELD, + Array: SCHEMA_ARRAY_FIELD, + Object: SCHEMA_OBJECT_FIELD, + Date: SCHEMA_DATE_FIELD, + File: SCHEMA_FILE_FIELD, + GeoPoint: SCHEMA_GEO_POINT_FIELD, + Polygon: SCHEMA_POLYGON_FIELD, + Bytes: SCHEMA_BYTES_FIELD, + Pointer: SCHEMA_POINTER_FIELD, + Relation: SCHEMA_RELATION_FIELD, + ACL: SCHEMA_ACL_FIELD, + }[value.type]), +}); + +const SCHEMA_STRING_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaStringFieldInput', + description: + 'The SchemaStringFieldInput is used to specify a field of type string for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_STRING_FIELD = new GraphQLObjectType({ + name: 'SchemaStringField', + description: + 'The SchemaStringField is used to return information of a String field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_NUMBER_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaNumberFieldInput', + description: + 'The SchemaNumberFieldInput is used to specify a field of type number for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_NUMBER_FIELD = new GraphQLObjectType({ + name: 'SchemaNumberField', + description: + 'The SchemaNumberField is used to return information of a Number field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BOOLEAN_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaBooleanFieldInput', + description: + 'The SchemaBooleanFieldInput is used to specify a field of type boolean for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BOOLEAN_FIELD = new GraphQLObjectType({ + name: 'SchemaBooleanField', + description: + 'The SchemaBooleanField is used to return information of a Boolean field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_ARRAY_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaArrayFieldInput', + description: + 'The SchemaArrayFieldInput is used to specify a field of type array for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_ARRAY_FIELD = new GraphQLObjectType({ + name: 'SchemaArrayField', + description: + 'The SchemaArrayField is used to return information of an Array field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_OBJECT_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaObjectFieldInput', + description: + 'The SchemaObjectFieldInput is used to specify a field of type object for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_OBJECT_FIELD = new GraphQLObjectType({ + name: 'SchemaObjectField', + description: + 'The SchemaObjectField is used to return information of an Object field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_DATE_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaDateFieldInput', + description: + 'The SchemaDateFieldInput is used to specify a field of type date for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_DATE_FIELD = new GraphQLObjectType({ + name: 'SchemaDateField', + description: + 'The SchemaDateField is used to return information of a Date field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FILE_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFileFieldInput', + description: + 'The SchemaFileFieldInput is used to specify a field of type file for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FILE_FIELD = new GraphQLObjectType({ + name: 'SchemaFileField', + description: + 'The SchemaFileField is used to return information of a File field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_GEO_POINT_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaGeoPointFieldInput', + description: + 'The SchemaGeoPointFieldInput is used to specify a field of type geo point for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_GEO_POINT_FIELD = new GraphQLObjectType({ + name: 'SchemaGeoPointField', + description: + 'The SchemaGeoPointField is used to return information of a Geo Point field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_POLYGON_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaPolygonFieldInput', + description: + 'The SchemaPolygonFieldInput is used to specify a field of type polygon for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_POLYGON_FIELD = new GraphQLObjectType({ + name: 'SchemaPolygonField', + description: + 'The SchemaPolygonField is used to return information of a Polygon field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BYTES_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaBytesFieldInput', + description: + 'The SchemaBytesFieldInput is used to specify a field of type bytes for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BYTES_FIELD = new GraphQLObjectType({ + name: 'SchemaBytesField', + description: + 'The SchemaBytesField is used to return information of a Bytes field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const TARGET_CLASS_ATT = { + description: 'This is the name of the target class for the field.', + type: new GraphQLNonNull(GraphQLString), +}; + +const SCHEMA_POINTER_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'PointerFieldInput', + description: + 'The PointerFieldInput is used to specify a field of type pointer for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_POINTER_FIELD = new GraphQLObjectType({ + name: 'SchemaPointerField', + description: + 'The SchemaPointerField is used to return information of a Pointer field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_RELATION_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'RelationFieldInput', + description: + 'The RelationFieldInput is used to specify a field of type relation for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_RELATION_FIELD = new GraphQLObjectType({ + name: 'SchemaRelationField', + description: + 'The SchemaRelationField is used to return information of a Relation field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_ACL_FIELD = new GraphQLObjectType({ + name: 'SchemaACLField', + description: + 'The SchemaACLField is used to return information of an ACL field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FIELDS_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFieldsInput', + description: `The CreateClassSchemaInput type is used to specify the schema for a new object class to be created.`, + fields: { + addStrings: { + description: + 'These are the String fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_STRING_FIELD_INPUT)), + }, + addNumbers: { + description: + 'These are the Number fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_NUMBER_FIELD_INPUT)), + }, + addBooleans: { + description: + 'These are the Boolean fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_BOOLEAN_FIELD_INPUT)), + }, + addArrays: { + description: + 'These are the Array fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_ARRAY_FIELD_INPUT)), + }, + addObjects: { + description: + 'These are the Object fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_OBJECT_FIELD_INPUT)), + }, + addDates: { + description: 'These are the Date fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_DATE_FIELD_INPUT)), + }, + addFiles: { + description: 'These are the File fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_FILE_FIELD_INPUT)), + }, + addGeoPoint: { + description: + 'This is the Geo Point field to be added to the class schema. Currently it is supported only one GeoPoint field per Class.', + type: SCHEMA_GEO_POINT_FIELD_INPUT, + }, + addPolygons: { + description: + 'These are the Polygon fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_POLYGON_FIELD_INPUT)), + }, + addBytes: { + description: + 'These are the Bytes fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_BYTES_FIELD_INPUT)), + }, + addPointers: { + description: + 'These are the Pointer fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_POINTER_FIELD_INPUT)), + }, + addRelations: { + description: + 'These are the Relation fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_RELATION_FIELD_INPUT)), + }, + remove: { + description: 'These are the fields to be removed from the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_FIELD_INPUT)), + }, + }, +}); + +const CLASS_NAME_ATT = { + description: 'This is the name of the object class.', + type: new GraphQLNonNull(GraphQLString), +}; + +const CLASS = new GraphQLObjectType({ + name: 'Class', + description: `The Class type is used to return the information about an object class.`, + fields: { + name: CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(SCHEMA_FIELD)) + ), + }, + }, +}); + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLType(SCHEMA_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_STRING_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_STRING_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_NUMBER_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_NUMBER_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BOOLEAN_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BOOLEAN_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ARRAY_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ARRAY_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_OBJECT_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_OBJECT_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_DATE_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_DATE_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FILE_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FILE_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_GEO_POINT_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_GEO_POINT_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POLYGON_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POLYGON_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BYTES_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BYTES_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POINTER_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POINTER_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_RELATION_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_RELATION_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ACL_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FIELDS_INPUT, true); + parseGraphQLSchema.addGraphQLType(CLASS, true); +}; + +export { + SCHEMA_FIELD_NAME_ATT, + SCHEMA_FIELD_INPUT, + SCHEMA_STRING_FIELD_INPUT, + SCHEMA_STRING_FIELD, + SCHEMA_NUMBER_FIELD_INPUT, + SCHEMA_NUMBER_FIELD, + SCHEMA_BOOLEAN_FIELD_INPUT, + SCHEMA_BOOLEAN_FIELD, + SCHEMA_ARRAY_FIELD_INPUT, + SCHEMA_ARRAY_FIELD, + SCHEMA_OBJECT_FIELD_INPUT, + SCHEMA_OBJECT_FIELD, + SCHEMA_DATE_FIELD_INPUT, + SCHEMA_DATE_FIELD, + SCHEMA_FILE_FIELD_INPUT, + SCHEMA_FILE_FIELD, + SCHEMA_GEO_POINT_FIELD_INPUT, + SCHEMA_GEO_POINT_FIELD, + SCHEMA_POLYGON_FIELD_INPUT, + SCHEMA_POLYGON_FIELD, + SCHEMA_BYTES_FIELD_INPUT, + SCHEMA_BYTES_FIELD, + TARGET_CLASS_ATT, + SCHEMA_POINTER_FIELD_INPUT, + SCHEMA_POINTER_FIELD, + SCHEMA_RELATION_FIELD_INPUT, + SCHEMA_RELATION_FIELD, + SCHEMA_ACL_FIELD, + SCHEMA_FIELDS_INPUT, + CLASS_NAME_ATT, + CLASS, + load, +}; diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js new file mode 100644 index 0000000000..701929677e --- /dev/null +++ b/src/GraphQL/loaders/usersMutations.js @@ -0,0 +1,367 @@ +import { + GraphQLNonNull, + GraphQLString, + GraphQLBoolean, + GraphQLInputObjectType, +} from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import UsersRouter from '../../Routers/UsersRouter'; +import * as objectsMutations from '../helpers/objectsMutations'; +import { OBJECT } from './defaultGraphQLTypes'; +import { getUserFromSessionToken } from './usersQueries'; + +const usersRouter = new UsersRouter(); + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.isUsersClassDisabled) { + return; + } + + const signUpMutation = mutationWithClientMutationId({ + name: 'SignUp', + description: + 'The signUp mutation can be used to create and sign up a new user.', + inputFields: { + fields: { + descriptions: + 'These are the fields of the new user to be created and signed up.', + type: + parseGraphQLSchema.parseClassTypes['_User'].classGraphQLCreateType, + }, + }, + outputFields: { + viewer: { + description: + 'This is the new user that was created, signed up and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { fields } = args; + const { config, auth, info } = context; + + const { sessionToken } = await objectsMutations.createObject( + '_User', + fields, + config, + auth, + info + ); + + info.sessionToken = sessionToken; + + return { + viewer: await getUserFromSessionToken( + config, + info, + mutationInfo, + 'viewer.user.', + true + ), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + signUpMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(signUpMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('signUp', signUpMutation, true, true); + const logInWithMutation = mutationWithClientMutationId({ + name: 'LogInWith', + description: + 'The logInWith mutation can be used to signup, login user with 3rd party authentication system. This mutation create a user if the authData do not correspond to an existing one.', + inputFields: { + authData: { + descriptions: 'This is the auth data of your custom auth provider', + type: new GraphQLNonNull(OBJECT), + }, + fields: { + descriptions: + 'These are the fields of the user to be created/updated and logged in.', + type: new GraphQLInputObjectType({ + name: 'UserLoginWithInput', + fields: () => { + const classGraphQLCreateFields = parseGraphQLSchema.parseClassTypes[ + '_User' + ].classGraphQLCreateType.getFields(); + return Object.keys(classGraphQLCreateFields).reduce( + (fields, fieldName) => { + if ( + fieldName !== 'password' && + fieldName !== 'username' && + fieldName !== 'authData' + ) { + fields[fieldName] = classGraphQLCreateFields[fieldName]; + } + return fields; + }, + {} + ); + }, + }), + }, + }, + outputFields: { + viewer: { + description: + 'This is the new user that was created, signed up and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { fields, authData } = args; + const { config, auth, info } = context; + + const { sessionToken } = await objectsMutations.createObject( + '_User', + { ...fields, authData }, + config, + auth, + info + ); + + info.sessionToken = sessionToken; + + return { + viewer: await getUserFromSessionToken( + config, + info, + mutationInfo, + 'viewer.user.', + true + ), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + logInWithMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(logInWithMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'logInWith', + logInWithMutation, + true, + true + ); + + const logInMutation = mutationWithClientMutationId({ + name: 'LogIn', + description: 'The logIn mutation can be used to log in an existing user.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: new GraphQLNonNull(GraphQLString), + }, + password: { + description: 'This is the password used to log in the user.', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + viewer: { + description: + 'This is the existing user that was logged in and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { username, password } = args; + const { config, auth, info } = context; + + const { sessionToken } = ( + await usersRouter.handleLogIn({ + body: { + username, + password, + }, + query: {}, + config, + auth, + info, + }) + ).response; + + info.sessionToken = sessionToken; + + return { + viewer: await getUserFromSessionToken( + config, + info, + mutationInfo, + 'viewer.user.', + true + ), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + logInMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(logInMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('logIn', logInMutation, true, true); + + const logOutMutation = mutationWithClientMutationId({ + name: 'LogOut', + description: 'The logOut mutation can be used to log out an existing user.', + outputFields: { + viewer: { + description: + 'This is the existing user that was logged out and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (_args, context, mutationInfo) => { + try { + const { config, auth, info } = context; + + const viewer = await getUserFromSessionToken( + config, + info, + mutationInfo, + 'viewer.user.', + true + ); + + await usersRouter.handleLogOut({ + config, + auth, + info, + }); + + return { viewer }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + logOutMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(logOutMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('logOut', logOutMutation, true, true); + + const resetPasswordMutation = mutationWithClientMutationId({ + name: 'ResetPassword', + description: + 'The resetPassword mutation can be used to reset the password of an existing user.', + inputFields: { + email: { + descriptions: 'Email of the user that should receive the reset email', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async ({ email }, context) => { + const { config, auth, info } = context; + + await usersRouter.handleResetRequest({ + body: { + email, + }, + config, + auth, + info, + }); + + return { ok: true }; + }, + }); + + parseGraphQLSchema.addGraphQLType( + resetPasswordMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(resetPasswordMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'resetPassword', + resetPasswordMutation, + true, + true + ); + + const sendVerificationEmailMutation = mutationWithClientMutationId({ + name: 'SendVerificationEmail', + description: + 'The sendVerificationEmail mutation can be used to send the verification email again.', + inputFields: { + email: { + descriptions: + 'Email of the user that should receive the verification email', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async ({ email }, context) => { + try { + const { config, auth, info } = context; + + await usersRouter.handleVerificationEmailRequest({ + body: { + email, + }, + config, + auth, + info, + }); + + return { ok: true }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + sendVerificationEmailMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType( + sendVerificationEmailMutation.type, + true, + true + ); + parseGraphQLSchema.addGraphQLMutation( + 'sendVerificationEmail', + sendVerificationEmailMutation, + true, + true + ); +}; + +export { load }; diff --git a/src/GraphQL/loaders/usersQueries.js b/src/GraphQL/loaders/usersQueries.js new file mode 100644 index 0000000000..7c00d19315 --- /dev/null +++ b/src/GraphQL/loaders/usersQueries.js @@ -0,0 +1,109 @@ +import { GraphQLNonNull } from 'graphql'; +import getFieldNames from 'graphql-list-fields'; +import Parse from 'parse/node'; +import rest from '../../rest'; +import Auth from '../../Auth'; +import { extractKeysAndInclude } from './parseClassTypes'; + +const getUserFromSessionToken = async ( + config, + info, + queryInfo, + keysPrefix, + validatedToken +) => { + if (!info || !info.sessionToken) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); + } + const sessionToken = info.sessionToken; + const selectedFields = getFieldNames(queryInfo) + .filter(field => field.startsWith(keysPrefix)) + .map(field => field.replace(keysPrefix, '')); + + const keysAndInclude = extractKeysAndInclude(selectedFields); + const { keys } = keysAndInclude; + let { include } = keysAndInclude; + + if (validatedToken && !keys && !include) { + return { + sessionToken, + }; + } else if (keys && !include) { + include = 'user'; + } + + const options = {}; + if (keys) { + options.keys = keys + .split(',') + .map(key => `user.${key}`) + .join(','); + } + if (include) { + options.include = include + .split(',') + .map(included => `user.${included}`) + .join(','); + } + + const response = await rest.find( + config, + Auth.master(config), + '_Session', + { sessionToken }, + options, + info.clientVersion + ); + if ( + !response.results || + response.results.length == 0 || + !response.results[0].user + ) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); + } else { + const user = response.results[0].user; + return { + sessionToken, + user, + }; + } +}; + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.isUsersClassDisabled) { + return; + } + + parseGraphQLSchema.addGraphQLQuery( + 'viewer', + { + description: + 'The viewer query can be used to return the current user data.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + async resolve(_source, _args, context, queryInfo) { + try { + const { config, info } = context; + return await getUserFromSessionToken( + config, + info, + queryInfo, + 'user.', + false + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); +}; + +export { load, getUserFromSessionToken }; diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js new file mode 100644 index 0000000000..825f37ddd8 --- /dev/null +++ b/src/GraphQL/parseGraphQLUtils.js @@ -0,0 +1,62 @@ +import Parse from 'parse/node'; +import { ApolloError } from 'apollo-server-core'; + +export function enforceMasterKeyAccess(auth) { + if (!auth.isMaster) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'unauthorized: master key is required' + ); + } +} + +export function toGraphQLError(error) { + let code, message; + if (error instanceof Parse.Error) { + code = error.code; + message = error.message; + } else { + code = Parse.Error.INTERNAL_SERVER_ERROR; + message = 'Internal server error'; + } + return new ApolloError(message, code); +} + +export const extractKeysAndInclude = selectedFields => { + selectedFields = selectedFields.filter( + field => !field.includes('__typename') + ); + + // Handles "id" field for both current and included objects + selectedFields = selectedFields.map(field => { + if (field === 'id') return 'objectId'; + return field.endsWith('.id') + ? `${field.substring(0, field.lastIndexOf('.id'))}.objectId` + : field; + }); + let keys = undefined; + let include = undefined; + if (selectedFields.length > 0) { + keys = selectedFields.join(','); + include = selectedFields + .reduce((fields, field) => { + fields = fields.slice(); + let pointIndex = field.lastIndexOf('.'); + while (pointIndex > 0) { + const lastField = field.slice(pointIndex + 1); + field = field.slice(0, pointIndex); + if (!fields.includes(field) && lastField !== 'objectId') { + fields.push(field); + } + pointIndex = field.lastIndexOf('.'); + } + return fields; + }, []) + .join(','); + } + return { keys, include }; +}; + +export const getParseClassMutationConfig = function(parseClassConfig) { + return (parseClassConfig && parseClassConfig.mutation) || {}; +}; diff --git a/src/GraphQL/transformers/className.js b/src/GraphQL/transformers/className.js new file mode 100644 index 0000000000..da1f3cbb68 --- /dev/null +++ b/src/GraphQL/transformers/className.js @@ -0,0 +1,8 @@ +const transformClassNameToGraphQL = className => { + if (className[0] === '_') { + className = className.slice(1); + } + return className[0].toUpperCase() + className.slice(1); +}; + +export { transformClassNameToGraphQL }; diff --git a/src/GraphQL/transformers/constraintType.js b/src/GraphQL/transformers/constraintType.js new file mode 100644 index 0000000000..61a6413a8f --- /dev/null +++ b/src/GraphQL/transformers/constraintType.js @@ -0,0 +1,59 @@ +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; + +const transformConstraintTypeToGraphQL = ( + parseType, + targetClass, + parseClassTypes, + fieldName +) => { + if (fieldName === 'id' || fieldName === 'objectId') { + return defaultGraphQLTypes.ID_WHERE_INPUT; + } + + switch (parseType) { + case 'String': + return defaultGraphQLTypes.STRING_WHERE_INPUT; + case 'Number': + return defaultGraphQLTypes.NUMBER_WHERE_INPUT; + case 'Boolean': + return defaultGraphQLTypes.BOOLEAN_WHERE_INPUT; + case 'Array': + return defaultGraphQLTypes.ARRAY_WHERE_INPUT; + case 'Object': + return defaultGraphQLTypes.OBJECT_WHERE_INPUT; + case 'Date': + return defaultGraphQLTypes.DATE_WHERE_INPUT; + case 'Pointer': + if ( + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationConstraintsType + ) { + return parseClassTypes[targetClass].classGraphQLRelationConstraintsType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'File': + return defaultGraphQLTypes.FILE_WHERE_INPUT; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT_WHERE_INPUT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON_WHERE_INPUT; + case 'Bytes': + return defaultGraphQLTypes.BYTES_WHERE_INPUT; + case 'ACL': + return defaultGraphQLTypes.OBJECT_WHERE_INPUT; + case 'Relation': + if ( + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationConstraintsType + ) { + return parseClassTypes[targetClass].classGraphQLRelationConstraintsType; + } else { + return defaultGraphQLTypes.OBJECT; + } + default: + return undefined; + } +}; + +export { transformConstraintTypeToGraphQL }; diff --git a/src/GraphQL/transformers/inputType.js b/src/GraphQL/transformers/inputType.js new file mode 100644 index 0000000000..29c91a65ea --- /dev/null +++ b/src/GraphQL/transformers/inputType.js @@ -0,0 +1,62 @@ +import { + GraphQLString, + GraphQLFloat, + GraphQLBoolean, + GraphQLList, +} from 'graphql'; +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; + +const transformInputTypeToGraphQL = ( + parseType, + targetClass, + parseClassTypes +) => { + switch (parseType) { + case 'String': + return GraphQLString; + case 'Number': + return GraphQLFloat; + case 'Boolean': + return GraphQLBoolean; + case 'Array': + return new GraphQLList(defaultGraphQLTypes.ANY); + case 'Object': + return defaultGraphQLTypes.OBJECT; + case 'Date': + return defaultGraphQLTypes.DATE; + case 'Pointer': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLPointerType + ) { + return parseClassTypes[targetClass].classGraphQLPointerType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'Relation': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationType + ) { + return parseClassTypes[targetClass].classGraphQLRelationType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'File': + return defaultGraphQLTypes.FILE_INPUT; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT_INPUT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON_INPUT; + case 'Bytes': + return defaultGraphQLTypes.BYTES; + case 'ACL': + return defaultGraphQLTypes.ACL_INPUT; + default: + return undefined; + } +}; + +export { transformInputTypeToGraphQL }; diff --git a/src/GraphQL/transformers/mutation.js b/src/GraphQL/transformers/mutation.js new file mode 100644 index 0000000000..d2f3c8b7b5 --- /dev/null +++ b/src/GraphQL/transformers/mutation.js @@ -0,0 +1,247 @@ +import Parse from 'parse/node'; +import { fromGlobalId } from 'graphql-relay'; +import { handleUpload } from '../loaders/filesMutations'; +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; +import * as objectsMutations from '../helpers/objectsMutations'; + +const transformTypes = async ( + inputType: 'create' | 'update', + fields, + { className, parseGraphQLSchema, req } +) => { + const { + classGraphQLCreateType, + classGraphQLUpdateType, + config: { isCreateEnabled, isUpdateEnabled }, + } = parseGraphQLSchema.parseClassTypes[className]; + const parseClass = parseGraphQLSchema.parseClasses.find( + clazz => clazz.className === className + ); + if (fields) { + const classGraphQLCreateTypeFields = + isCreateEnabled && classGraphQLCreateType + ? classGraphQLCreateType.getFields() + : null; + const classGraphQLUpdateTypeFields = + isUpdateEnabled && classGraphQLUpdateType + ? classGraphQLUpdateType.getFields() + : null; + const promises = Object.keys(fields).map(async field => { + let inputTypeField; + if (inputType === 'create' && classGraphQLCreateTypeFields) { + inputTypeField = classGraphQLCreateTypeFields[field]; + } else if (classGraphQLUpdateTypeFields) { + inputTypeField = classGraphQLUpdateTypeFields[field]; + } + if (inputTypeField) { + switch (true) { + case inputTypeField.type === defaultGraphQLTypes.GEO_POINT_INPUT: + fields[field] = transformers.geoPoint(fields[field]); + break; + case inputTypeField.type === defaultGraphQLTypes.POLYGON_INPUT: + fields[field] = transformers.polygon(fields[field]); + break; + case inputTypeField.type === defaultGraphQLTypes.FILE_INPUT: + fields[field] = await transformers.file(fields[field], req); + break; + case parseClass.fields[field].type === 'Relation': + fields[field] = await transformers.relation( + parseClass.fields[field].targetClass, + field, + fields[field], + parseGraphQLSchema, + req + ); + break; + case parseClass.fields[field].type === 'Pointer': + fields[field] = await transformers.pointer( + parseClass.fields[field].targetClass, + field, + fields[field], + parseGraphQLSchema, + req + ); + break; + } + } + }); + await Promise.all(promises); + if (fields.ACL) fields.ACL = transformers.ACL(fields.ACL); + } + return fields; +}; + +const transformers = { + file: async ({ file, upload }, { config }) => { + if (upload) { + const { fileInfo } = await handleUpload(upload, config); + return { name: fileInfo.name, __type: 'File' }; + } else if (file && file.name) { + return { name: file.name, __type: 'File' }; + } + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'); + }, + polygon: value => ({ + __type: 'Polygon', + coordinates: value.map(geoPoint => [geoPoint.latitude, geoPoint.longitude]), + }), + geoPoint: value => ({ + ...value, + __type: 'GeoPoint', + }), + ACL: value => { + const parseACL = {}; + if (value.public) { + parseACL['*'] = { + read: value.public.read, + write: value.public.write, + }; + } + if (value.users) { + value.users.forEach(rule => { + parseACL[rule.userId] = { + read: rule.read, + write: rule.write, + }; + }); + } + if (value.roles) { + value.roles.forEach(rule => { + parseACL[`role:${rule.roleName}`] = { + read: rule.read, + write: rule.write, + }; + }); + } + return parseACL; + }, + relation: async ( + targetClass, + field, + value, + parseGraphQLSchema, + { config, auth, info } + ) => { + if (Object.keys(value).length === 0) + throw new Parse.Error( + Parse.Error.INVALID_POINTER, + `You need to provide at least one operation on the relation mutation of field ${field}` + ); + + const op = { + __op: 'Batch', + ops: [], + }; + let nestedObjectsToAdd = []; + + if (value.createAndAdd) { + nestedObjectsToAdd = ( + await Promise.all( + value.createAndAdd.map(async input => { + const parseFields = await transformTypes('create', input, { + className: targetClass, + parseGraphQLSchema, + req: { config, auth, info }, + }); + return objectsMutations.createObject( + targetClass, + parseFields, + config, + auth, + info + ); + }) + ) + ).map(object => ({ + __type: 'Pointer', + className: targetClass, + objectId: object.objectId, + })); + } + + if (value.add || nestedObjectsToAdd.length > 0) { + if (!value.add) value.add = []; + value.add = value.add.map(input => { + const globalIdObject = fromGlobalId(input); + if (globalIdObject.type === targetClass) { + input = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId: input, + }; + }); + op.ops.push({ + __op: 'AddRelation', + objects: [...value.add, ...nestedObjectsToAdd], + }); + } + + if (value.remove) { + op.ops.push({ + __op: 'RemoveRelation', + objects: value.remove.map(input => { + const globalIdObject = fromGlobalId(input); + if (globalIdObject.type === targetClass) { + input = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId: input, + }; + }), + }); + } + return op; + }, + pointer: async ( + targetClass, + field, + value, + parseGraphQLSchema, + { config, auth, info } + ) => { + if (Object.keys(value).length > 1 || Object.keys(value).length === 0) + throw new Parse.Error( + Parse.Error.INVALID_POINTER, + `You need to provide link OR createLink on the pointer mutation of field ${field}` + ); + + let nestedObjectToAdd; + if (value.createAndLink) { + const parseFields = await transformTypes('create', value.createAndLink, { + className: targetClass, + parseGraphQLSchema, + req: { config, auth, info }, + }); + nestedObjectToAdd = await objectsMutations.createObject( + targetClass, + parseFields, + config, + auth, + info + ); + return { + __type: 'Pointer', + className: targetClass, + objectId: nestedObjectToAdd.objectId, + }; + } + if (value.link) { + let objectId = value.link; + const globalIdObject = fromGlobalId(objectId); + if (globalIdObject.type === targetClass) { + objectId = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId, + }; + } + }, +}; + +export { transformTypes }; diff --git a/src/GraphQL/transformers/outputType.js b/src/GraphQL/transformers/outputType.js new file mode 100644 index 0000000000..e56f233832 --- /dev/null +++ b/src/GraphQL/transformers/outputType.js @@ -0,0 +1,65 @@ +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; +import { + GraphQLString, + GraphQLFloat, + GraphQLBoolean, + GraphQLList, + GraphQLNonNull, +} from 'graphql'; + +const transformOutputTypeToGraphQL = ( + parseType, + targetClass, + parseClassTypes +) => { + switch (parseType) { + case 'String': + return GraphQLString; + case 'Number': + return GraphQLFloat; + case 'Boolean': + return GraphQLBoolean; + case 'Array': + return new GraphQLList(defaultGraphQLTypes.ARRAY_RESULT); + case 'Object': + return defaultGraphQLTypes.OBJECT; + case 'Date': + return defaultGraphQLTypes.DATE; + case 'Pointer': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLOutputType + ) { + return parseClassTypes[targetClass].classGraphQLOutputType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'Relation': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLFindResultType + ) { + return new GraphQLNonNull( + parseClassTypes[targetClass].classGraphQLFindResultType + ); + } else { + return new GraphQLNonNull(defaultGraphQLTypes.OBJECT); + } + case 'File': + return defaultGraphQLTypes.FILE_INFO; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON; + case 'Bytes': + return defaultGraphQLTypes.BYTES; + case 'ACL': + return new GraphQLNonNull(defaultGraphQLTypes.ACL); + default: + return undefined; + } +}; + +export { transformOutputTypeToGraphQL }; diff --git a/src/GraphQL/transformers/query.js b/src/GraphQL/transformers/query.js new file mode 100644 index 0000000000..91e5b3b3b7 --- /dev/null +++ b/src/GraphQL/transformers/query.js @@ -0,0 +1,284 @@ +import { fromGlobalId } from 'graphql-relay'; + +const parseQueryMap = { + OR: '$or', + AND: '$and', + NOR: '$nor', +}; + +const parseConstraintMap = { + equalTo: '$eq', + notEqualTo: '$ne', + lessThan: '$lt', + lessThanOrEqualTo: '$lte', + greaterThan: '$gt', + greaterThanOrEqualTo: '$gte', + in: '$in', + notIn: '$nin', + exists: '$exists', + inQueryKey: '$select', + notInQueryKey: '$dontSelect', + inQuery: '$inQuery', + notInQuery: '$notInQuery', + containedBy: '$containedBy', + contains: '$all', + matchesRegex: '$regex', + options: '$options', + text: '$text', + search: '$search', + term: '$term', + language: '$language', + caseSensitive: '$caseSensitive', + diacriticSensitive: '$diacriticSensitive', + nearSphere: '$nearSphere', + maxDistance: '$maxDistance', + maxDistanceInRadians: '$maxDistanceInRadians', + maxDistanceInMiles: '$maxDistanceInMiles', + maxDistanceInKilometers: '$maxDistanceInKilometers', + within: '$within', + box: '$box', + geoWithin: '$geoWithin', + polygon: '$polygon', + centerSphere: '$centerSphere', + geoIntersects: '$geoIntersects', + point: '$point', +}; + +const transformQueryConstraintInputToParse = ( + constraints, + parentFieldName, + className, + parentConstraints, + parseClasses +) => { + const fields = parseClasses.find( + parseClass => parseClass.className === className + ).fields; + if (parentFieldName === 'id' && className) { + Object.keys(constraints).forEach(constraintName => { + const constraintValue = constraints[constraintName]; + if (typeof constraintValue === 'string') { + const globalIdObject = fromGlobalId(constraintValue); + + if (globalIdObject.type === className) { + constraints[constraintName] = globalIdObject.id; + } + } else if (Array.isArray(constraintValue)) { + constraints[constraintName] = constraintValue.map(value => { + const globalIdObject = fromGlobalId(value); + + if (globalIdObject.type === className) { + return globalIdObject.id; + } + + return value; + }); + } + }); + parentConstraints.objectId = constraints; + delete parentConstraints.id; + } + Object.keys(constraints).forEach(fieldName => { + let fieldValue = constraints[fieldName]; + if (parseConstraintMap[fieldName]) { + constraints[parseConstraintMap[fieldName]] = constraints[fieldName]; + delete constraints[fieldName]; + } + /** + * If we have a key-value pair, we need to change the way the constraint is structured. + * + * Example: + * From: + * { + * "someField": { + * "lessThan": { + * "key":"foo.bar", + * "value": 100 + * }, + * "greaterThan": { + * "key":"foo.bar", + * "value": 10 + * } + * } + * } + * + * To: + * { + * "someField.foo.bar": { + * "$lt": 100, + * "$gt": 10 + * } + * } + */ + if ( + fieldValue.key && + fieldValue.value && + parentConstraints && + parentFieldName + ) { + delete parentConstraints[parentFieldName]; + parentConstraints[`${parentFieldName}.${fieldValue.key}`] = { + ...parentConstraints[`${parentFieldName}.${fieldValue.key}`], + [parseConstraintMap[fieldName]]: fieldValue.value, + }; + } else if ( + fields[parentFieldName] && + (fields[parentFieldName].type === 'Pointer' || + fields[parentFieldName].type === 'Relation') + ) { + const { targetClass } = fields[parentFieldName]; + if (fieldName === 'exists') { + if (fields[parentFieldName].type === 'Relation') { + const whereTarget = fieldValue ? 'where' : 'notWhere'; + if (constraints[whereTarget]) { + if (constraints[whereTarget].objectId) { + constraints[whereTarget].objectId = { + ...constraints[whereTarget].objectId, + $exists: fieldValue, + }; + } else { + constraints[whereTarget].objectId = { + $exists: fieldValue, + }; + } + } else { + const parseWhereTarget = fieldValue ? '$inQuery' : '$notInQuery'; + parentConstraints[parentFieldName][parseWhereTarget] = { + where: { objectId: { $exists: true } }, + className: targetClass, + }; + } + delete constraints.$exists; + } else { + parentConstraints[parentFieldName].$exists = fieldValue; + } + return; + } + switch (fieldName) { + case 'have': + parentConstraints[parentFieldName].$inQuery = { + where: fieldValue, + className: targetClass, + }; + transformQueryInputToParse( + parentConstraints[parentFieldName].$inQuery.where, + targetClass, + parseClasses + ); + break; + case 'haveNot': + parentConstraints[parentFieldName].$notInQuery = { + where: fieldValue, + className: targetClass, + }; + transformQueryInputToParse( + parentConstraints[parentFieldName].$notInQuery.where, + targetClass, + parseClasses + ); + break; + } + delete constraints[fieldName]; + return; + } + switch (fieldName) { + case 'point': + if (typeof fieldValue === 'object' && !fieldValue.__type) { + fieldValue.__type = 'GeoPoint'; + } + break; + case 'nearSphere': + if (typeof fieldValue === 'object' && !fieldValue.__type) { + fieldValue.__type = 'GeoPoint'; + } + break; + case 'box': + if ( + typeof fieldValue === 'object' && + fieldValue.bottomLeft && + fieldValue.upperRight + ) { + fieldValue = [ + { + __type: 'GeoPoint', + ...fieldValue.bottomLeft, + }, + { + __type: 'GeoPoint', + ...fieldValue.upperRight, + }, + ]; + constraints[parseConstraintMap[fieldName]] = fieldValue; + } + break; + case 'polygon': + if (fieldValue instanceof Array) { + fieldValue.forEach(geoPoint => { + if (typeof geoPoint === 'object' && !geoPoint.__type) { + geoPoint.__type = 'GeoPoint'; + } + }); + } + break; + case 'centerSphere': + if ( + typeof fieldValue === 'object' && + fieldValue.center && + fieldValue.distance + ) { + fieldValue = [ + { + __type: 'GeoPoint', + ...fieldValue.center, + }, + fieldValue.distance, + ]; + constraints[parseConstraintMap[fieldName]] = fieldValue; + } + break; + } + if (typeof fieldValue === 'object') { + if (fieldName === 'where') { + transformQueryInputToParse(fieldValue, className, parseClasses); + } else { + transformQueryConstraintInputToParse( + fieldValue, + fieldName, + className, + constraints, + parseClasses + ); + } + } + }); +}; + +const transformQueryInputToParse = (constraints, className, parseClasses) => { + if (!constraints || typeof constraints !== 'object') { + return; + } + + Object.keys(constraints).forEach(fieldName => { + const fieldValue = constraints[fieldName]; + + if (parseQueryMap[fieldName]) { + delete constraints[fieldName]; + fieldName = parseQueryMap[fieldName]; + constraints[fieldName] = fieldValue; + fieldValue.forEach(fieldValueItem => { + transformQueryInputToParse(fieldValueItem, className, parseClasses); + }); + return; + } else { + transformQueryConstraintInputToParse( + fieldValue, + fieldName, + className, + constraints, + parseClasses + ); + } + }); +}; + +export { transformQueryConstraintInputToParse, transformQueryInputToParse }; diff --git a/src/GraphQL/transformers/schemaFields.js b/src/GraphQL/transformers/schemaFields.js new file mode 100644 index 0000000000..9d94e6f80e --- /dev/null +++ b/src/GraphQL/transformers/schemaFields.js @@ -0,0 +1,147 @@ +import Parse from 'parse/node'; + +const transformToParse = (graphQLSchemaFields, existingFields) => { + if (!graphQLSchemaFields) { + return {}; + } + + let parseSchemaFields = {}; + + const reducerGenerator = type => (parseSchemaFields, field) => { + if (type === 'Remove') { + if (existingFields[field.name]) { + return { + ...parseSchemaFields, + [field.name]: { + __op: 'Delete', + }, + }; + } else { + return parseSchemaFields; + } + } + if ( + graphQLSchemaFields.remove && + graphQLSchemaFields.remove.find( + removeField => removeField.name === field.name + ) + ) { + return parseSchemaFields; + } + if ( + parseSchemaFields[field.name] || + (existingFields && existingFields[field.name]) + ) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Duplicated field name: ${field.name}` + ); + } + if (type === 'Relation' || type === 'Pointer') { + return { + ...parseSchemaFields, + [field.name]: { + type, + targetClass: field.targetClassName, + }, + }; + } + return { + ...parseSchemaFields, + [field.name]: { + type, + }, + }; + }; + + if (graphQLSchemaFields.addStrings) { + parseSchemaFields = graphQLSchemaFields.addStrings.reduce( + reducerGenerator('String'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addNumbers) { + parseSchemaFields = graphQLSchemaFields.addNumbers.reduce( + reducerGenerator('Number'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addBooleans) { + parseSchemaFields = graphQLSchemaFields.addBooleans.reduce( + reducerGenerator('Boolean'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addArrays) { + parseSchemaFields = graphQLSchemaFields.addArrays.reduce( + reducerGenerator('Array'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addObjects) { + parseSchemaFields = graphQLSchemaFields.addObjects.reduce( + reducerGenerator('Object'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addDates) { + parseSchemaFields = graphQLSchemaFields.addDates.reduce( + reducerGenerator('Date'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addFiles) { + parseSchemaFields = graphQLSchemaFields.addFiles.reduce( + reducerGenerator('File'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addGeoPoint) { + parseSchemaFields = [graphQLSchemaFields.addGeoPoint].reduce( + reducerGenerator('GeoPoint'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addPolygons) { + parseSchemaFields = graphQLSchemaFields.addPolygons.reduce( + reducerGenerator('Polygon'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addBytes) { + parseSchemaFields = graphQLSchemaFields.addBytes.reduce( + reducerGenerator('Bytes'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addPointers) { + parseSchemaFields = graphQLSchemaFields.addPointers.reduce( + reducerGenerator('Pointer'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addRelations) { + parseSchemaFields = graphQLSchemaFields.addRelations.reduce( + reducerGenerator('Relation'), + parseSchemaFields + ); + } + if (existingFields && graphQLSchemaFields.remove) { + parseSchemaFields = graphQLSchemaFields.remove.reduce( + reducerGenerator('Remove'), + parseSchemaFields + ); + } + + return parseSchemaFields; +}; + +const transformToGraphQL = parseSchemaFields => { + return Object.keys(parseSchemaFields).map(name => ({ + name, + type: parseSchemaFields[name].type, + targetClassName: parseSchemaFields[name].targetClass, + })); +}; + +export { transformToParse, transformToGraphQL }; diff --git a/src/LiveQuery/Client.js b/src/LiveQuery/Client.js index 4c88635d26..40e934d0fc 100644 --- a/src/LiveQuery/Client.js +++ b/src/LiveQuery/Client.js @@ -3,12 +3,20 @@ import logger from '../logger'; import type { FlattenedObjectData } from './Subscription'; export type Message = { [attr: string]: any }; -const dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL']; +const dafaultFields = [ + 'className', + 'objectId', + 'updatedAt', + 'createdAt', + 'ACL', +]; class Client { id: number; parseWebSocket: any; hasMasterKey: boolean; + sessionToken: string; + installationId: string; userId: string; roles: Array; subscriptionInfos: Object; @@ -21,10 +29,18 @@ class Client { pushDelete: Function; pushLeave: Function; - constructor(id: number, parseWebSocket: any, hasMasterKey: boolean) { + constructor( + id: number, + parseWebSocket: any, + hasMasterKey: boolean = false, + sessionToken: string, + installationId: string + ) { this.id = id; this.parseWebSocket = parseWebSocket; this.hasMasterKey = hasMasterKey; + this.sessionToken = sessionToken; + this.installationId = installationId; this.roles = []; this.subscriptionInfos = new Map(); this.pushConnect = this._pushEvent('connected'); @@ -42,13 +58,21 @@ class Client { parseWebSocket.send(message); } - static pushError(parseWebSocket: any, code: number, error: string, reconnect: boolean = true): void { - Client.pushResponse(parseWebSocket, JSON.stringify({ - 'op': 'error', - 'error': error, - 'code': code, - 'reconnect': reconnect - })); + static pushError( + parseWebSocket: any, + code: number, + error: string, + reconnect: boolean = true + ): void { + Client.pushResponse( + parseWebSocket, + JSON.stringify({ + op: 'error', + error: error, + code: code, + reconnect: reconnect, + }) + ); } addSubscriptionInfo(requestId: number, subscriptionInfo: any): void { @@ -64,10 +88,15 @@ class Client { } _pushEvent(type: string): Function { - return function(subscriptionId: number, parseObjectJSON: any): void { + return function( + subscriptionId: number, + parseObjectJSON: any, + parseOriginalObjectJSON: any + ): void { const response: Message = { - 'op' : type, - 'clientId' : this.id + op: type, + clientId: this.id, + installationId: this.installationId, }; if (typeof subscriptionId !== 'undefined') { response['requestId'] = subscriptionId; @@ -78,9 +107,15 @@ class Client { fields = this.subscriptionInfos.get(subscriptionId).fields; } response['object'] = this._toJSONWithFields(parseObjectJSON, fields); + if (parseOriginalObjectJSON) { + response['original'] = this._toJSONWithFields( + parseOriginalObjectJSON, + fields + ); + } } Client.pushResponse(this.parseWebSocket, JSON.stringify(response)); - } + }; } _toJSONWithFields(parseObjectJSON: any, fields: any): FlattenedObjectData { @@ -100,6 +135,4 @@ class Client { } } -export { - Client -} +export { Client }; diff --git a/src/LiveQuery/ParseCloudCodePublisher.js b/src/LiveQuery/ParseCloudCodePublisher.js index 5b1645bbe1..85e95121fb 100644 --- a/src/LiveQuery/ParseCloudCodePublisher.js +++ b/src/LiveQuery/ParseCloudCodePublisher.js @@ -1,5 +1,5 @@ import { ParsePubSub } from './ParsePubSub'; -import Parse from 'parse/node'; +import Parse from 'parse/node'; import logger from '../logger'; class ParseCloudCodePublisher { @@ -21,11 +21,15 @@ class ParseCloudCodePublisher { // Request is the request object from cloud code functions. request.object is a ParseObject. _onCloudCodeMessage(type: string, request: any): void { - logger.verbose('Raw request from cloud code current : %j | original : %j', request.object, request.original); + logger.verbose( + 'Raw request from cloud code current : %j | original : %j', + request.object, + request.original + ); // We need the full JSON which includes className const message = { - currentParseObject: request.object._toFullJSON() - } + currentParseObject: request.object._toFullJSON(), + }; if (request.original) { message.originalParseObject = request.original._toFullJSON(); } @@ -33,6 +37,4 @@ class ParseCloudCodePublisher { } } -export { - ParseCloudCodePublisher -} +export { ParseCloudCodePublisher }; diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index b8b7bf3d8c..fc3d7e3902 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -7,26 +7,31 @@ import logger from '../logger'; import RequestSchema from './RequestSchema'; import { matchesQuery, queryHash } from './QueryTools'; import { ParsePubSub } from './ParsePubSub'; -import { SessionTokenCache } from './SessionTokenCache'; +import SchemaController from '../Controllers/SchemaController'; import _ from 'lodash'; import uuid from 'uuid'; import { runLiveQueryEventHandlers } from '../triggers'; +import { getAuthForSessionToken, Auth } from '../Auth'; +import { getCacheController } from '../Controllers'; +import LRU from 'lru-cache'; +import UserRouter from '../Routers/UsersRouter'; class ParseLiveQueryServer { clients: Map; // className -> (queryHash -> subscription) subscriptions: Object; parseWebSocketServer: Object; - keyPairs : any; + keyPairs: any; // The subscriber we use to get object update from publisher subscriber: Object; - constructor(server: any, config: any) { + constructor(server: any, config: any = {}) { this.server = server; this.clients = new Map(); this.subscriptions = new Map(); - config = config || {}; + config.appId = config.appId || Parse.applicationId; + config.masterKey = config.masterKey || Parse.masterKey; // Store keys, convert obj to map const keyPairs = config.keyPairs || {}; @@ -38,19 +43,25 @@ class ParseLiveQueryServer { // Initialize Parse Parse.Object.disableSingleInstance(); - const serverURL = config.serverURL || Parse.serverURL; Parse.serverURL = serverURL; - const appId = config.appId || Parse.applicationId; - const javascriptKey = Parse.javaScriptKey; - const masterKey = config.masterKey || Parse.masterKey; - Parse.initialize(appId, javascriptKey, masterKey); + Parse.initialize(config.appId, Parse.javaScriptKey, config.masterKey); + + // The cache controller is a proper cache controller + // with access to User and Roles + this.cacheController = getCacheController(config); + // This auth cache stores the promises for each auth resolution. + // The main benefit is to be able to reuse the same user / session token resolution. + this.authCache = new LRU({ + max: 500, // 500 concurrent + maxAge: 60 * 60 * 1000, // 1h + }); // Initialize websocket server this.parseWebSocketServer = new ParseWebSocketServer( server, - (parseWebsocket) => this._onConnect(parseWebsocket), - config.websocketTimeout + parseWebsocket => this._onConnect(parseWebsocket), + config ); // Initialize subscriber @@ -64,7 +75,7 @@ class ParseLiveQueryServer { let message; try { message = JSON.parse(messageStr); - } catch(e) { + } catch (e) { logger.error('unable to parse message', messageStr, e); return; } @@ -74,12 +85,13 @@ class ParseLiveQueryServer { } else if (channel === Parse.applicationId + 'afterDelete') { this._onAfterDelete(message); } else { - logger.error('Get message %s from unknown channel %j', message, channel); + logger.error( + 'Get message %s from unknown channel %j', + message, + channel + ); } }); - - // Initialize sessionToken cache - this.sessionTokenCache = new SessionTokenCache(config.cacheTimeout); } // Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes. @@ -87,6 +99,7 @@ class ParseLiveQueryServer { _inflateParseObject(message: any): void { // Inflate merged object const currentParseObject = message.currentParseObject; + UserRouter.removeHiddenProperties(currentParseObject); let className = currentParseObject.className; let parseObject = new Parse.Object(className); parseObject._finishFetch(currentParseObject); @@ -94,6 +107,7 @@ class ParseLiveQueryServer { // Inflate original object const originalParseObject = message.originalParseObject; if (originalParseObject) { + UserRouter.removeHiddenProperties(originalParseObject); className = originalParseObject.className; parseObject = new Parse.Object(className); parseObject._finishFetch(originalParseObject); @@ -107,8 +121,13 @@ class ParseLiveQueryServer { logger.verbose(Parse.applicationId + 'afterDelete is triggered'); const deletedParseObject = message.currentParseObject.toJSON(); + const classLevelPermissions = message.classLevelPermissions; const className = deletedParseObject.className; - logger.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id); + logger.verbose( + 'ClassName: %j | ObjectId: %s', + className, + deletedParseObject.id + ); logger.verbose('Current client number : %d', this.clients.size); const classSubscriptions = this.subscriptions.get(className); @@ -117,26 +136,44 @@ class ParseLiveQueryServer { return; } for (const subscription of classSubscriptions.values()) { - const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + const isSubscriptionMatched = this._matchesSubscription( + deletedParseObject, + subscription + ); if (!isSubscriptionMatched) { continue; } - for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { + for (const [clientId, requestIds] of _.entries( + subscription.clientRequestIds + )) { const client = this.clients.get(clientId); if (typeof client === 'undefined') { continue; } for (const requestId of requestIds) { const acl = message.currentParseObject.getACL(); - // Check ACL - this._matchesACL(acl, client, requestId).then((isMatched) => { - if (!isMatched) { - return null; - } - client.pushDelete(requestId, deletedParseObject); - }, (error) => { - logger.error('Matching ACL error : ', error); - }); + // Check CLP + const op = this._getCLPOperation(subscription.query); + this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ) + .then(() => { + // Check ACL + return this._matchesACL(acl, client, requestId); + }) + .then(isMatched => { + if (!isMatched) { + return null; + } + client.pushDelete(requestId, deletedParseObject); + }) + .catch(error => { + logger.error('Matching ACL error : ', error); + }); } } } @@ -151,9 +188,14 @@ class ParseLiveQueryServer { if (message.originalParseObject) { originalParseObject = message.originalParseObject.toJSON(); } + const classLevelPermissions = message.classLevelPermissions; const currentParseObject = message.currentParseObject.toJSON(); const className = currentParseObject.className; - logger.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id); + logger.verbose( + 'ClassName: %s | ObjectId: %s', + className, + currentParseObject.id + ); logger.verbose('Current client number : %d', this.clients.size); const classSubscriptions = this.subscriptions.get(className); @@ -162,9 +204,17 @@ class ParseLiveQueryServer { return; } for (const subscription of classSubscriptions.values()) { - const isOriginalSubscriptionMatched = this._matchesSubscription(originalParseObject, subscription); - const isCurrentSubscriptionMatched = this._matchesSubscription(currentParseObject, subscription); - for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { + const isOriginalSubscriptionMatched = this._matchesSubscription( + originalParseObject, + subscription + ); + const isCurrentSubscriptionMatched = this._matchesSubscription( + currentParseObject, + subscription + ); + for (const [clientId, requestIds] of _.entries( + subscription.clientRequestIds + )) { const client = this.clients.get(clientId); if (typeof client === 'undefined') { continue; @@ -174,69 +224,95 @@ class ParseLiveQueryServer { // subscription, we do not need to check ACL let originalACLCheckingPromise; if (!isOriginalSubscriptionMatched) { - originalACLCheckingPromise = Parse.Promise.as(false); + originalACLCheckingPromise = Promise.resolve(false); } else { let originalACL; if (message.originalParseObject) { originalACL = message.originalParseObject.getACL(); } - originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId); + originalACLCheckingPromise = this._matchesACL( + originalACL, + client, + requestId + ); } // Set current ParseObject ACL checking promise, if the object does not match // subscription, we do not need to check ACL let currentACLCheckingPromise; if (!isCurrentSubscriptionMatched) { - currentACLCheckingPromise = Parse.Promise.as(false); + currentACLCheckingPromise = Promise.resolve(false); } else { const currentACL = message.currentParseObject.getACL(); - currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId); - } - - Parse.Promise.when( - originalACLCheckingPromise, - currentACLCheckingPromise - ).then((isOriginalMatched, isCurrentMatched) => { - logger.verbose('Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', - originalParseObject, - currentParseObject, - isOriginalSubscriptionMatched, - isCurrentSubscriptionMatched, - isOriginalMatched, - isCurrentMatched, - subscription.hash + currentACLCheckingPromise = this._matchesACL( + currentACL, + client, + requestId ); - - // Decide event type - let type; - if (isOriginalMatched && isCurrentMatched) { - type = 'Update'; - } else if (isOriginalMatched && !isCurrentMatched) { - type = 'Leave'; - } else if (!isOriginalMatched && isCurrentMatched) { - if (originalParseObject) { - type = 'Enter'; - } else { - type = 'Create'; + } + const op = this._getCLPOperation(subscription.query); + this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ) + .then(() => { + return Promise.all([ + originalACLCheckingPromise, + currentACLCheckingPromise, + ]); + }) + .then( + ([isOriginalMatched, isCurrentMatched]) => { + logger.verbose( + 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + originalParseObject, + currentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); + + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'Update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'Leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (originalParseObject) { + type = 'Enter'; + } else { + type = 'Create'; + } + } else { + return null; + } + const functionName = 'push' + type; + client[functionName]( + requestId, + currentParseObject, + originalParseObject + ); + }, + error => { + logger.error('Matching ACL error : ', error); } - } else { - return null; - } - const functionName = 'push' + type; - client[functionName](requestId, currentParseObject); - }, (error) => { - logger.error('Matching ACL error : ', error); - }); + ); } } } } _onConnect(parseWebsocket: any): void { - parseWebsocket.on('message', (request) => { + parseWebsocket.on('message', request => { if (typeof request === 'string') { try { request = JSON.parse(request); - } catch(e) { + } catch (e) { logger.error('unable to parse request', request, e); return; } @@ -244,28 +320,31 @@ class ParseLiveQueryServer { logger.verbose('Request: %j', request); // Check whether this request is a valid request, return error directly if not - if (!tv4.validate(request, RequestSchema['general']) || !tv4.validate(request, RequestSchema[request.op])) { + if ( + !tv4.validate(request, RequestSchema['general']) || + !tv4.validate(request, RequestSchema[request.op]) + ) { Client.pushError(parseWebsocket, 1, tv4.error.message); logger.error('Connect message error %s', tv4.error.message); return; } - switch(request.op) { - case 'connect': - this._handleConnect(parseWebsocket, request); - break; - case 'subscribe': - this._handleSubscribe(parseWebsocket, request); - break; - case 'update': - this._handleUpdateSubscription(parseWebsocket, request); - break; - case 'unsubscribe': - this._handleUnsubscribe(parseWebsocket, request); - break; - default: - Client.pushError(parseWebsocket, 3, 'Get unknown operation'); - logger.error('Get unknown operation', request.op); + switch (request.op) { + case 'connect': + this._handleConnect(parseWebsocket, request); + break; + case 'subscribe': + this._handleSubscribe(parseWebsocket, request); + break; + case 'update': + this._handleUpdateSubscription(parseWebsocket, request); + break; + case 'unsubscribe': + this._handleUnsubscribe(parseWebsocket, request); + break; + default: + Client.pushError(parseWebsocket, 3, 'Get unknown operation'); + logger.error('Get unknown operation', request.op); } }); @@ -277,7 +356,7 @@ class ParseLiveQueryServer { event: 'ws_disconnect_error', clients: this.clients.size, subscriptions: this.subscriptions.size, - error: `Unable to find client ${clientId}` + error: `Unable to find client ${clientId}`, }); logger.error(`Can not find client ${clientId} on disconnect`); return; @@ -288,12 +367,16 @@ class ParseLiveQueryServer { this.clients.delete(clientId); // Delete client from subscriptions - for (const [requestId, subscriptionInfo] of _.entries(client.subscriptionInfos)) { + for (const [requestId, subscriptionInfo] of _.entries( + client.subscriptionInfos + )) { const subscription = subscriptionInfo.subscription; subscription.deleteClientSubscription(clientId, requestId); // If there is no client which is subscribing this subscription, remove it from subscriptions - const classSubscriptions = this.subscriptions.get(subscription.className); + const classSubscriptions = this.subscriptions.get( + subscription.className + ); if (!subscription.hasSubscribingClient()) { classSubscriptions.delete(subscription.hash); } @@ -308,14 +391,16 @@ class ParseLiveQueryServer { runLiveQueryEventHandlers({ event: 'ws_disconnect', clients: this.clients.size, - subscriptions: this.subscriptions.size + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, }); }); runLiveQueryEventHandlers({ event: 'ws_connect', clients: this.clients.size, - subscriptions: this.subscriptions.size + subscriptions: this.subscriptions.size, }); } @@ -327,92 +412,166 @@ class ParseLiveQueryServer { return matchesQuery(parseObject, subscription.query); } - _matchesACL(acl: any, client: any, requestId: number): any { - // Return true directly if ACL isn't present, ACL is public read, or client has master key - if (!acl || acl.getPublicReadAccess() || client.hasMasterKey) { - return Parse.Promise.as(true); + getAuthForSessionToken( + sessionToken: ?string + ): Promise<{ auth: ?Auth, userId: ?string }> { + if (!sessionToken) { + return Promise.resolve({}); } - // Check subscription sessionToken matches ACL first - const subscriptionInfo = client.getSubscriptionInfo(requestId); - if (typeof subscriptionInfo === 'undefined') { - return Parse.Promise.as(false); + const fromCache = this.authCache.get(sessionToken); + if (fromCache) { + return fromCache; } + const authPromise = getAuthForSessionToken({ + cacheController: this.cacheController, + sessionToken: sessionToken, + }) + .then(auth => { + return { auth, userId: auth && auth.user && auth.user.id }; + }) + .catch(error => { + // There was an error with the session token + const result = {}; + if (error && error.code === Parse.Error.INVALID_SESSION_TOKEN) { + // Store a resolved promise with the error for 10 minutes + result.error = error; + this.authCache.set( + sessionToken, + Promise.resolve(result), + 60 * 10 * 1000 + ); + } else { + this.authCache.del(sessionToken); + } + return result; + }); + this.authCache.set(sessionToken, authPromise); + return authPromise; + } - const subscriptionSessionToken = subscriptionInfo.sessionToken; - return this.sessionTokenCache.getUserId(subscriptionSessionToken).then((userId) => { - return acl.getReadAccess(userId); - }).then((isSubscriptionSessionTokenMatched) => { - if (isSubscriptionSessionTokenMatched) { - return Parse.Promise.as(true); + async _matchesCLP( + classLevelPermissions: ?any, + object: any, + client: any, + requestId: number, + op: string + ): any { + // try to match on user first, less expensive than with roles + const subscriptionInfo = client.getSubscriptionInfo(requestId); + const aclGroup = ['*']; + let userId; + if (typeof subscriptionInfo !== 'undefined') { + const { userId } = await this.getAuthForSessionToken( + subscriptionInfo.sessionToken + ); + if (userId) { + aclGroup.push(userId); } + } + try { + await SchemaController.validatePermission( + classLevelPermissions, + object.className, + aclGroup, + op + ); + return true; + } catch (e) { + logger.verbose(`Failed matching CLP for ${object.id} ${userId} ${e}`); + return false; + } + // TODO: handle roles permissions + // Object.keys(classLevelPermissions).forEach((key) => { + // const perm = classLevelPermissions[key]; + // Object.keys(perm).forEach((key) => { + // if (key.indexOf('role')) + // }); + // }) + // // it's rejected here, check the roles + // var rolesQuery = new Parse.Query(Parse.Role); + // rolesQuery.equalTo("users", user); + // return rolesQuery.find({useMasterKey:true}); + } - // Check if the user has any roles that match the ACL - return new Parse.Promise((resolve, reject) => { + _getCLPOperation(query: any) { + return typeof query === 'object' && + Object.keys(query).length == 1 && + typeof query.objectId === 'string' + ? 'get' + : 'find'; + } - // Resolve false right away if the acl doesn't have any roles - const acl_has_roles = Object.keys(acl.permissionsById).some(key => key.startsWith("role:")); - if (!acl_has_roles) { - return resolve(false); - } + async _verifyACL(acl: any, token: string) { + if (!token) { + return false; + } - this.sessionTokenCache.getUserId(subscriptionSessionToken) - .then((userId) => { + const { auth, userId } = await this.getAuthForSessionToken(token); - // Pass along a null if there is no user id - if (!userId) { - return Parse.Promise.as(null); - } + // Getting the session token failed + // This means that no additional auth is available + // At this point, just bail out as no additional visibility can be inferred. + if (!auth || !userId) { + return false; + } + const isSubscriptionSessionTokenMatched = acl.getReadAccess(userId); + if (isSubscriptionSessionTokenMatched) { + return true; + } - // Prepare a user object to query for roles - // To eliminate a query for the user, create one locally with the id - var user = new Parse.User(); - user.id = userId; - return user; + // Check if the user has any roles that match the ACL + return Promise.resolve() + .then(async () => { + // Resolve false right away if the acl doesn't have any roles + const acl_has_roles = Object.keys(acl.permissionsById).some(key => + key.startsWith('role:') + ); + if (!acl_has_roles) { + return false; + } - }) - .then((user) => { + const roleNames = await auth.getUserRoles(); + // Finally, see if any of the user's roles allow them read access + for (const role of roleNames) { + // We use getReadAccess as `role` is in the form `role:roleName` + if (acl.getReadAccess(role)) { + return true; + } + } + return false; + }) + .catch(() => { + return false; + }); + } - // Pass along an empty array (of roles) if no user - if (!user) { - return Parse.Promise.as([]); - } + async _matchesACL( + acl: any, + client: any, + requestId: number + ): Promise { + // Return true directly if ACL isn't present, ACL is public read, or client has master key + if (!acl || acl.getPublicReadAccess() || client.hasMasterKey) { + return true; + } + // Check subscription sessionToken matches ACL first + const subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + return false; + } - // Then get the user's roles - var rolesQuery = new Parse.Query(Parse.Role); - rolesQuery.equalTo("users", user); - return rolesQuery.find({useMasterKey:true}); - }). - then((roles) => { - - // Finally, see if any of the user's roles allow them read access - for (const role of roles) { - if (acl.getRoleReadAccess(role)) { - return resolve(true); - } - } - resolve(false); - }) - .catch((error) => { - reject(error); - }); + const subscriptionToken = subscriptionInfo.sessionToken; + const clientSessionToken = client.sessionToken; - }); - }).then((isRoleMatched) => { + if (await this._verifyACL(acl, subscriptionToken)) { + return true; + } - if(isRoleMatched) { - return Parse.Promise.as(true); - } + if (await this._verifyACL(acl, clientSessionToken)) { + return true; + } - // Check client sessionToken matches ACL - const clientSessionToken = client.sessionToken; - return this.sessionTokenCache.getUserId(clientSessionToken).then((userId) => { - return acl.getReadAccess(userId); - }); - }).then((isMatched) => { - return Parse.Promise.as(isMatched); - }, () => { - return Parse.Promise.as(false); - }); + return false; } _handleConnect(parseWebsocket: any, request: any): any { @@ -423,27 +582,43 @@ class ParseLiveQueryServer { } const hasMasterKey = this._hasMasterKey(request, this.keyPairs); const clientId = uuid(); - const client = new Client(clientId, parseWebsocket, hasMasterKey); + const client = new Client( + clientId, + parseWebsocket, + hasMasterKey, + request.sessionToken, + request.installationId + ); parseWebsocket.clientId = clientId; this.clients.set(parseWebsocket.clientId, client); logger.info(`Create new client: ${parseWebsocket.clientId}`); client.pushConnect(); runLiveQueryEventHandlers({ + client, event: 'connect', clients: this.clients.size, - subscriptions: this.subscriptions.size + subscriptions: this.subscriptions.size, + sessionToken: request.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: request.installationId, }); } _hasMasterKey(request: any, validKeyPairs: any): boolean { - if(!validKeyPairs || validKeyPairs.size == 0 || - !validKeyPairs.has("masterKey")) { + if ( + !validKeyPairs || + validKeyPairs.size == 0 || + !validKeyPairs.has('masterKey') + ) { return false; } - if(!request || !request.hasOwnProperty("masterKey")) { + if ( + !request || + !Object.prototype.hasOwnProperty.call(request, 'masterKey') + ) { return false; } - return request.masterKey === validKeyPairs.get("masterKey"); + return request.masterKey === validKeyPairs.get('masterKey'); } _validateKeys(request: any, validKeyPairs: any): boolean { @@ -463,9 +638,15 @@ class ParseLiveQueryServer { _handleSubscribe(parseWebsocket: any, request: any): any { // If we can not find this client, return error to client - if (!parseWebsocket.hasOwnProperty('clientId')) { - Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before subscribing'); - logger.error('Can not find this client, make sure you connect to server before subscribing'); + if (!Object.prototype.hasOwnProperty.call(parseWebsocket, 'clientId')) { + Client.pushError( + parseWebsocket, + 2, + 'Can not find this client, make sure you connect to server before subscribing' + ); + logger.error( + 'Can not find this client, make sure you connect to server before subscribing' + ); return; } const client = this.clients.get(parseWebsocket.clientId); @@ -482,15 +663,19 @@ class ParseLiveQueryServer { if (classSubscriptions.has(subscriptionHash)) { subscription = classSubscriptions.get(subscriptionHash); } else { - subscription = new Subscription(className, request.query.where, subscriptionHash); + subscription = new Subscription( + className, + request.query.where, + subscriptionHash + ); classSubscriptions.set(subscriptionHash, subscription); } // Add subscriptionInfo to client const subscriptionInfo = { - subscription: subscription + subscription: subscription, }; - // Add selected fields and sessionToken for this subscription if necessary + // Add selected fields, sessionToken and installationId for this subscription if necessary if (request.query.fields) { subscriptionInfo.fields = request.query.fields; } @@ -500,16 +685,25 @@ class ParseLiveQueryServer { client.addSubscriptionInfo(request.requestId, subscriptionInfo); // Add clientId to subscription - subscription.addClientSubscription(parseWebsocket.clientId, request.requestId); + subscription.addClientSubscription( + parseWebsocket.clientId, + request.requestId + ); client.pushSubscribe(request.requestId); - logger.verbose(`Create client ${parseWebsocket.clientId} new subscription: ${request.requestId}`); + logger.verbose( + `Create client ${parseWebsocket.clientId} new subscription: ${request.requestId}` + ); logger.verbose('Current client number: %d', this.clients.size); runLiveQueryEventHandlers({ + client, event: 'subscribe', clients: this.clients.size, - subscriptions: this.subscriptions.size + subscriptions: this.subscriptions.size, + sessionToken: request.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, }); } @@ -518,27 +712,54 @@ class ParseLiveQueryServer { this._handleSubscribe(parseWebsocket, request); } - _handleUnsubscribe(parseWebsocket: any, request: any, notifyClient: bool = true): any { + _handleUnsubscribe( + parseWebsocket: any, + request: any, + notifyClient: boolean = true + ): any { // If we can not find this client, return error to client - if (!parseWebsocket.hasOwnProperty('clientId')) { - Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before unsubscribing'); - logger.error('Can not find this client, make sure you connect to server before unsubscribing'); + if (!Object.prototype.hasOwnProperty.call(parseWebsocket, 'clientId')) { + Client.pushError( + parseWebsocket, + 2, + 'Can not find this client, make sure you connect to server before unsubscribing' + ); + logger.error( + 'Can not find this client, make sure you connect to server before unsubscribing' + ); return; } const requestId = request.requestId; const client = this.clients.get(parseWebsocket.clientId); if (typeof client === 'undefined') { - Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId + - '. Make sure you connect to live query server before unsubscribing.'); + Client.pushError( + parseWebsocket, + 2, + 'Cannot find client with clientId ' + + parseWebsocket.clientId + + '. Make sure you connect to live query server before unsubscribing.' + ); logger.error('Can not find this client ' + parseWebsocket.clientId); return; } const subscriptionInfo = client.getSubscriptionInfo(requestId); if (typeof subscriptionInfo === 'undefined') { - Client.pushError(parseWebsocket, 2, 'Cannot find subscription with clientId ' + parseWebsocket.clientId + - ' subscriptionId ' + requestId + '. Make sure you subscribe to live query server before unsubscribing.'); - logger.error('Can not find subscription with clientId ' + parseWebsocket.clientId + ' subscriptionId ' + requestId); + Client.pushError( + parseWebsocket, + 2, + 'Cannot find subscription with clientId ' + + parseWebsocket.clientId + + ' subscriptionId ' + + requestId + + '. Make sure you subscribe to live query server before unsubscribing.' + ); + logger.error( + 'Can not find subscription with clientId ' + + parseWebsocket.clientId + + ' subscriptionId ' + + requestId + ); return; } @@ -558,9 +779,13 @@ class ParseLiveQueryServer { this.subscriptions.delete(className); } runLiveQueryEventHandlers({ + client, event: 'unsubscribe', clients: this.clients.size, - subscriptions: this.subscriptions.size + subscriptions: this.subscriptions.size, + sessionToken: subscriptionInfo.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, }); if (!notifyClient) { @@ -569,10 +794,10 @@ class ParseLiveQueryServer { client.pushUnsubscribe(request.requestId); - logger.verbose(`Delete client: ${parseWebsocket.clientId} | subscription: ${request.requestId}`); + logger.verbose( + `Delete client: ${parseWebsocket.clientId} | subscription: ${request.requestId}` + ); } } -export { - ParseLiveQueryServer -} +export { ParseLiveQueryServer }; diff --git a/src/LiveQuery/ParsePubSub.js b/src/LiveQuery/ParsePubSub.js index ff321889a1..59639ea378 100644 --- a/src/LiveQuery/ParsePubSub.js +++ b/src/LiveQuery/ParsePubSub.js @@ -1,11 +1,7 @@ import { loadAdapter } from '../Adapters/AdapterLoader'; -import { - EventEmitterPubSub -} from '../Adapters/PubSub/EventEmitterPubSub'; +import { EventEmitterPubSub } from '../Adapters/PubSub/EventEmitterPubSub'; -import { - RedisPubSub -} from '../Adapters/PubSub/RedisPubSub'; +import { RedisPubSub } from '../Adapters/PubSub/RedisPubSub'; const ParsePubSub = {}; @@ -18,26 +14,32 @@ ParsePubSub.createPublisher = function(config: any): any { if (useRedis(config)) { return RedisPubSub.createPublisher(config); } else { - const adapter = loadAdapter(config.pubSubAdapter, EventEmitterPubSub, config) + const adapter = loadAdapter( + config.pubSubAdapter, + EventEmitterPubSub, + config + ); if (typeof adapter.createPublisher !== 'function') { throw 'pubSubAdapter should have createPublisher()'; } return adapter.createPublisher(config); } -} +}; ParsePubSub.createSubscriber = function(config: any): void { if (useRedis(config)) { return RedisPubSub.createSubscriber(config); } else { - const adapter = loadAdapter(config.pubSubAdapter, EventEmitterPubSub, config) + const adapter = loadAdapter( + config.pubSubAdapter, + EventEmitterPubSub, + config + ); if (typeof adapter.createSubscriber !== 'function') { throw 'pubSubAdapter should have createSubscriber()'; } return adapter.createSubscriber(config); } -} +}; -export { - ParsePubSub -} +export { ParsePubSub }; diff --git a/src/LiveQuery/ParseWebSocketServer.js b/src/LiveQuery/ParseWebSocketServer.js index c76c36f752..606056fc2e 100644 --- a/src/LiveQuery/ParseWebSocketServer.js +++ b/src/LiveQuery/ParseWebSocketServer.js @@ -1,24 +1,22 @@ +import { loadAdapter } from '../Adapters/AdapterLoader'; +import { WSAdapter } from '../Adapters/WebSocketServer/WSAdapter'; import logger from '../logger'; - -const typeMap = new Map([['disconnect', 'close']]); -const getWS = function() { - try { - return require('uws'); - } catch(e) { - return require('ws'); - } -} +import events from 'events'; export class ParseWebSocketServer { server: Object; - constructor(server: any, onConnect: Function, websocketTimeout: number = 10 * 1000) { - const WebSocketServer = getWS().Server; - const wss = new WebSocketServer({ server: server }); - wss.on('listening', () => { + constructor(server: any, onConnect: Function, config) { + config.server = server; + const wss = loadAdapter(config.wssAdapter, WSAdapter, config); + wss.onListen = () => { logger.info('Parse LiveQuery Server starts running'); - }); - wss.on('connection', (ws) => { + }; + wss.onConnection = ws => { + ws.on('error', error => { + logger.error(error.message); + logger.error(JSON.stringify(ws)); + }); onConnect(new ParseWebSocket(ws)); // Send ping to client periodically const pingIntervalId = setInterval(() => { @@ -27,24 +25,33 @@ export class ParseWebSocketServer { } else { clearInterval(pingIntervalId); } - }, websocketTimeout); - }); + }, config.websocketTimeout || 10 * 1000); + }; + wss.onError = error => { + logger.error(error); + }; + wss.start(); this.server = wss; } + + close() { + if (this.server && this.server.close) { + this.server.close(); + } + } } -export class ParseWebSocket { +export class ParseWebSocket extends events.EventEmitter { ws: any; constructor(ws: any) { + super(); + ws.onmessage = request => + this.emit('message', request && request.data ? request.data : request); + ws.onclose = () => this.emit('disconnect'); this.ws = ws; } - on(type: string, callback): void { - const wsType = typeMap.has(type) ? typeMap.get(type) : type; - this.ws.on(wsType, callback); - } - send(message: any): void { this.ws.send(message); } diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 5a42b9d824..c5e588d0e2 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -13,7 +13,7 @@ var Parse = require('parse/node'); * Convert $or queries into an array of where conditions */ function flattenOrQueries(where) { - if (!where.hasOwnProperty('$or')) { + if (!Object.prototype.hasOwnProperty.call(where, '$or')) { return where; } var accum = []; @@ -55,8 +55,8 @@ function queryHash(query) { if (query instanceof Parse.Query) { query = { className: query.className, - where: query._where - } + where: query._where, + }; } var where = flattenOrQueries(query.where || {}); var columns = []; @@ -99,8 +99,10 @@ function contains(haystack: Array, needle: any): boolean { if (typeof ptr === 'string' && ptr === needle.objectId) { return true; } - if (ptr.className === needle.className && - ptr.objectId === needle.objectId) { + if ( + ptr.className === needle.className && + ptr.objectId === needle.objectId + ) { return true; } } @@ -117,7 +119,7 @@ function contains(haystack: Array, needle: any): boolean { function matchesQuery(object: any, query: any): boolean { if (query instanceof Parse.Query) { var className = - (object.id instanceof Id) ? object.id.className : object.className; + object.id instanceof Id ? object.id.className : object.className; if (className !== query.className) { return false; } @@ -144,7 +146,6 @@ function equalObjectsGeneric(obj, compareTo, eqlFn) { return eqlFn(obj, compareTo); } - /** * Determines whether an object matches a single key's constraints */ @@ -152,12 +153,16 @@ function matchesKeyConstraints(object, key, constraints) { if (constraints === null) { return false; } - if(key.indexOf(".") >= 0){ + if (key.indexOf('.') >= 0) { // Key references a subobject - var keyComponents = key.split("."); + var keyComponents = key.split('.'); var subObjectKey = keyComponents[0]; - var keyRemainder = keyComponents.slice(1).join("."); - return matchesKeyConstraints(object[subObjectKey] || {}, keyRemainder, constraints); + var keyRemainder = keyComponents.slice(1).join('.'); + return matchesKeyConstraints( + object[subObjectKey] || {}, + keyRemainder, + constraints + ); } var i; if (key === '$or') { @@ -172,6 +177,10 @@ function matchesKeyConstraints(object, key, constraints) { // Bail! We can't handle relational queries locally return false; } + // Decode Date JSON value + if (object[key] && object[key].__type == 'Date') { + object[key] = new Date(object[key].iso); + } // Equality (or Array contains) cases if (typeof constraints !== 'object') { if (Array.isArray(object[key])) { @@ -191,7 +200,11 @@ function matchesKeyConstraints(object, key, constraints) { }); } - return equalObjectsGeneric(object[key], Parse._decode(key, constraints), equalObjects); + return equalObjectsGeneric( + object[key], + Parse._decode(key, constraints), + equalObjects + ); } // More complex cases for (var condition in constraints) { @@ -200,124 +213,131 @@ function matchesKeyConstraints(object, key, constraints) { compareTo = Parse._decode(key, compareTo); } switch (condition) { - case '$lt': - if (object[key] >= compareTo) { - return false; - } - break; - case '$lte': - if (object[key] > compareTo) { - return false; - } - break; - case '$gt': - if (object[key] <= compareTo) { - return false; - } - break; - case '$gte': - if (object[key] < compareTo) { - return false; - } - break; - case '$ne': - if (equalObjects(object[key], compareTo)) { - return false; - } - break; - case '$in': - if (!contains(compareTo, object[key])) { - return false; - } - break; - case '$nin': - if (contains(compareTo, object[key])) { - return false; - } - break; - case '$all': - for (i = 0; i < compareTo.length; i++) { - if (object[key].indexOf(compareTo[i]) < 0) { + case '$lt': + if (object[key] >= compareTo) { + return false; + } + break; + case '$lte': + if (object[key] > compareTo) { + return false; + } + break; + case '$gt': + if (object[key] <= compareTo) { + return false; + } + break; + case '$gte': + if (object[key] < compareTo) { + return false; + } + break; + case '$ne': + if (equalObjects(object[key], compareTo)) { + return false; + } + break; + case '$in': + if (!contains(compareTo, object[key])) { + return false; + } + break; + case '$nin': + if (contains(compareTo, object[key])) { + return false; + } + break; + case '$all': + for (i = 0; i < compareTo.length; i++) { + if (object[key].indexOf(compareTo[i]) < 0) { + return false; + } + } + break; + case '$exists': { + const propertyExists = typeof object[key] !== 'undefined'; + const existenceIsRequired = constraints['$exists']; + if (typeof constraints['$exists'] !== 'boolean') { + // The SDK will never submit a non-boolean for $exists, but if someone + // tries to submit a non-boolean for $exits outside the SDKs, just ignore it. + break; + } + if ( + (!propertyExists && existenceIsRequired) || + (propertyExists && !existenceIsRequired) + ) { return false; } - } - break; - case '$exists': { - const propertyExists = typeof object[key] !== 'undefined'; - const existenceIsRequired = constraints['$exists']; - if (typeof constraints['$exists'] !== 'boolean') { - // The SDK will never submit a non-boolean for $exists, but if someone - // tries to submit a non-boolean for $exits outside the SDKs, just ignore it. break; } - if ((!propertyExists && existenceIsRequired) || (propertyExists && !existenceIsRequired)) { - return false; - } - break; - } - case '$regex': - if (typeof compareTo === 'object') { - return compareTo.test(object[key]); - } - // JS doesn't support perl-style escaping - var expString = ''; - var escapeEnd = -2; - var escapeStart = compareTo.indexOf('\\Q'); - while (escapeStart > -1) { - // Add the unescaped portion - expString += compareTo.substring(escapeEnd + 2, escapeStart); - escapeEnd = compareTo.indexOf('\\E', escapeStart); - if (escapeEnd > -1) { - expString += compareTo.substring(escapeStart + 2, escapeEnd) - .replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&'); + case '$regex': + if (typeof compareTo === 'object') { + return compareTo.test(object[key]); } + // JS doesn't support perl-style escaping + var expString = ''; + var escapeEnd = -2; + var escapeStart = compareTo.indexOf('\\Q'); + while (escapeStart > -1) { + // Add the unescaped portion + expString += compareTo.substring(escapeEnd + 2, escapeStart); + escapeEnd = compareTo.indexOf('\\E', escapeStart); + if (escapeEnd > -1) { + expString += compareTo + .substring(escapeStart + 2, escapeEnd) + .replace(/\\\\\\\\E/g, '\\E') + .replace(/\W/g, '\\$&'); + } - escapeStart = compareTo.indexOf('\\Q', escapeEnd); - } - expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2)); - var exp = new RegExp(expString, constraints.$options || ''); - if (!exp.test(object[key])) { - return false; - } - break; - case '$nearSphere': - if (!compareTo || !object[key]) { - return false; - } - var distance = compareTo.radiansTo(object[key]); - var max = constraints.$maxDistance || Infinity; - return distance <= max; - case '$within': - if (!compareTo || !object[key]) { - return false; - } - var southWest = compareTo.$box[0]; - var northEast = compareTo.$box[1]; - if (southWest.latitude > northEast.latitude || - southWest.longitude > northEast.longitude) { - // Invalid box, crosses the date line - return false; - } - return ( - object[key].latitude > southWest.latitude && + escapeStart = compareTo.indexOf('\\Q', escapeEnd); + } + expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2)); + var exp = new RegExp(expString, constraints.$options || ''); + if (!exp.test(object[key])) { + return false; + } + break; + case '$nearSphere': + if (!compareTo || !object[key]) { + return false; + } + var distance = compareTo.radiansTo(object[key]); + var max = constraints.$maxDistance || Infinity; + return distance <= max; + case '$within': + if (!compareTo || !object[key]) { + return false; + } + var southWest = compareTo.$box[0]; + var northEast = compareTo.$box[1]; + if ( + southWest.latitude > northEast.latitude || + southWest.longitude > northEast.longitude + ) { + // Invalid box, crosses the date line + return false; + } + return ( + object[key].latitude > southWest.latitude && object[key].latitude < northEast.latitude && object[key].longitude > southWest.longitude && object[key].longitude < northEast.longitude - ); - case '$options': - // Not a query type, but a way to add options to $regex. Ignore and - // avoid the default - break; - case '$maxDistance': - // Not a query type, but a way to add a cap to $nearSphere. Ignore and - // avoid the default - break; - case '$select': - return false; - case '$dontSelect': - return false; - default: - return false; + ); + case '$options': + // Not a query type, but a way to add options to $regex. Ignore and + // avoid the default + break; + case '$maxDistance': + // Not a query type, but a way to add a cap to $nearSphere. Ignore and + // avoid the default + break; + case '$select': + return false; + case '$dontSelect': + return false; + default: + return false; } } return true; @@ -325,7 +345,7 @@ function matchesKeyConstraints(object, key, constraints) { var QueryTools = { queryHash: queryHash, - matchesQuery: matchesQuery + matchesQuery: matchesQuery, }; module.exports = QueryTools; diff --git a/src/LiveQuery/RequestSchema.js b/src/LiveQuery/RequestSchema.js index 05cfed3275..14cb2f046b 100644 --- a/src/LiveQuery/RequestSchema.js +++ b/src/LiveQuery/RequestSchema.js @@ -1,140 +1,144 @@ const general = { - 'title': 'General request schema', - 'type': 'object', - 'properties': { - 'op': { - 'type': 'string', - 'enum': ['connect', 'subscribe', 'unsubscribe', 'update'] + title: 'General request schema', + type: 'object', + properties: { + op: { + type: 'string', + enum: ['connect', 'subscribe', 'unsubscribe', 'update'], }, }, + required: ['op'], }; -const connect = { - 'title': 'Connect operation schema', - 'type': 'object', - 'properties': { - 'op': 'connect', - 'applicationId': { - 'type': 'string' +const connect = { + title: 'Connect operation schema', + type: 'object', + properties: { + op: 'connect', + applicationId: { + type: 'string', }, - 'javascriptKey': { - type: 'string' + javascriptKey: { + type: 'string', }, - 'masterKey': { - type: 'string' + masterKey: { + type: 'string', }, - 'clientKey': { - type: 'string' + clientKey: { + type: 'string', }, - 'windowsKey': { - type: 'string' + windowsKey: { + type: 'string', }, - 'restAPIKey': { - 'type': 'string' + restAPIKey: { + type: 'string', + }, + sessionToken: { + type: 'string', + }, + installationId: { + type: 'string', }, - 'sessionToken': { - 'type': 'string' - } }, - 'required': ['op', 'applicationId'], - "additionalProperties": false + required: ['op', 'applicationId'], + additionalProperties: false, }; const subscribe = { - 'title': 'Subscribe operation schema', - 'type': 'object', - 'properties': { - 'op': 'subscribe', - 'requestId': { - 'type': 'number' - }, - 'query': { - 'title': 'Query field schema', - 'type': 'object', - 'properties': { - 'className': { - 'type': 'string' + title: 'Subscribe operation schema', + type: 'object', + properties: { + op: 'subscribe', + requestId: { + type: 'number', + }, + query: { + title: 'Query field schema', + type: 'object', + properties: { + className: { + type: 'string', }, - 'where': { - 'type': 'object' + where: { + type: 'object', }, - 'fields': { - "type": "array", - "items": { - "type": "string" + fields: { + type: 'array', + items: { + type: 'string', }, - "minItems": 1, - "uniqueItems": true - } + minItems: 1, + uniqueItems: true, + }, }, - 'required': ['where', 'className'], - 'additionalProperties': false + required: ['where', 'className'], + additionalProperties: false, + }, + sessionToken: { + type: 'string', }, - 'sessionToken': { - 'type': 'string' - } }, - 'required': ['op', 'requestId', 'query'], - 'additionalProperties': false + required: ['op', 'requestId', 'query'], + additionalProperties: false, }; const update = { - 'title': 'Update operation schema', - 'type': 'object', - 'properties': { - 'op': 'update', - 'requestId': { - 'type': 'number' - }, - 'query': { - 'title': 'Query field schema', - 'type': 'object', - 'properties': { - 'className': { - 'type': 'string' + title: 'Update operation schema', + type: 'object', + properties: { + op: 'update', + requestId: { + type: 'number', + }, + query: { + title: 'Query field schema', + type: 'object', + properties: { + className: { + type: 'string', }, - 'where': { - 'type': 'object' + where: { + type: 'object', }, - 'fields': { - "type": "array", - "items": { - "type": "string" + fields: { + type: 'array', + items: { + type: 'string', }, - "minItems": 1, - "uniqueItems": true - } + minItems: 1, + uniqueItems: true, + }, }, - 'required': ['where', 'className'], - 'additionalProperties': false + required: ['where', 'className'], + additionalProperties: false, + }, + sessionToken: { + type: 'string', }, - 'sessionToken': { - 'type': 'string' - } }, - 'required': ['op', 'requestId', 'query'], - 'additionalProperties': false + required: ['op', 'requestId', 'query'], + additionalProperties: false, }; const unsubscribe = { - 'title': 'Unsubscribe operation schema', - 'type': 'object', - 'properties': { - 'op': 'unsubscribe', - 'requestId': { - 'type': 'number' - } + title: 'Unsubscribe operation schema', + type: 'object', + properties: { + op: 'unsubscribe', + requestId: { + type: 'number', + }, }, - 'required': ['op', 'requestId'], - "additionalProperties": false -} + required: ['op', 'requestId'], + additionalProperties: false, +}; const RequestSchema = { - 'general': general, - 'connect': connect, - 'subscribe': subscribe, - 'update': update, - 'unsubscribe': unsubscribe -} + general: general, + connect: connect, + subscribe: subscribe, + update: update, + unsubscribe: unsubscribe, +}; export default RequestSchema; diff --git a/src/LiveQuery/SessionTokenCache.js b/src/LiveQuery/SessionTokenCache.js index a38c951c79..282235ba88 100644 --- a/src/LiveQuery/SessionTokenCache.js +++ b/src/LiveQuery/SessionTokenCache.js @@ -2,48 +2,64 @@ import Parse from 'parse/node'; import LRU from 'lru-cache'; import logger from '../logger'; -function userForSessionToken(sessionToken){ - var q = new Parse.Query("_Session"); - q.equalTo("sessionToken", sessionToken); - return q.first({useMasterKey:true}).then(function(session){ - if(!session){ - return Parse.Promise.error("No session found for session token"); +function userForSessionToken(sessionToken) { + var q = new Parse.Query('_Session'); + q.equalTo('sessionToken', sessionToken); + return q.first({ useMasterKey: true }).then(function(session) { + if (!session) { + return Promise.reject('No session found for session token'); } - return session.get("user"); + return session.get('user'); }); } class SessionTokenCache { cache: Object; - constructor(timeout: number = 30 * 24 * 60 * 60 * 1000, maxSize: number = 10000) { + constructor( + timeout: number = 30 * 24 * 60 * 60 * 1000, + maxSize: number = 10000 + ) { this.cache = new LRU({ max: maxSize, - maxAge: timeout + maxAge: timeout, }); } getUserId(sessionToken: string): any { if (!sessionToken) { - return Parse.Promise.error('Empty sessionToken'); + return Promise.reject('Empty sessionToken'); } const userId = this.cache.get(sessionToken); if (userId) { - logger.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken); - return Parse.Promise.as(userId); + logger.verbose( + 'Fetch userId %s of sessionToken %s from Cache', + userId, + sessionToken + ); + return Promise.resolve(userId); } - return userForSessionToken(sessionToken).then((user) => { - logger.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken); - const userId = user.id; - this.cache.set(sessionToken, userId); - return Parse.Promise.as(userId); - }, (error) => { - logger.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error); - return Parse.Promise.error(error); - }); + return userForSessionToken(sessionToken).then( + user => { + logger.verbose( + 'Fetch userId %s of sessionToken %s from Parse', + user.id, + sessionToken + ); + const userId = user.id; + this.cache.set(sessionToken, userId); + return Promise.resolve(userId); + }, + error => { + logger.error( + 'Can not fetch userId for sessionToken %j, error %j', + sessionToken, + error + ); + return Promise.reject(error); + } + ); } } -export { - SessionTokenCache -} +export { SessionTokenCache }; diff --git a/src/LiveQuery/Subscription.js b/src/LiveQuery/Subscription.js index 53d3938748..7a88abc1b5 100644 --- a/src/LiveQuery/Subscription.js +++ b/src/LiveQuery/Subscription.js @@ -34,7 +34,11 @@ class Subscription { const index = requestIds.indexOf(requestId); if (index < 0) { - logger.error('Can not find client %d subscription %d to delete', clientId, requestId); + logger.error( + 'Can not find client %d subscription %d to delete', + clientId, + requestId + ); return; } requestIds.splice(index, 1); @@ -49,6 +53,4 @@ class Subscription { } } -export { - Subscription -} +export { Subscription }; diff --git a/src/LiveQuery/equalObjects.js b/src/LiveQuery/equalObjects.js index 931d392fd8..5bc3f5e957 100644 --- a/src/LiveQuery/equalObjects.js +++ b/src/LiveQuery/equalObjects.js @@ -9,14 +9,14 @@ function equalObjects(a, b) { return false; } if (typeof a !== 'object') { - return (a === b); + return a === b; } if (a === b) { return true; } if (toString.call(a) === '[object Date]') { if (toString.call(b) === '[object Date]') { - return (+a === +b); + return +a === +b; } return false; } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 70ffdb76a4..725002034c 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -2,387 +2,524 @@ **** GENERATED CODE **** This code has been generated by resources/buildConfigDefinitions.js Do not edit manually, but update Options/index.js -*/"use strict"; - -var parsers = require("./parsers"); +*/ +var parsers = require('./parsers'); module.exports.ParseServerOptions = { - "appId": { - "env": "PARSE_SERVER_APPLICATION_ID", - "help": "Your Parse Application ID", - "required": true - }, - "masterKey": { - "env": "PARSE_SERVER_MASTER_KEY", - "help": "Your Parse Master Key", - "required": true - }, - "serverURL": { - "env": "PARSE_SERVER_URL", - "help": "URL to your parse server with http:// or https://.", - "required": true - }, - "masterKeyIps": { - "env": "PARSE_SERVER_MASTER_KEY_IPS", - "help": "Restrict masterKey to be used by only these ips. defaults to [] (allow all ips)", - "action": parsers.arrayParser, - "default": [] - }, - "appName": { - "env": "PARSE_SERVER_APP_NAME", - "help": "Sets the app name" - }, - "analyticsAdapter": { - "env": "PARSE_SERVER_ANALYTICS_ADAPTER", - "help": "Adapter module for the analytics", - "action": parsers.moduleOrObjectParser - }, - "filesAdapter": { - "env": "PARSE_SERVER_FILES_ADAPTER", - "help": "Adapter module for the files sub-system", - "action": parsers.moduleOrObjectParser - }, - "push": { - "env": "PARSE_SERVER_PUSH", - "help": "Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications", - "action": parsers.objectParser - }, - "scheduledPush": { - "env": "PARSE_SERVER_SCHEDULED_PUSH", - "help": "Configuration for push scheduling. Defaults to false.", - "action": parsers.booleanParser, - "default": false - }, - "loggerAdapter": { - "env": "PARSE_SERVER_LOGGER_ADAPTER", - "help": "Adapter module for the logging sub-system", - "action": parsers.moduleOrObjectParser - }, - "jsonLogs": { - "env": "JSON_LOGS", - "help": "Log as structured JSON objects", - "action": parsers.booleanParser - }, - "logsFolder": { - "env": "PARSE_SERVER_LOGS_FOLDER", - "help": "Folder for the logs (defaults to './logs'); set to null to disable file based logging", - "default": "./logs" - }, - "verbose": { - "env": "VERBOSE", - "help": "Set the logging to verbose", - "action": parsers.booleanParser - }, - "logLevel": { - "env": "PARSE_SERVER_LOG_LEVEL", - "help": "Sets the level for logs" - }, - "silent": { - "env": "SILENT", - "help": "Disables console output", - "action": parsers.booleanParser - }, - "databaseURI": { - "env": "PARSE_SERVER_DATABASE_URI", - "help": "The full URI to your mongodb database", - "required": true, - "default": "mongodb://localhost:27017/parse" - }, - "databaseOptions": { - "env": "PARSE_SERVER_DATABASE_OPTIONS", - "help": "Options to pass to the mongodb client", - "action": parsers.objectParser - }, - "databaseAdapter": { - "env": "PARSE_SERVER_DATABASE_ADAPTER", - "help": "Adapter module for the database", - "action": parsers.moduleOrObjectParser - }, - "cloud": { - "env": "PARSE_SERVER_CLOUD", - "help": "Full path to your cloud code main.js" - }, - "collectionPrefix": { - "env": "PARSE_SERVER_COLLECTION_PREFIX", - "help": "A collection prefix for the classes", - "default": "" - }, - "clientKey": { - "env": "PARSE_SERVER_CLIENT_KEY", - "help": "Key for iOS, MacOS, tvOS clients" - }, - "javascriptKey": { - "env": "PARSE_SERVER_JAVASCRIPT_KEY", - "help": "Key for the Javascript SDK" - }, - "dotNetKey": { - "env": "PARSE_SERVER_DOT_NET_KEY", - "help": "Key for Unity and .Net SDK" - }, - "restAPIKey": { - "env": "PARSE_SERVER_REST_API_KEY", - "help": "Key for REST calls" - }, - "readOnlyMasterKey": { - "env": "PARSE_SERVER_READ_ONLY_MASTER_KEY", - "help": "Read-only key, which has the same capabilities as MasterKey without writes" - }, - "webhookKey": { - "env": "PARSE_SERVER_WEBHOOK_KEY", - "help": "Key sent with outgoing webhook calls" - }, - "fileKey": { - "env": "PARSE_SERVER_FILE_KEY", - "help": "Key for your files" - }, - "userSensitiveFields": { - "env": "PARSE_SERVER_USER_SENSITIVE_FIELDS", - "help": "Personally identifiable information fields in the user table the should be removed for non-authorized users.", - "action": parsers.arrayParser, - "default": ["email"] - }, - "enableAnonymousUsers": { - "env": "PARSE_SERVER_ENABLE_ANON_USERS", - "help": "Enable (or disable) anon users, defaults to true", - "action": parsers.booleanParser, - "default": true - }, - "allowClientClassCreation": { - "env": "PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION", - "help": "Enable (or disable) client class creation, defaults to true", - "action": parsers.booleanParser, - "default": true - }, - "auth": { - "env": "PARSE_SERVER_AUTH_PROVIDERS", - "help": "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication", - "action": parsers.objectParser - }, - "maxUploadSize": { - "env": "PARSE_SERVER_MAX_UPLOAD_SIZE", - "help": "Max file size for uploads. defaults to 20mb", - "default": "20mb" - }, - "verifyUserEmails": { - "env": "PARSE_SERVER_VERIFY_USER_EMAILS", - "help": "Enable (or disable) user email validation, defaults to false", - "action": parsers.booleanParser, - "default": false - }, - "preventLoginWithUnverifiedEmail": { - "env": "PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL", - "help": "Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false", - "action": parsers.booleanParser, - "default": false - }, - "emailVerifyTokenValidityDuration": { - "env": "PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION", - "help": "Email verification token validity duration", - "action": parsers.numberParser("emailVerifyTokenValidityDuration") - }, - "accountLockout": { - "env": "PARSE_SERVER_ACCOUNT_LOCKOUT", - "help": "account lockout policy for failed login attempts", - "action": parsers.objectParser - }, - "passwordPolicy": { - "env": "PARSE_SERVER_PASSWORD_POLICY", - "help": "Password policy for enforcing password related rules", - "action": parsers.objectParser - }, - "cacheAdapter": { - "env": "PARSE_SERVER_CACHE_ADAPTER", - "help": "Adapter module for the cache", - "action": parsers.moduleOrObjectParser - }, - "emailAdapter": { - "env": "PARSE_SERVER_EMAIL_ADAPTER", - "help": "Adapter module for the email sending", - "action": parsers.moduleOrObjectParser - }, - "publicServerURL": { - "env": "PARSE_PUBLIC_SERVER_URL", - "help": "Public URL to your parse server with http:// or https://." - }, - "customPages": { - "env": "PARSE_SERVER_CUSTOM_PAGES", - "help": "custom pages for password validation and reset", - "action": parsers.objectParser, - "default": {} - }, - "liveQuery": { - "env": "PARSE_SERVER_LIVE_QUERY", - "help": "parse-server's LiveQuery configuration object", - "action": parsers.objectParser - }, - "sessionLength": { - "env": "PARSE_SERVER_SESSION_LENGTH", - "help": "Session duration, in seconds, defaults to 1 year", - "action": parsers.numberParser("sessionLength"), - "default": 31536000 - }, - "maxLimit": { - "env": "PARSE_SERVER_MAX_LIMIT", - "help": "Max value for limit option on queries, defaults to unlimited", - "action": parsers.numberParser("maxLimit") - }, - "expireInactiveSessions": { - "env": "PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS", - "help": "Sets wether we should expire the inactive sessions, defaults to true", - "action": parsers.booleanParser, - "default": true - }, - "revokeSessionOnPasswordReset": { - "env": "PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET", - "help": "When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.", - "action": parsers.booleanParser, - "default": true - }, - "schemaCacheTTL": { - "env": "PARSE_SERVER_SCHEMA_CACHE_TTL", - "help": "The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.", - "action": parsers.numberParser("schemaCacheTTL"), - "default": 5000 - }, - "cacheTTL": { - "env": "PARSE_SERVER_CACHE_TTL", - "help": "Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)", - "action": parsers.numberParser("cacheTTL"), - "default": 5000 - }, - "cacheMaxSize": { - "env": "PARSE_SERVER_CACHE_MAX_SIZE", - "help": "Sets the maximum size for the in memory cache, defaults to 10000", - "action": parsers.numberParser("cacheMaxSize"), - "default": 10000 - }, - "enableSingleSchemaCache": { - "env": "PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE", - "help": "Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA. Defaults to false, i.e. unique schema cache per request.", - "action": parsers.booleanParser, - "default": false - }, - "objectIdSize": { - "env": "PARSE_SERVER_OBJECT_ID_SIZE", - "help": "Sets the number of characters in generated object id's, default 10", - "action": parsers.numberParser("objectIdSize"), - "default": 10 - }, - "port": { - "env": "PORT", - "help": "The port to run the ParseServer. defaults to 1337.", - "action": parsers.numberParser("port"), - "default": 1337 - }, - "host": { - "env": "PARSE_SERVER_HOST", - "help": "The host to serve ParseServer on. defaults to 0.0.0.0", - "default": "0.0.0.0" - }, - "mountPath": { - "env": "PARSE_SERVER_MOUNT_PATH", - "help": "Mount path for the server, defaults to /parse", - "default": "/parse" - }, - "cluster": { - "env": "PARSE_SERVER_CLUSTER", - "help": "Run with cluster, optionally set the number of processes default to os.cpus().length", - "action": parsers.numberOrBooleanParser - }, - "middleware": { - "env": "PARSE_SERVER_MIDDLEWARE", - "help": "middleware for express server, can be string or function" - }, - "startLiveQueryServer": { - "env": "PARSE_SERVER_START_LIVE_QUERY_SERVER", - "help": "Starts the liveQuery server", - "action": parsers.booleanParser - }, - "liveQueryServerOptions": { - "env": "PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS", - "help": "Live query server configuration options (will start the liveQuery server)", - "action": parsers.objectParser - } + accountLockout: { + env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', + help: 'account lockout policy for failed login attempts', + action: parsers.objectParser, + }, + allowClientClassCreation: { + env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION', + help: 'Enable (or disable) client class creation, defaults to true', + action: parsers.booleanParser, + default: true, + }, + allowCustomObjectId: { + env: 'PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID', + help: 'Enable (or disable) custom objectId', + action: parsers.booleanParser, + default: false, + }, + allowHeaders: { + env: 'PARSE_SERVER_ALLOW_HEADERS', + help: 'Add headers to Access-Control-Allow-Headers', + action: parsers.arrayParser, + }, + analyticsAdapter: { + env: 'PARSE_SERVER_ANALYTICS_ADAPTER', + help: 'Adapter module for the analytics', + action: parsers.moduleOrObjectParser, + }, + appId: { + env: 'PARSE_SERVER_APPLICATION_ID', + help: 'Your Parse Application ID', + required: true, + }, + appName: { + env: 'PARSE_SERVER_APP_NAME', + help: 'Sets the app name', + }, + auth: { + env: 'PARSE_SERVER_AUTH_PROVIDERS', + help: + 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', + action: parsers.objectParser, + }, + cacheAdapter: { + env: 'PARSE_SERVER_CACHE_ADAPTER', + help: 'Adapter module for the cache', + action: parsers.moduleOrObjectParser, + }, + cacheMaxSize: { + env: 'PARSE_SERVER_CACHE_MAX_SIZE', + help: 'Sets the maximum size for the in memory cache, defaults to 10000', + action: parsers.numberParser('cacheMaxSize'), + default: 10000, + }, + cacheTTL: { + env: 'PARSE_SERVER_CACHE_TTL', + help: + 'Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)', + action: parsers.numberParser('cacheTTL'), + default: 5000, + }, + clientKey: { + env: 'PARSE_SERVER_CLIENT_KEY', + help: 'Key for iOS, MacOS, tvOS clients', + }, + cloud: { + env: 'PARSE_SERVER_CLOUD', + help: 'Full path to your cloud code main.js', + }, + cluster: { + env: 'PARSE_SERVER_CLUSTER', + help: + 'Run with cluster, optionally set the number of processes default to os.cpus().length', + action: parsers.numberOrBooleanParser, + }, + collectionPrefix: { + env: 'PARSE_SERVER_COLLECTION_PREFIX', + help: 'A collection prefix for the classes', + default: '', + }, + customPages: { + env: 'PARSE_SERVER_CUSTOM_PAGES', + help: 'custom pages for password validation and reset', + action: parsers.objectParser, + default: {}, + }, + databaseAdapter: { + env: 'PARSE_SERVER_DATABASE_ADAPTER', + help: 'Adapter module for the database', + action: parsers.moduleOrObjectParser, + }, + databaseOptions: { + env: 'PARSE_SERVER_DATABASE_OPTIONS', + help: 'Options to pass to the mongodb client', + action: parsers.objectParser, + }, + databaseURI: { + env: 'PARSE_SERVER_DATABASE_URI', + help: + 'The full URI to your database. Supported databases are mongodb or postgres.', + required: true, + default: 'mongodb://localhost:27017/parse', + }, + directAccess: { + env: 'PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS', + help: + 'Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production.', + action: parsers.booleanParser, + default: false, + }, + dotNetKey: { + env: 'PARSE_SERVER_DOT_NET_KEY', + help: 'Key for Unity and .Net SDK', + }, + emailAdapter: { + env: 'PARSE_SERVER_EMAIL_ADAPTER', + help: 'Adapter module for email sending', + action: parsers.moduleOrObjectParser, + }, + emailVerifyTokenValidityDuration: { + env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION', + help: 'Email verification token validity duration, in seconds', + action: parsers.numberParser('emailVerifyTokenValidityDuration'), + }, + enableAnonymousUsers: { + env: 'PARSE_SERVER_ENABLE_ANON_USERS', + help: 'Enable (or disable) anon users, defaults to true', + action: parsers.booleanParser, + default: true, + }, + enableExpressErrorHandler: { + env: 'PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER', + help: 'Enables the default express error handler for all errors', + action: parsers.booleanParser, + default: false, + }, + enableSingleSchemaCache: { + env: 'PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE', + help: + 'Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request.', + action: parsers.booleanParser, + default: false, + }, + expireInactiveSessions: { + env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', + help: + 'Sets wether we should expire the inactive sessions, defaults to true', + action: parsers.booleanParser, + default: true, + }, + fileKey: { + env: 'PARSE_SERVER_FILE_KEY', + help: 'Key for your files', + }, + filesAdapter: { + env: 'PARSE_SERVER_FILES_ADAPTER', + help: 'Adapter module for the files sub-system', + action: parsers.moduleOrObjectParser, + }, + graphQLPath: { + env: 'PARSE_SERVER_GRAPHQL_PATH', + help: 'Mount path for the GraphQL endpoint, defaults to /graphql', + default: '/graphql', + }, + graphQLSchema: { + env: 'PARSE_SERVER_GRAPH_QLSCHEMA', + help: 'Full path to your GraphQL custom schema.graphql file', + }, + host: { + env: 'PARSE_SERVER_HOST', + help: 'The host to serve ParseServer on, defaults to 0.0.0.0', + default: '0.0.0.0', + }, + javascriptKey: { + env: 'PARSE_SERVER_JAVASCRIPT_KEY', + help: 'Key for the Javascript SDK', + }, + jsonLogs: { + env: 'JSON_LOGS', + help: 'Log as structured JSON objects', + action: parsers.booleanParser, + }, + liveQuery: { + env: 'PARSE_SERVER_LIVE_QUERY', + help: "parse-server's LiveQuery configuration object", + action: parsers.objectParser, + }, + liveQueryServerOptions: { + env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS', + help: + 'Live query server configuration options (will start the liveQuery server)', + action: parsers.objectParser, + }, + loggerAdapter: { + env: 'PARSE_SERVER_LOGGER_ADAPTER', + help: 'Adapter module for the logging sub-system', + action: parsers.moduleOrObjectParser, + }, + logLevel: { + env: 'PARSE_SERVER_LOG_LEVEL', + help: 'Sets the level for logs', + }, + logsFolder: { + env: 'PARSE_SERVER_LOGS_FOLDER', + help: + "Folder for the logs (defaults to './logs'); set to null to disable file based logging", + default: './logs', + }, + masterKey: { + env: 'PARSE_SERVER_MASTER_KEY', + help: 'Your Parse Master Key', + required: true, + }, + masterKeyIps: { + env: 'PARSE_SERVER_MASTER_KEY_IPS', + help: + 'Restrict masterKey to be used by only these ips, defaults to [] (allow all ips)', + action: parsers.arrayParser, + default: [], + }, + maxLimit: { + env: 'PARSE_SERVER_MAX_LIMIT', + help: 'Max value for limit option on queries, defaults to unlimited', + action: parsers.numberParser('maxLimit'), + }, + maxLogFiles: { + env: 'PARSE_SERVER_MAX_LOG_FILES', + help: + "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", + action: parsers.objectParser, + }, + maxUploadSize: { + env: 'PARSE_SERVER_MAX_UPLOAD_SIZE', + help: 'Max file size for uploads, defaults to 20mb', + default: '20mb', + }, + middleware: { + env: 'PARSE_SERVER_MIDDLEWARE', + help: 'middleware for express server, can be string or function', + }, + mountGraphQL: { + env: 'PARSE_SERVER_MOUNT_GRAPHQL', + help: 'Mounts the GraphQL endpoint', + action: parsers.booleanParser, + default: false, + }, + mountPath: { + env: 'PARSE_SERVER_MOUNT_PATH', + help: 'Mount path for the server, defaults to /parse', + default: '/parse', + }, + mountPlayground: { + env: 'PARSE_SERVER_MOUNT_PLAYGROUND', + help: 'Mounts the GraphQL Playground - never use this option in production', + action: parsers.booleanParser, + default: false, + }, + objectIdSize: { + env: 'PARSE_SERVER_OBJECT_ID_SIZE', + help: "Sets the number of characters in generated object id's, default 10", + action: parsers.numberParser('objectIdSize'), + default: 10, + }, + passwordPolicy: { + env: 'PARSE_SERVER_PASSWORD_POLICY', + help: 'Password policy for enforcing password related rules', + action: parsers.objectParser, + }, + playgroundPath: { + env: 'PARSE_SERVER_PLAYGROUND_PATH', + help: 'Mount path for the GraphQL Playground, defaults to /playground', + default: '/playground', + }, + port: { + env: 'PORT', + help: 'The port to run the ParseServer, defaults to 1337.', + action: parsers.numberParser('port'), + default: 1337, + }, + preserveFileName: { + env: 'PARSE_SERVER_PRESERVE_FILE_NAME', + help: 'Enable (or disable) the addition of a unique hash to the file names', + action: parsers.booleanParser, + default: false, + }, + preventLoginWithUnverifiedEmail: { + env: 'PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL', + help: + 'Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false', + action: parsers.booleanParser, + default: false, + }, + protectedFields: { + env: 'PARSE_SERVER_PROTECTED_FIELDS', + help: + 'Protected fields that should be treated with extra security when fetching details.', + action: parsers.objectParser, + default: { + _User: { + '*': ['email'], + }, + }, + }, + publicServerURL: { + env: 'PARSE_PUBLIC_SERVER_URL', + help: 'Public URL to your parse server with http:// or https://.', + }, + push: { + env: 'PARSE_SERVER_PUSH', + help: + 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', + action: parsers.objectParser, + }, + readOnlyMasterKey: { + env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', + help: + 'Read-only key, which has the same capabilities as MasterKey without writes', + }, + restAPIKey: { + env: 'PARSE_SERVER_REST_API_KEY', + help: 'Key for REST calls', + }, + revokeSessionOnPasswordReset: { + env: 'PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET', + help: + "When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.", + action: parsers.booleanParser, + default: true, + }, + scheduledPush: { + env: 'PARSE_SERVER_SCHEDULED_PUSH', + help: 'Configuration for push scheduling, defaults to false.', + action: parsers.booleanParser, + default: false, + }, + schemaCacheTTL: { + env: 'PARSE_SERVER_SCHEMA_CACHE_TTL', + help: + 'The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.', + action: parsers.numberParser('schemaCacheTTL'), + default: 5000, + }, + serverCloseComplete: { + env: 'PARSE_SERVER_SERVER_CLOSE_COMPLETE', + help: 'Callback when server has closed', + }, + serverStartComplete: { + env: 'PARSE_SERVER_SERVER_START_COMPLETE', + help: 'Callback when server has started', + }, + serverURL: { + env: 'PARSE_SERVER_URL', + help: 'URL to your parse server with http:// or https://.', + required: true, + }, + sessionLength: { + env: 'PARSE_SERVER_SESSION_LENGTH', + help: 'Session duration, in seconds, defaults to 1 year', + action: parsers.numberParser('sessionLength'), + default: 31536000, + }, + silent: { + env: 'SILENT', + help: 'Disables console output', + action: parsers.booleanParser, + }, + startLiveQueryServer: { + env: 'PARSE_SERVER_START_LIVE_QUERY_SERVER', + help: 'Starts the liveQuery server', + action: parsers.booleanParser, + }, + userSensitiveFields: { + env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', + help: + 'Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields', + action: parsers.arrayParser, + }, + verbose: { + env: 'VERBOSE', + help: 'Set the logging to verbose', + action: parsers.booleanParser, + }, + verifyUserEmails: { + env: 'PARSE_SERVER_VERIFY_USER_EMAILS', + help: 'Enable (or disable) user email validation, defaults to false', + action: parsers.booleanParser, + default: false, + }, + webhookKey: { + env: 'PARSE_SERVER_WEBHOOK_KEY', + help: 'Key sent with outgoing webhook calls', + }, }; module.exports.CustomPagesOptions = { - "invalidLink": { - "env": "PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK", - "help": "invalid link page path" - }, - "verifyEmailSuccess": { - "env": "PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS", - "help": "verify email success page path" - }, - "choosePassword": { - "env": "PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD", - "help": "choose password page path" - }, - "passwordResetSuccess": { - "env": "PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS", - "help": "password reset success page path" - } + choosePassword: { + env: 'PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD', + help: 'choose password page path', + }, + invalidLink: { + env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK', + help: 'invalid link page path', + }, + invalidVerificationLink: { + env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK', + help: 'invalid verification link page path', + }, + linkSendFail: { + env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_FAIL', + help: 'verification link send fail page path', + }, + linkSendSuccess: { + env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_SUCCESS', + help: 'verification link send success page path', + }, + parseFrameURL: { + env: 'PARSE_SERVER_CUSTOM_PAGES_PARSE_FRAME_URL', + help: 'for masking user-facing pages', + }, + passwordResetSuccess: { + env: 'PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS', + help: 'password reset success page path', + }, + verifyEmailSuccess: { + env: 'PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS', + help: 'verify email success page path', + }, }; module.exports.LiveQueryOptions = { - "classNames": { - "env": "PARSE_SERVER_LIVEQUERY_CLASSNAMES", - "help": "parse-server's LiveQuery classNames", - "action": parsers.arrayParser - }, - "redisURL": { - "env": "PARSE_SERVER_LIVEQUERY_REDIS_URL", - "help": "parse-server's LiveQuery redisURL" - }, - "pubSubAdapter": { - "env": "PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER", - "help": "LiveQuery pubsub adapter", - "action": parsers.moduleOrObjectParser - } + classNames: { + env: 'PARSE_SERVER_LIVEQUERY_CLASSNAMES', + help: "parse-server's LiveQuery classNames", + action: parsers.arrayParser, + }, + pubSubAdapter: { + env: 'PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER', + help: 'LiveQuery pubsub adapter', + action: parsers.moduleOrObjectParser, + }, + redisOptions: { + env: 'PARSE_SERVER_LIVEQUERY_REDIS_OPTIONS', + help: "parse-server's LiveQuery redisOptions", + action: parsers.objectParser, + }, + redisURL: { + env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL', + help: "parse-server's LiveQuery redisURL", + }, + wssAdapter: { + env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER', + help: 'Adapter module for the WebSocketServer', + action: parsers.moduleOrObjectParser, + }, }; module.exports.LiveQueryServerOptions = { - "appId": { - "env": "PARSE_LIVE_QUERY_SERVER_APP_ID", - "help": "This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId." - }, - "masterKey": { - "env": "PARSE_LIVE_QUERY_SERVER_MASTER_KEY", - "help": "This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey." - }, - "serverURL": { - "env": "PARSE_LIVE_QUERY_SERVER_SERVER_URL", - "help": "This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL." - }, - "keyPairs": { - "env": "PARSE_LIVE_QUERY_SERVER_KEY_PAIRS", - "help": "A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.", - "action": parsers.objectParser - }, - "websocketTimeout": { - "env": "PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT", - "help": "Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients. Defaults to 10 * 1000 ms (10 s).", - "action": parsers.numberParser("websocketTimeout") - }, - "cacheTimeout": { - "env": "PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT", - "help": "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details. Defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", - "action": parsers.numberParser("cacheTimeout") - }, - "logLevel": { - "env": "PARSE_LIVE_QUERY_SERVER_LOG_LEVEL", - "help": "This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE. Defaults to INFO." - }, - "port": { - "env": "PARSE_LIVE_QUERY_SERVER_PORT", - "help": "The port to run the ParseServer. defaults to 1337.", - "action": parsers.numberParser("port"), - "default": 1337 - }, - "redisURL": { - "env": "PARSE_LIVE_QUERY_SERVER_REDIS_URL", - "help": "parse-server's LiveQuery redisURL" - }, - "pubSubAdapter": { - "env": "PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER", - "help": "LiveQuery pubsub adapter", - "action": parsers.moduleOrObjectParser - } + appId: { + env: 'PARSE_LIVE_QUERY_SERVER_APP_ID', + help: + 'This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId.', + }, + cacheTimeout: { + env: 'PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT', + help: + "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", + action: parsers.numberParser('cacheTimeout'), + }, + keyPairs: { + env: 'PARSE_LIVE_QUERY_SERVER_KEY_PAIRS', + help: + 'A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.', + action: parsers.objectParser, + }, + logLevel: { + env: 'PARSE_LIVE_QUERY_SERVER_LOG_LEVEL', + help: + 'This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO.', + }, + masterKey: { + env: 'PARSE_LIVE_QUERY_SERVER_MASTER_KEY', + help: + 'This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey.', + }, + port: { + env: 'PARSE_LIVE_QUERY_SERVER_PORT', + help: 'The port to run the LiveQuery server, defaults to 1337.', + action: parsers.numberParser('port'), + default: 1337, + }, + pubSubAdapter: { + env: 'PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER', + help: 'LiveQuery pubsub adapter', + action: parsers.moduleOrObjectParser, + }, + redisOptions: { + env: 'PARSE_LIVE_QUERY_SERVER_REDIS_OPTIONS', + help: "parse-server's LiveQuery redisOptions", + action: parsers.objectParser, + }, + redisURL: { + env: 'PARSE_LIVE_QUERY_SERVER_REDIS_URL', + help: "parse-server's LiveQuery redisURL", + }, + serverURL: { + env: 'PARSE_LIVE_QUERY_SERVER_SERVER_URL', + help: + 'This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL.', + }, + websocketTimeout: { + env: 'PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT', + help: + 'Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).', + action: parsers.numberParser('websocketTimeout'), + }, + wssAdapter: { + env: 'PARSE_LIVE_QUERY_SERVER_WSS_ADAPTER', + help: 'Adapter module for the WebSocketServer', + action: parsers.moduleOrObjectParser, + }, }; diff --git a/src/Options/docs.js b/src/Options/docs.js new file mode 100644 index 0000000000..9e5b6ac8f8 --- /dev/null +++ b/src/Options/docs.js @@ -0,0 +1,112 @@ +/** + * @interface ParseServerOptions + * @property {Any} accountLockout account lockout policy for failed login attempts + * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to true + * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId + * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers + * @property {Adapter} analyticsAdapter Adapter module for the analytics + * @property {String} appId Your Parse Application ID + * @property {String} appName Sets the app name + * @property {Any} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + * @property {Adapter} cacheAdapter Adapter module for the cache + * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 + * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) + * @property {String} clientKey Key for iOS, MacOS, tvOS clients + * @property {String} cloud Full path to your cloud code main.js + * @property {Number|Boolean} cluster Run with cluster, optionally set the number of processes default to os.cpus().length + * @property {String} collectionPrefix A collection prefix for the classes + * @property {CustomPagesOptions} customPages custom pages for password validation and reset + * @property {Adapter} databaseAdapter Adapter module for the database + * @property {Any} databaseOptions Options to pass to the mongodb client + * @property {String} databaseURI The full URI to your database. Supported databases are mongodb or postgres. + * @property {Boolean} directAccess Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production. + * @property {String} dotNetKey Key for Unity and .Net SDK + * @property {Adapter} emailAdapter Adapter module for email sending + * @property {Number} emailVerifyTokenValidityDuration Email verification token validity duration, in seconds + * @property {Boolean} enableAnonymousUsers Enable (or disable) anon users, defaults to true + * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors + * @property {Boolean} enableSingleSchemaCache Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request. + * @property {Boolean} expireInactiveSessions Sets wether we should expire the inactive sessions, defaults to true + * @property {String} fileKey Key for your files + * @property {Adapter} filesAdapter Adapter module for the files sub-system + * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql + * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file + * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 + * @property {String} javascriptKey Key for the Javascript SDK + * @property {Boolean} jsonLogs Log as structured JSON objects + * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object + * @property {LiveQueryServerOptions} liveQueryServerOptions Live query server configuration options (will start the liveQuery server) + * @property {Adapter} loggerAdapter Adapter module for the logging sub-system + * @property {String} logLevel Sets the level for logs + * @property {String} logsFolder Folder for the logs (defaults to './logs'); set to null to disable file based logging + * @property {String} masterKey Your Parse Master Key + * @property {String[]} masterKeyIps Restrict masterKey to be used by only these ips, defaults to [] (allow all ips) + * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited + * @property {Number|String} maxLogFiles Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) + * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb + * @property {Union} middleware middleware for express server, can be string or function + * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint + * @property {String} mountPath Mount path for the server, defaults to /parse + * @property {Boolean} mountPlayground Mounts the GraphQL Playground - never use this option in production + * @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10 + * @property {Any} passwordPolicy Password policy for enforcing password related rules + * @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground + * @property {Number} port The port to run the ParseServer, defaults to 1337. + * @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names + * @property {Boolean} preventLoginWithUnverifiedEmail Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false + * @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. + * @property {String} publicServerURL Public URL to your parse server with http:// or https://. + * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications + * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {String} restAPIKey Key for REST calls + * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. + * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. + * @property {Number} schemaCacheTTL The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable. + * @property {Function} serverCloseComplete Callback when server has closed + * @property {Function} serverStartComplete Callback when server has started + * @property {String} serverURL URL to your parse server with http:// or https://. + * @property {Number} sessionLength Session duration, in seconds, defaults to 1 year + * @property {Boolean} silent Disables console output + * @property {Boolean} startLiveQueryServer Starts the liveQuery server + * @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields + * @property {Boolean} verbose Set the logging to verbose + * @property {Boolean} verifyUserEmails Enable (or disable) user email validation, defaults to false + * @property {String} webhookKey Key sent with outgoing webhook calls + */ + +/** + * @interface CustomPagesOptions + * @property {String} choosePassword choose password page path + * @property {String} invalidLink invalid link page path + * @property {String} invalidVerificationLink invalid verification link page path + * @property {String} linkSendFail verification link send fail page path + * @property {String} linkSendSuccess verification link send success page path + * @property {String} parseFrameURL for masking user-facing pages + * @property {String} passwordResetSuccess password reset success page path + * @property {String} verifyEmailSuccess verify email success page path + */ + +/** + * @interface LiveQueryOptions + * @property {String[]} classNames parse-server's LiveQuery classNames + * @property {Adapter} pubSubAdapter LiveQuery pubsub adapter + * @property {Any} redisOptions parse-server's LiveQuery redisOptions + * @property {String} redisURL parse-server's LiveQuery redisURL + * @property {Adapter} wssAdapter Adapter module for the WebSocketServer + */ + +/** + * @interface LiveQueryServerOptions + * @property {String} appId This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId. + * @property {Number} cacheTimeout Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days). + * @property {Any} keyPairs A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details. + * @property {String} logLevel This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO. + * @property {String} masterKey This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey. + * @property {Number} port The port to run the LiveQuery server, defaults to 1337. + * @property {Adapter} pubSubAdapter LiveQuery pubsub adapter + * @property {Any} redisOptions parse-server's LiveQuery redisOptions + * @property {String} redisURL parse-server's LiveQuery redisURL + * @property {String} serverURL This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL. + * @property {Number} websocketTimeout Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s). + * @property {Adapter} wssAdapter Adapter module for the WebSocketServer + */ diff --git a/src/Options/index.js b/src/Options/index.js index d21e715270..3aa5b7fc6b 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -1,6 +1,17 @@ +import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; +import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter'; +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import { CacheAdapter } from '../Adapters/Cache/CacheAdapter'; +import { MailAdapter } from '../Adapters/Email/MailAdapter'; +import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter'; +import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; + // @flow -type Adapter = string|any; -type NumberOrBoolean = number|boolean; +type Adapter = string | any | T; +type NumberOrBoolean = number | boolean; +type NumberOrString = number | string; +type ProtectedFields = any; export interface ParseServerOptions { /* Your Parse Application ID @@ -11,44 +22,53 @@ export interface ParseServerOptions { /* URL to your parse server with http:// or https://. :ENV: PARSE_SERVER_URL */ serverURL: string; - /* Restrict masterKey to be used by only these ips. defaults to [] (allow all ips) */ - masterKeyIps: ?string[]; // = [] + /* Restrict masterKey to be used by only these ips, defaults to [] (allow all ips) + :DEFAULT: [] */ + masterKeyIps: ?(string[]); /* Sets the app name */ appName: ?string; + /* Add headers to Access-Control-Allow-Headers */ + allowHeaders: ?(string[]); /* Adapter module for the analytics */ - analyticsAdapter: ?Adapter; + analyticsAdapter: ?Adapter; /* Adapter module for the files sub-system */ - filesAdapter: ?Adapter; + filesAdapter: ?Adapter; /* Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications */ push: ?any; - /* Configuration for push scheduling. Defaults to false. */ - scheduledPush: ?boolean; // = false + /* Configuration for push scheduling, defaults to false. + :DEFAULT: false */ + scheduledPush: ?boolean; /* Adapter module for the logging sub-system */ - loggerAdapter: ?Adapter; + loggerAdapter: ?Adapter; /* Log as structured JSON objects :ENV: JSON_LOGS */ jsonLogs: ?boolean; /* Folder for the logs (defaults to './logs'); set to null to disable file based logging - :ENV: PARSE_SERVER_LOGS_FOLDER */ - logsFolder: ?string; // = ./logs + :ENV: PARSE_SERVER_LOGS_FOLDER + :DEFAULT: ./logs */ + logsFolder: ?string; /* Set the logging to verbose :ENV: VERBOSE */ verbose: ?boolean; /* Sets the level for logs */ logLevel: ?string; + /* Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) */ + maxLogFiles: ?NumberOrString; /* Disables console output :ENV: SILENT */ silent: ?boolean; - /* The full URI to your mongodb database */ - databaseURI: string; // = mongodb://localhost:27017/parse + /* The full URI to your database. Supported databases are mongodb or postgres. + :DEFAULT: mongodb://localhost:27017/parse */ + databaseURI: string; /* Options to pass to the mongodb client */ databaseOptions: ?any; /* Adapter module for the database */ - databaseAdapter: ?Adapter; + databaseAdapter: ?Adapter; /* Full path to your cloud code main.js */ cloud: ?string; - /* A collection prefix for the classes */ - collectionPrefix: ?string; // = '' + /* A collection prefix for the classes + :DEFAULT: '' */ + collectionPrefix: ?string; /* Key for iOS, MacOS, tvOS clients */ clientKey: ?string; /* Key for the Javascript SDK */ @@ -64,75 +84,130 @@ export interface ParseServerOptions { webhookKey: ?string; /* Key for your files */ fileKey: ?string; - /* Personally identifiable information fields in the user table the should be removed for non-authorized users. */ - userSensitiveFields: ?string[]; // = ["email"] + /* Enable (or disable) the addition of a unique hash to the file names + :ENV: PARSE_SERVER_PRESERVE_FILE_NAME + :DEFAULT: false */ + preserveFileName: ?boolean; + /* Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields */ + userSensitiveFields: ?(string[]); + /* Protected fields that should be treated with extra security when fetching details. + :DEFAULT: {"_User": {"*": ["email"]}} */ + protectedFields: ?ProtectedFields; /* Enable (or disable) anon users, defaults to true - :ENV: PARSE_SERVER_ENABLE_ANON_USERS */ - enableAnonymousUsers: ?boolean; // = true + :ENV: PARSE_SERVER_ENABLE_ANON_USERS + :DEFAULT: true */ + enableAnonymousUsers: ?boolean; /* Enable (or disable) client class creation, defaults to true - :ENV: PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION */ - allowClientClassCreation: ?boolean; // = true + :ENV: PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION + :DEFAULT: true */ + allowClientClassCreation: ?boolean; + /* Enable (or disable) custom objectId + :ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID + :DEFAULT: false */ + allowCustomObjectId: ?boolean; /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication :ENV: PARSE_SERVER_AUTH_PROVIDERS */ auth: ?any; - /* Max file size for uploads. defaults to 20mb */ - maxUploadSize: ?string; // = 20mb - /* Enable (or disable) user email validation, defaults to false */ - verifyUserEmails: ?boolean; // = false - /* Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false */ - preventLoginWithUnverifiedEmail: ?boolean; // = false - /* Email verification token validity duration */ + /* Max file size for uploads, defaults to 20mb + :DEFAULT: 20mb */ + maxUploadSize: ?string; + /* Enable (or disable) user email validation, defaults to false + :DEFAULT: false */ + verifyUserEmails: ?boolean; + /* Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false + :DEFAULT: false */ + preventLoginWithUnverifiedEmail: ?boolean; + /* Email verification token validity duration, in seconds */ emailVerifyTokenValidityDuration: ?number; /* account lockout policy for failed login attempts */ accountLockout: ?any; /* Password policy for enforcing password related rules */ passwordPolicy: ?any; /* Adapter module for the cache */ - cacheAdapter: ?Adapter; - /* Adapter module for the email sending */ - emailAdapter: ?Adapter; + cacheAdapter: ?Adapter; + /* Adapter module for email sending */ + emailAdapter: ?Adapter; /* Public URL to your parse server with http:// or https://. :ENV: PARSE_PUBLIC_SERVER_URL */ publicServerURL: ?string; - /* custom pages for password validation and reset */ - customPages: ?CustomPagesOptions; // = {} + /* custom pages for password validation and reset + :DEFAULT: {} */ + customPages: ?CustomPagesOptions; /* parse-server's LiveQuery configuration object */ liveQuery: ?LiveQueryOptions; - /* Session duration, in seconds, defaults to 1 year */ - sessionLength: ?number; // = 31536000 + /* Session duration, in seconds, defaults to 1 year + :DEFAULT: 31536000 */ + sessionLength: ?number; /* Max value for limit option on queries, defaults to unlimited */ maxLimit: ?number; - /* Sets wether we should expire the inactive sessions, defaults to true */ - expireInactiveSessions: ?boolean; // = true - /* When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. */ - revokeSessionOnPasswordReset: ?boolean; // = true - /* The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable. */ - schemaCacheTTL: ?number; // = 5000 - /* Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) */ - cacheTTL: ?number; // = 5000 - /* Sets the maximum size for the in memory cache, defaults to 10000 */ - cacheMaxSize : ?number; // = 10000 - /* Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA. Defaults to false, i.e. unique schema cache per request. */ - enableSingleSchemaCache: ?boolean; // = false - /* Sets the number of characters in generated object id's, default 10 */ - objectIdSize: ?number; // = 10 - /* The port to run the ParseServer. defaults to 1337. - :ENV: PORT */ - port: ?number; // = 1337 - /* The host to serve ParseServer on. defaults to 0.0.0.0 */ - host: ?string; // = 0.0.0.0 - /* Mount path for the server, defaults to /parse */ - mountPath: ?string; // = /parse + /* Sets wether we should expire the inactive sessions, defaults to true + :DEFAULT: true */ + expireInactiveSessions: ?boolean; + /* When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. + :DEFAULT: true */ + revokeSessionOnPasswordReset: ?boolean; + /* The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable. + :DEFAULT: 5000 */ + schemaCacheTTL: ?number; + /* Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) + :DEFAULT: 5000 */ + cacheTTL: ?number; + /* Sets the maximum size for the in memory cache, defaults to 10000 + :DEFAULT: 10000 */ + cacheMaxSize: ?number; + /* Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production. + :ENV: PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS + :DEFAULT: false */ + directAccess: ?boolean; + /* Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request. + :DEFAULT: false */ + enableSingleSchemaCache: ?boolean; + /* Enables the default express error handler for all errors + :DEFAULT: false */ + enableExpressErrorHandler: ?boolean; + /* Sets the number of characters in generated object id's, default 10 + :DEFAULT: 10 */ + objectIdSize: ?number; + /* The port to run the ParseServer, defaults to 1337. + :ENV: PORT + :DEFAULT: 1337 */ + port: ?number; + /* The host to serve ParseServer on, defaults to 0.0.0.0 + :DEFAULT: 0.0.0.0 */ + host: ?string; + /* Mount path for the server, defaults to /parse + :DEFAULT: /parse */ + mountPath: ?string; /* Run with cluster, optionally set the number of processes default to os.cpus().length */ cluster: ?NumberOrBoolean; /* middleware for express server, can be string or function */ - middleware: ?((()=>void)|string); + middleware: ?((() => void) | string); /* Starts the liveQuery server */ startLiveQueryServer: ?boolean; /* Live query server configuration options (will start the liveQuery server) */ liveQueryServerOptions: ?LiveQueryServerOptions; - - __indexBuildCompletionCallbackForTests: ?()=>void; + /* Full path to your GraphQL custom schema.graphql file */ + graphQLSchema: ?string; + /* Mounts the GraphQL endpoint + :ENV: PARSE_SERVER_MOUNT_GRAPHQL + :DEFAULT: false */ + mountGraphQL: ?boolean; + /* Mount path for the GraphQL endpoint, defaults to /graphql + :ENV: PARSE_SERVER_GRAPHQL_PATH + :DEFAULT: /graphql */ + graphQLPath: ?string; + /* Mounts the GraphQL Playground - never use this option in production + :ENV: PARSE_SERVER_MOUNT_PLAYGROUND + :DEFAULT: false */ + mountPlayground: ?boolean; + /* Mount path for the GraphQL Playground, defaults to /playground + :ENV: PARSE_SERVER_PLAYGROUND_PATH + :DEFAULT: /playground */ + playgroundPath: ?string; + /* Callback when server has started */ + serverStartComplete: ?(error: ?Error) => void; + /* Callback when server has closed */ + serverCloseComplete: ?() => void; } export interface CustomPagesOptions { @@ -140,41 +215,58 @@ export interface CustomPagesOptions { invalidLink: ?string; /* verify email success page path */ verifyEmailSuccess: ?string; + /* invalid verification link page path */ + invalidVerificationLink: ?string; + /* verification link send success page path */ + linkSendSuccess: ?string; + /* verification link send fail page path */ + linkSendFail: ?string; /* choose password page path */ choosePassword: ?string; /* password reset success page path */ passwordResetSuccess: ?string; + /* for masking user-facing pages */ + parseFrameURL: ?string; } export interface LiveQueryOptions { /* parse-server's LiveQuery classNames :ENV: PARSE_SERVER_LIVEQUERY_CLASSNAMES */ - classNames: ?string[], + classNames: ?(string[]); + /* parse-server's LiveQuery redisOptions */ + redisOptions: ?any; /* parse-server's LiveQuery redisURL */ - redisURL: ?string, + redisURL: ?string; /* LiveQuery pubsub adapter */ - pubSubAdapter: ?Adapter, + pubSubAdapter: ?Adapter; + /* Adapter module for the WebSocketServer */ + wssAdapter: ?Adapter; } export interface LiveQueryServerOptions { /* This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId.*/ - appId: ?string, + appId: ?string; /* This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey.*/ - masterKey: ?string, + masterKey: ?string; /* This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL.*/ - serverURL: ?string, + serverURL: ?string; /* A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.*/ - keyPairs: ?any, - /* Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients. Defaults to 10 * 1000 ms (10 s).*/ - websocketTimeout: ?number, - /* Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details. Defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).*/ - cacheTimeout: ?number, - /* This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE. Defaults to INFO.*/ - logLevel: ?string, - /* The port to run the ParseServer. defaults to 1337.*/ - port: ?number, // = 1337 + keyPairs: ?any; + /* Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).*/ + websocketTimeout: ?number; + /* Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).*/ + cacheTimeout: ?number; + /* This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO.*/ + logLevel: ?string; + /* The port to run the LiveQuery server, defaults to 1337. + :DEFAULT: 1337 */ + port: ?number; + /* parse-server's LiveQuery redisOptions */ + redisOptions: ?any; /* parse-server's LiveQuery redisURL */ - redisURL: ?string, + redisURL: ?string; /* LiveQuery pubsub adapter */ - pubSubAdapter: ?Adapter, + pubSubAdapter: ?Adapter; + /* Adapter module for the WebSocketServer */ + wssAdapter: ?Adapter; } diff --git a/src/Options/parsers.js b/src/Options/parsers.js index 752a27f8f7..e03fcaaa03 100644 --- a/src/Options/parsers.js +++ b/src/Options/parsers.js @@ -5,7 +5,7 @@ function numberParser(key) { throw new Error(`Key ${key} has invalid value ${opt}`); } return intOpt; - } + }; } function numberOrBoolParser(key) { @@ -20,14 +20,14 @@ function numberOrBoolParser(key) { return false; } return numberParser(key)(opt); - } + }; } function objectParser(opt) { if (typeof opt == 'object') { return opt; } - return JSON.parse(opt) + return JSON.parse(opt); } function arrayParser(opt) { @@ -41,12 +41,14 @@ function arrayParser(opt) { } function moduleOrObjectParser(opt) { - if (typeof opt == 'object') { + if (typeof opt == 'object') { return opt; } try { return JSON.parse(opt); - } catch(e) { /* */ } + } catch (e) { + /* */ + } return opt; } @@ -71,5 +73,5 @@ module.exports = { booleanParser, moduleOrObjectParser, arrayParser, - objectParser + objectParser, }; diff --git a/src/ParseMessageQueue.js b/src/ParseMessageQueue.js index 7195642400..1dcf55d525 100644 --- a/src/ParseMessageQueue.js +++ b/src/ParseMessageQueue.js @@ -1,26 +1,30 @@ import { loadAdapter } from './Adapters/AdapterLoader'; -import { - EventEmitterMQ -} from './Adapters/MessageQueue/EventEmitterMQ'; +import { EventEmitterMQ } from './Adapters/MessageQueue/EventEmitterMQ'; const ParseMessageQueue = {}; ParseMessageQueue.createPublisher = function(config: any): any { - const adapter = loadAdapter(config.messageQueueAdapter, EventEmitterMQ, config); + const adapter = loadAdapter( + config.messageQueueAdapter, + EventEmitterMQ, + config + ); if (typeof adapter.createPublisher !== 'function') { throw 'pubSubAdapter should have createPublisher()'; } return adapter.createPublisher(config); -} +}; ParseMessageQueue.createSubscriber = function(config: any): void { - const adapter = loadAdapter(config.messageQueueAdapter, EventEmitterMQ, config) + const adapter = loadAdapter( + config.messageQueueAdapter, + EventEmitterMQ, + config + ); if (typeof adapter.createSubscriber !== 'function') { throw 'messageQueueAdapter should have createSubscriber()'; } return adapter.createSubscriber(config); -} +}; -export { - ParseMessageQueue -} +export { ParseMessageQueue }; diff --git a/src/ParseServer.js b/src/ParseServer.js index ee2c837501..1fd279b4c7 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -5,70 +5,52 @@ var batch = require('./batch'), express = require('express'), middlewares = require('./middlewares'), Parse = require('parse/node').Parse, - path = require('path'); - -import { ParseServerOptions, - LiveQueryServerOptions } from './Options'; -import defaults from './defaults'; -import * as logging from './logger'; -import Config from './Config'; -import PromiseRouter from './PromiseRouter'; -import requiredParameter from './requiredParameter'; -import { AnalyticsRouter } from './Routers/AnalyticsRouter'; -import { ClassesRouter } from './Routers/ClassesRouter'; -import { FeaturesRouter } from './Routers/FeaturesRouter'; -import { FilesRouter } from './Routers/FilesRouter'; -import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; -import { HooksRouter } from './Routers/HooksRouter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { LogsRouter } from './Routers/LogsRouter'; + { parse } = require('graphql'), + path = require('path'), + fs = require('fs'); + +import { ParseServerOptions, LiveQueryServerOptions } from './Options'; +import defaults from './defaults'; +import * as logging from './logger'; +import Config from './Config'; +import PromiseRouter from './PromiseRouter'; +import requiredParameter from './requiredParameter'; +import { AnalyticsRouter } from './Routers/AnalyticsRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FeaturesRouter } from './Routers/FeaturesRouter'; +import { FilesRouter } from './Routers/FilesRouter'; +import { FunctionsRouter } from './Routers/FunctionsRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; +import { GraphQLRouter } from './Routers/GraphQLRouter'; +import { HooksRouter } from './Routers/HooksRouter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { LogsRouter } from './Routers/LogsRouter'; import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; -import { PublicAPIRouter } from './Routers/PublicAPIRouter'; -import { PushRouter } from './Routers/PushRouter'; -import { CloudCodeRouter } from './Routers/CloudCodeRouter'; -import { RolesRouter } from './Routers/RolesRouter'; -import { SchemasRouter } from './Routers/SchemasRouter'; -import { SessionsRouter } from './Routers/SessionsRouter'; -import { UsersRouter } from './Routers/UsersRouter'; -import { PurgeRouter } from './Routers/PurgeRouter'; -import { AudiencesRouter } from './Routers/AudiencesRouter'; - +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { PushRouter } from './Routers/PushRouter'; +import { CloudCodeRouter } from './Routers/CloudCodeRouter'; +import { RolesRouter } from './Routers/RolesRouter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; +import { PurgeRouter } from './Routers/PurgeRouter'; +import { AudiencesRouter } from './Routers/AudiencesRouter'; +import { AggregateRouter } from './Routers/AggregateRouter'; import { ParseServerRESTController } from './ParseServerRESTController'; import * as controllers from './Controllers'; +import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); // ParseServer works like a constructor of an express app. -// The args that we understand are: -// "analyticsAdapter": an adapter class for analytics -// "filesAdapter": a class like GridStoreAdapter providing create, get, -// and delete -// "loggerAdapter": a class like WinstonLoggerAdapter providing info, error, -// and query -// "jsonLogs": log as structured JSON objects -// "databaseURI": a uri like mongodb://localhost:27017/dbname to tell us -// what database this Parse API connects to. -// "cloud": relative location to cloud code to require, or a function -// that is given an instance of Parse as a parameter. Use this instance of Parse -// to register your cloud code hooks and functions. -// "appId": the application id to host -// "masterKey": the master key for requests to this app -// "collectionPrefix": optional prefix for database collection names -// "fileKey": optional key from Parse dashboard for supporting older files -// hosted by Parse -// "clientKey": optional key from Parse dashboard -// "dotNetKey": optional key from Parse dashboard -// "restAPIKey": optional key from Parse dashboard -// "webhookKey": optional key from Parse dashboard -// "javascriptKey": optional key from Parse dashboard -// "push": optional key from configure push -// "sessionLength": optional length in seconds for how long Sessions should be valid for -// "maxLimit": optional upper bound for what can be specified for the 'limit' parameter on queries - +// https://parseplatform.org/parse-server/api/master/ParseServerOptions.html class ParseServer { - + /** + * @constructor + * @param {ParseServerOptions} options the parse server initialization options + */ constructor(options: ParseServerOptions) { injectDefaults(options); const { @@ -77,7 +59,7 @@ class ParseServer { cloud, javascriptKey, serverURL = requiredParameter('You must provide a serverURL!'), - __indexBuildCompletionCallbackForTests = () => {}, + serverStartComplete, } = options; // Initialize the node client SDK automatically Parse.initialize(appId, javascriptKey || 'unused', masterKey); @@ -94,17 +76,28 @@ class ParseServer { logging.setLogger(loggerController); const dbInitPromise = databaseController.performInitialization(); - hooksController.load(); + const hooksLoadPromise = hooksController.load(); // Note: Tests will start to fail if any validation happens after this is called. - if (process.env.TESTING) { - __indexBuildCompletionCallbackForTests(dbInitPromise); - } + Promise.all([dbInitPromise, hooksLoadPromise]) + .then(() => { + if (serverStartComplete) { + serverStartComplete(); + } + }) + .catch(error => { + if (serverStartComplete) { + serverStartComplete(error); + } else { + console.error(error); + process.exit(1); + } + }); if (cloud) { addParseCloud(); if (typeof cloud === 'function') { - cloud(Parse) + cloud(Parse); } else if (typeof cloud === 'string') { require(path.resolve(process.cwd(), cloud)); } else { @@ -121,28 +114,59 @@ class ParseServer { } handleShutdown() { - const { adapter } = this.config.databaseController; - if (adapter && typeof adapter.handleShutdown === 'function') { - adapter.handleShutdown(); + const promises = []; + const { adapter: databaseAdapter } = this.config.databaseController; + if ( + databaseAdapter && + typeof databaseAdapter.handleShutdown === 'function' + ) { + promises.push(databaseAdapter.handleShutdown()); } + const { adapter: fileAdapter } = this.config.filesController; + if (fileAdapter && typeof fileAdapter.handleShutdown === 'function') { + promises.push(fileAdapter.handleShutdown()); + } + return (promises.length > 0 + ? Promise.all(promises) + : Promise.resolve() + ).then(() => { + if (this.config.serverCloseComplete) { + this.config.serverCloseComplete(); + } + }); } - static app({maxUploadSize = '20mb', appId}) { + /** + * @static + * Create an express app for the parse server + * @param {Object} options let you specify the maxUploadSize when creating the express app */ + static app({ maxUploadSize = '20mb', appId, directAccess }) { // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); //api.use("/apps", express.static(__dirname + "/public")); + api.use(middlewares.allowCrossDomain(appId)); // File handling needs to be before default middlewares are applied - api.use('/', middlewares.allowCrossDomain, new FilesRouter().expressRouter({ - maxUploadSize: maxUploadSize - })); - - api.use('/health', (req, res) => res.sendStatus(200)); + api.use( + '/', + new FilesRouter().expressRouter({ + maxUploadSize: maxUploadSize, + }) + ); + + api.use('/health', function(req, res) { + res.json({ + status: 'ok', + }); + }); - api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressRouter()); + api.use( + '/', + bodyParser.urlencoded({ extended: false }), + new PublicAPIRouter().expressRouter() + ); - api.use(bodyParser.json({ 'type': '*/*' , limit: maxUploadSize })); - api.use(middlewares.allowCrossDomain); + api.use(bodyParser.json({ type: '*/*', limit: maxUploadSize })); api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); @@ -155,9 +179,12 @@ class ParseServer { if (!process.env.TESTING) { //This causes tests to spew some useless warnings, so disable in test /* istanbul ignore next */ - process.on('uncaughtException', (err) => { - if (err.code === "EADDRINUSE") { // user-friendly message for this common error - process.stderr.write(`Unable to listen on port ${err.port}. The port is already in use.`); + process.on('uncaughtException', err => { + if (err.code === 'EADDRINUSE') { + // user-friendly message for this common error + process.stderr.write( + `Unable to listen on port ${err.port}. The port is already in use.` + ); process.exit(0); } else { throw err; @@ -169,13 +196,18 @@ class ParseServer { ParseServer.verifyServerUrl(); }); } - if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1') { - Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter)); + if ( + process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1' || + directAccess + ) { + Parse.CoreManager.setRESTController( + ParseServerRESTController(appId, appRouter) + ); } return api; } - static promiseRouter({appId}) { + static promiseRouter({ appId }) { const routers = [ new ClassesRouter(), new UsersRouter(), @@ -190,10 +222,12 @@ class ParseServer { new IAPValidationRouter(), new FeaturesRouter(), new GlobalConfigRouter(), + new GraphQLRouter(), new PurgeRouter(), new HooksRouter(), new CloudCodeRouter(), - new AudiencesRouter() + new AudiencesRouter(), + new AggregateRouter(), ]; const routes = routers.reduce((memo, router) => { @@ -206,7 +240,13 @@ class ParseServer { return appRouter; } - start(options: ParseServerOptions, callback: ?()=>void) { + /** + * starts the parse server's express app + * @param {ParseServerOptions} options to use to start the server + * @param {Function} callback called when the server has started + * @returns {ParseServer} the parse server instance + */ + start(options: ParseServerOptions, callback: ?() => void) { const app = express(); if (options.middleware) { let middleware; @@ -219,11 +259,40 @@ class ParseServer { } app.use(options.mountPath, this.app); + + if (options.mountGraphQL === true || options.mountPlayground === true) { + let graphQLCustomTypeDefs = undefined; + if (typeof options.graphQLSchema === 'string') { + graphQLCustomTypeDefs = parse( + fs.readFileSync(options.graphQLSchema, 'utf8') + ); + } else if (typeof options.graphQLSchema === 'object') { + graphQLCustomTypeDefs = options.graphQLSchema; + } + + const parseGraphQLServer = new ParseGraphQLServer(this, { + graphQLPath: options.graphQLPath, + playgroundPath: options.playgroundPath, + graphQLCustomTypeDefs, + }); + + if (options.mountGraphQL) { + parseGraphQLServer.applyGraphQL(app); + } + + if (options.mountPlayground) { + parseGraphQLServer.applyPlayground(app); + } + } + const server = app.listen(options.port, options.host, callback); this.server = server; if (options.startLiveQueryServer || options.liveQueryServerOptions) { - this.liveQueryServer = ParseServer.createLiveQueryServer(server, options.liveQueryServerOptions); + this.liveQueryServer = ParseServer.createLiveQueryServer( + server, + options.liveQueryServerOptions + ); } /* istanbul ignore next */ if (!process.env.TESTING) { @@ -233,11 +302,24 @@ class ParseServer { return this; } - static start(options: ParseServerOptions, callback: ?()=>void) { + /** + * Creates a new ParseServer and starts it. + * @param {ParseServerOptions} options used to start the server + * @param {Function} callback called when the server has started + * @returns {ParseServer} the parse server instance + */ + static start(options: ParseServerOptions, callback: ?() => void) { const parseServer = new ParseServer(options); return parseServer.start(options, callback); } + /** + * Helper method to create a liveQuery server + * @static + * @param {Server} httpServer an optional http server to pass + * @param {LiveQueryServerOptions} config options fot he liveQueryServer + * @returns {ParseLiveQueryServer} the live query server instance + */ static createLiveQueryServer(httpServer, config: LiveQueryServerOptions) { if (!httpServer || (config && config.port)) { var app = express(); @@ -249,52 +331,119 @@ class ParseServer { static verifyServerUrl(callback) { // perform a health check on the serverURL value - if(Parse.serverURL) { - const request = require('request'); - request(Parse.serverURL.replace(/\/$/, "") + "/health", function (error, response, body) { - if (error || response.statusCode !== 200 || body !== "OK") { - /* eslint-disable no-console */ - console.warn(`\nWARNING, Unable to connect to '${Parse.serverURL}'.` + - ` Cloud code and push notifications may be unavailable!\n`); - if(callback) { - callback(false); - } - } else { - if(callback) { - callback(true); + if (Parse.serverURL) { + const request = require('./request'); + request({ url: Parse.serverURL.replace(/\/$/, '') + '/health' }) + .catch(response => response) + .then(response => { + const json = response.data || null; + if ( + response.status !== 200 || + !json || + (json && json.status !== 'ok') + ) { + /* eslint-disable no-console */ + console.warn( + `\nWARNING, Unable to connect to '${Parse.serverURL}'.` + + ` Cloud code and push notifications may be unavailable!\n` + ); + /* eslint-enable no-console */ + if (callback) { + callback(false); + } + } else { + if (callback) { + callback(true); + } } - } - }); + }); } } } function addParseCloud() { - const ParseCloud = require("./cloud-code/Parse.Cloud"); + const ParseCloud = require('./cloud-code/Parse.Cloud'); Object.assign(Parse.Cloud, ParseCloud); global.Parse = Parse; } function injectDefaults(options: ParseServerOptions) { - Object.keys(defaults).forEach((key) => { - if (!options.hasOwnProperty(key)) { + Object.keys(defaults).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(options, key)) { options[key] = defaults[key]; } }); - if (!options.hasOwnProperty('serverURL')) { + if (!Object.prototype.hasOwnProperty.call(options, 'serverURL')) { options.serverURL = `http://localhost:${options.port}${options.mountPath}`; } - options.userSensitiveFields = Array.from(new Set(options.userSensitiveFields.concat( - defaults.userSensitiveFields, - options.userSensitiveFields - ))); + // Reserved Characters + if (options.appId) { + const regex = /[!#$%'()*+&/:;=?@[\]{}^,|<>]/g; + if (options.appId.match(regex)) { + console.warn( + `\nWARNING, appId that contains special characters can cause issues while using with urls.\n` + ); + } + } - options.masterKeyIps = Array.from(new Set(options.masterKeyIps.concat( - defaults.masterKeyIps, - options.masterKeyIps - ))); + // Backwards compatibility + if (options.userSensitiveFields) { + /* eslint-disable no-console */ + !process.env.TESTING && + console.warn( + `\nDEPRECATED: userSensitiveFields has been replaced by protectedFields allowing the ability to protect fields in all classes with CLP. \n` + ); + /* eslint-enable no-console */ + + const userSensitiveFields = Array.from( + new Set([ + ...(defaults.userSensitiveFields || []), + ...(options.userSensitiveFields || []), + ]) + ); + + // If the options.protectedFields is unset, + // it'll be assigned the default above. + // Here, protect against the case where protectedFields + // is set, but doesn't have _User. + if (!('_User' in options.protectedFields)) { + options.protectedFields = Object.assign( + { _User: [] }, + options.protectedFields + ); + } + + options.protectedFields['_User']['*'] = Array.from( + new Set([ + ...(options.protectedFields['_User']['*'] || []), + ...userSensitiveFields, + ]) + ); + } + + // Merge protectedFields options with defaults. + Object.keys(defaults.protectedFields).forEach(c => { + const cur = options.protectedFields[c]; + if (!cur) { + options.protectedFields[c] = defaults.protectedFields[c]; + } else { + Object.keys(defaults.protectedFields[c]).forEach(r => { + const unq = new Set([ + ...(options.protectedFields[c][r] || []), + ...defaults.protectedFields[c][r], + ]); + options.protectedFields[c][r] = Array.from(unq); + }); + } + }); + + options.masterKeyIps = Array.from( + new Set( + options.masterKeyIps.concat(defaults.masterKeyIps, options.masterKeyIps) + ) + ); } // Those can't be tested as it requires a subprocess @@ -304,7 +453,7 @@ function configureListeners(parseServer) { const sockets = {}; /* Currently, express doesn't shut down immediately after receiving SIGINT/SIGTERM if it has client connections that haven't timed out. (This is a known issue with node - https://github.com/nodejs/node/issues/2642) This function, along with `destroyAliveConnections()`, intend to fix this behavior such that parse server will close all open connections and initiate the shutdown process as soon as it receives a SIGINT/SIGTERM signal. */ - server.on('connection', (socket) => { + server.on('connection', socket => { const socketId = socket.remoteAddress + ':' + socket.remotePort; sockets[socketId] = socket; socket.on('close', () => { @@ -316,9 +465,11 @@ function configureListeners(parseServer) { for (const socketId in sockets) { try { sockets[socketId].destroy(); - } catch (e) { /* */ } + } catch (e) { + /* */ + } } - } + }; const handleShutdown = function() { process.stdout.write('Termination signal received. Shutting down.'); diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 63e8d0a41f..1f70503010 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -6,54 +6,91 @@ const Parse = require('parse/node'); function getSessionToken(options) { if (options && typeof options.sessionToken === 'string') { - return Parse.Promise.as(options.sessionToken); + return Promise.resolve(options.sessionToken); } - return Parse.Promise.as(null); + return Promise.resolve(null); } function getAuth(options = {}, config) { const installationId = options.installationId || 'cloud'; if (options.useMasterKey) { - return Parse.Promise.as(new Auth.Auth({config, isMaster: true, installationId })); + return Promise.resolve( + new Auth.Auth({ config, isMaster: true, installationId }) + ); } - return getSessionToken(options).then((sessionToken) => { + return getSessionToken(options).then(sessionToken => { if (sessionToken) { options.sessionToken = sessionToken; return Auth.getAuthForSessionToken({ config, sessionToken: sessionToken, - installationId + installationId, }); } else { - return Parse.Promise.as(new Auth.Auth({ config, installationId })); + return Promise.resolve(new Auth.Auth({ config, installationId })); } - }) + }); } function ParseServerRESTController(applicationId, router) { - function handleRequest(method, path, data = {}, options = {}) { + function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails const args = arguments; - const config = Config.get(applicationId); + if (!config) { + config = Config.get(applicationId); + } const serverURL = URL.parse(config.serverURL); if (path.indexOf(serverURL.path) === 0) { path = path.slice(serverURL.path.length, path.length); } - if (path[0] !== "/") { - path = "/" + path; + if (path[0] !== '/') { + path = '/' + path; } if (path === '/batch') { - const promises = data.requests.map((request) => { - return handleRequest(request.method, request.path, request.body, options).then((response) => { - return Parse.Promise.as({success: response}); - }, (error) => { - return Parse.Promise.as({error: {code: error.code, error: error.message}}); + let initialPromise = Promise.resolve(); + if (data.transaction === true) { + initialPromise = config.database.createTransactionalSession(); + } + return initialPromise.then(() => { + const promises = data.requests.map(request => { + return handleRequest( + request.method, + request.path, + request.body, + options, + config + ).then( + response => { + return { success: response }; + }, + error => { + return { + error: { code: error.code, error: error.message }, + }; + } + ); + }); + return Promise.all(promises).then(result => { + if (data.transaction === true) { + if ( + result.find(resultItem => typeof resultItem.error === 'object') + ) { + return config.database.abortTransactionalSession().then(() => { + return Promise.reject(result); + }); + } else { + return config.database.commitTransactionalSession().then(() => { + return result; + }); + } + } else { + return result; + } }); }); - return Parse.Promise.all(promises); } let query; @@ -61,38 +98,45 @@ function ParseServerRESTController(applicationId, router) { query = data; } - return new Parse.Promise((resolve, reject) => { - getAuth(options, config).then((auth) => { + return new Promise((resolve, reject) => { + getAuth(options, config).then(auth => { const request = { body: data, config, auth, info: { applicationId: applicationId, - sessionToken: options.sessionToken + sessionToken: options.sessionToken, }, - query + query, }; - return Promise.resolve().then(() => { - return router.tryRouteRequest(method, path, request); - }).then((response) => { - resolve(response.response, response.status, response); - }, (err) => { - if (err instanceof Parse.Error && - err.code == Parse.Error.INVALID_JSON && - err.message == `cannot route ${method} ${path}`) { - RESTController.request.apply(null, args).then(resolve, reject); - } else { - reject(err); - } - }); + return Promise.resolve() + .then(() => { + return router.tryRouteRequest(method, path, request); + }) + .then( + response => { + resolve(response.response, response.status, response); + }, + err => { + if ( + err instanceof Parse.Error && + err.code == Parse.Error.INVALID_JSON && + err.message == `cannot route ${method} ${path}` + ) { + RESTController.request.apply(null, args).then(resolve, reject); + } else { + reject(err); + } + } + ); }, reject); }); } - return { + return { request: handleRequest, - ajax: RESTController.ajax + ajax: RESTController.ajax, }; } diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index e57b577ff3..d67bf30d0a 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -5,10 +5,10 @@ // themselves use our routing information, without disturbing express // components that external developers may be modifying. -import Parse from 'parse/node'; -import express from 'express'; -import log from './logger'; -import {inspect} from 'util'; +import Parse from 'parse/node'; +import express from 'express'; +import log from './logger'; +import { inspect } from 'util'; const Layer = require('express/lib/router/layer'); function validateParameter(key, value) { @@ -25,7 +25,6 @@ function validateParameter(key, value) { } } - export default class PromiseRouter { // Each entry should be an object with: // path: the path to route, in express format @@ -54,14 +53,14 @@ export default class PromiseRouter { } route(method, path, ...handlers) { - switch(method) { - case 'POST': - case 'GET': - case 'PUT': - case 'DELETE': - break; - default: - throw 'cannot route method: ' + method; + switch (method) { + case 'POST': + case 'GET': + case 'PUT': + case 'DELETE': + break; + default: + throw 'cannot route method: ' + method; } let handler = handlers[0]; @@ -73,14 +72,14 @@ export default class PromiseRouter { return handler(req); }); }, Promise.resolve()); - } + }; } this.routes.push({ path: path, method: method, handler: handler, - layer: new Layer(path, null, handler) + layer: new Layer(path, null, handler), }); } @@ -97,17 +96,17 @@ export default class PromiseRouter { const match = layer.match(path); if (match) { const params = layer.params; - Object.keys(params).forEach((key) => { + Object.keys(params).forEach(key => { params[key] = validateParameter(key, params[key]); }); - return {params: params, handler: route.handler}; + return { params: params, handler: route.handler }; } } } // Mount the routes on this router onto an express app (or express router) mountOnto(expressApp) { - this.routes.forEach((route) => { + this.routes.forEach(route => { const method = route.method.toLowerCase(); const handler = makeExpressHandler(this.appId, route.handler); expressApp[method].call(expressApp, route.path, handler); @@ -124,7 +123,8 @@ export default class PromiseRouter { if (!match) { throw new Parse.Error( Parse.Error.INVALID_JSON, - 'cannot route ' + method + ' ' + path); + 'cannot route ' + method + ' ' + path + ); } request.params = match.params; return new Promise((resolve, reject) => { @@ -148,57 +148,77 @@ function makeExpressHandler(appId, promiseHandler) { method, url, headers, - body + body, }); - promiseHandler(req).then((result) => { - if (!result.response && !result.location && !result.text) { - log.error('the handler did not include a "response" or a "location" field'); - throw 'control should not get here'; - } - - log.logResponse({ method, url, result }); - - var status = result.status || 200; - res.status(status); - - if (result.text) { - res.send(result.text); - return; - } - - if (result.location) { - res.set('Location', result.location); - // Override the default expressjs response - // as it double encodes %encoded chars in URL - if (!result.response) { - res.send('Found. Redirecting to ' + result.location); - return; + promiseHandler(req) + .then( + result => { + clearSchemaCache(req); + if (!result.response && !result.location && !result.text) { + log.error( + 'the handler did not include a "response" or a "location" field' + ); + throw 'control should not get here'; + } + + log.logResponse({ method, url, result }); + + var status = result.status || 200; + res.status(status); + + if (result.text) { + res.send(result.text); + return; + } + + if (result.location) { + res.set('Location', result.location); + // Override the default expressjs response + // as it double encodes %encoded chars in URL + if (!result.response) { + res.send('Found. Redirecting to ' + result.location); + return; + } + } + if (result.headers) { + Object.keys(result.headers).forEach(header => { + res.set(header, result.headers[header]); + }); + } + res.json(result.response); + }, + error => { + clearSchemaCache(req); + next(error); } - } - if (result.headers) { - Object.keys(result.headers).forEach((header) => { - res.set(header, result.headers[header]); - }) - } - res.json(result.response); - }, (e) => { - log.error(`Error generating response. ${inspect(e)}`, {error: e}); - next(e); - }); + ) + .catch(e => { + clearSchemaCache(req); + log.error(`Error generating response. ${inspect(e)}`, { error: e }); + next(e); + }); } catch (e) { - log.error(`Error handling request: ${inspect(e)}`, {error: e}); + clearSchemaCache(req); + log.error(`Error handling request: ${inspect(e)}`, { error: e }); next(e); } - } + }; } - function maskSensitiveUrl(req) { let maskUrl = req.originalUrl.toString(); - const shouldMaskUrl = req.method === 'GET' && req.originalUrl.includes('/login') - && !req.originalUrl.includes('classes'); + const shouldMaskUrl = + req.method === 'GET' && + req.originalUrl.includes('/login') && + !req.originalUrl.includes('classes'); if (shouldMaskUrl) { maskUrl = log.maskSensitiveUrl(maskUrl); } return maskUrl; } + +function clearSchemaCache(req) { + if (req.config && !req.config.enableSingleSchemaCache) { + req.config.database.schemaCache.clear(); + } +} diff --git a/src/Push/PushQueue.js b/src/Push/PushQueue.js index cc6fb16e84..f67f7c9e6b 100644 --- a/src/Push/PushQueue.js +++ b/src/Push/PushQueue.js @@ -1,5 +1,5 @@ -import { ParseMessageQueue } from '../ParseMessageQueue'; -import rest from '../rest'; +import { ParseMessageQueue } from '../ParseMessageQueue'; +import rest from '../rest'; import { applyDeviceTokenExists } from './utils'; import Parse from 'parse/node'; @@ -30,33 +30,39 @@ export class PushQueue { // Order by objectId so no impact on the DB const order = 'objectId'; - return Promise.resolve().then(() => { - return rest.find(config, - auth, - '_Installation', - where, - {limit: 0, count: true}); - }).then(({results, count}) => { - if (!results || count == 0) { - return Promise.reject({error: 'PushController: no results in query'}) - } - pushStatus.setRunning(count); - let skip = 0; - while (skip < count) { - const query = { where, - limit, - skip, - order }; - - const pushWorkItem = { - body, - query, - pushStatus: { objectId: pushStatus.objectId }, - applicationId: config.applicationId + return Promise.resolve() + .then(() => { + return rest.find(config, auth, '_Installation', where, { + limit: 0, + count: true, + }); + }) + .then(({ results, count }) => { + if (!results || count == 0) { + return pushStatus.complete(); } - this.parsePublisher.publish(this.channel, JSON.stringify(pushWorkItem)); - skip += limit; - } - }); + pushStatus.setRunning(Math.ceil(count / limit)); + let skip = 0; + while (skip < count) { + const query = { + where, + limit, + skip, + order, + }; + + const pushWorkItem = { + body, + query, + pushStatus: { objectId: pushStatus.objectId }, + applicationId: config.applicationId, + }; + this.parsePublisher.publish( + this.channel, + JSON.stringify(pushWorkItem) + ); + skip += limit; + } + }); } } diff --git a/src/Push/PushWorker.js b/src/Push/PushWorker.js index afd4416dc9..d16542aeac 100644 --- a/src/Push/PushWorker.js +++ b/src/Push/PushWorker.js @@ -1,15 +1,16 @@ // @flow -import deepcopy from 'deepcopy'; -import AdaptableController from '../Controllers/AdaptableController'; -import { master } from '../Auth'; -import Config from '../Config'; -import { PushAdapter } from '../Adapters/Push/PushAdapter'; -import rest from '../rest'; -import { pushStatusHandler } from '../StatusHandler'; -import * as utils from './utils'; -import { ParseMessageQueue } from '../ParseMessageQueue'; -import { PushQueue } from './PushQueue'; -import logger from '../logger'; +// @flow-disable-next +import deepcopy from 'deepcopy'; +import AdaptableController from '../Controllers/AdaptableController'; +import { master } from '../Auth'; +import Config from '../Config'; +import { PushAdapter } from '../Adapters/Push/PushAdapter'; +import rest from '../rest'; +import { pushStatusHandler } from '../StatusHandler'; +import * as utils from './utils'; +import { ParseMessageQueue } from '../ParseMessageQueue'; +import { PushQueue } from './PushQueue'; +import logger from '../logger'; function groupByBadge(installations) { return installations.reduce((map, installation) => { @@ -41,29 +42,29 @@ export class PushWorker { } } - unsubscribe(): void { - if (this.subscriber) { - this.subscriber.unsubscribe(this.channel); - } - } - run({ body, query, pushStatus, applicationId, UTCOffset }: any): Promise<*> { const config = Config.get(applicationId); const auth = master(config); const where = utils.applyDeviceTokenExists(query.where); delete query.where; pushStatus = pushStatusHandler(config, pushStatus.objectId); - return rest.find(config, auth, '_Installation', where, query).then(({results}) => { - if (results.length == 0) { - return; - } - return this.sendToAdapter(body, results, pushStatus, config, UTCOffset); - }, err => { - throw err; - }); + return rest + .find(config, auth, '_Installation', where, query) + .then(({ results }) => { + if (results.length == 0) { + return; + } + return this.sendToAdapter(body, results, pushStatus, config, UTCOffset); + }); } - sendToAdapter(body: any, installations: any[], pushStatus: any, config: Config, UTCOffset: ?any): Promise<*> { + sendToAdapter( + body: any, + installations: any[], + pushStatus: any, + config: Config, + UTCOffset: ?any + ): Promise<*> { // Check if we have locales in the push body const locales = utils.getLocalesFromPush(body); if (locales.length > 0) { @@ -71,31 +72,48 @@ export class PushWorker { const bodiesPerLocales = utils.bodiesPerLocales(body, locales); // Group installations on the specified locales (en, fr, default etc...) - const grouppedInstallations = utils.groupByLocaleIdentifier(installations, locales); - const promises = Object.keys(grouppedInstallations).map((locale) => { + const grouppedInstallations = utils.groupByLocaleIdentifier( + installations, + locales + ); + const promises = Object.keys(grouppedInstallations).map(locale => { const installations = grouppedInstallations[locale]; const body = bodiesPerLocales[locale]; - return this.sendToAdapter(body, installations, pushStatus, config, UTCOffset); + return this.sendToAdapter( + body, + installations, + pushStatus, + config, + UTCOffset + ); }); return Promise.all(promises); } if (!utils.isPushIncrementing(body)) { logger.verbose(`Sending push to ${installations.length}`); - return this.adapter.send(body, installations, pushStatus.objectId).then((results) => { - return pushStatus.trackSent(results, UTCOffset).then(() => results); - }); + return this.adapter + .send(body, installations, pushStatus.objectId) + .then(results => { + return pushStatus.trackSent(results, UTCOffset).then(() => results); + }); } // Collect the badges to reduce the # of calls const badgeInstallationsMap = groupByBadge(installations); // Map the on the badges count and return the send result - const promises = Object.keys(badgeInstallationsMap).map((badge) => { + const promises = Object.keys(badgeInstallationsMap).map(badge => { const payload = deepcopy(body); payload.data.badge = parseInt(badge); const installations = badgeInstallationsMap[badge]; - return this.sendToAdapter(payload, installations, pushStatus, config, UTCOffset); + return this.sendToAdapter( + payload, + installations, + pushStatus, + config, + UTCOffset + ); }); return Promise.all(promises); } diff --git a/src/Push/utils.js b/src/Push/utils.js index ed33a66a14..ce7023917e 100644 --- a/src/Push/utils.js +++ b/src/Push/utils.js @@ -1,11 +1,22 @@ -import Parse from 'parse/node'; +import Parse from 'parse/node'; import deepcopy from 'deepcopy'; export function isPushIncrementing(body) { - return body.data && - body.data.badge && - typeof body.data.badge == 'string' && - body.data.badge.toLowerCase() == "increment" + if (!body.data || !body.data.badge) { + return false; + } + + const badge = body.data.badge; + if (typeof badge == 'string' && badge.toLowerCase() == 'increment') { + return true; + } + + return ( + typeof badge == 'object' && + typeof badge.__op == 'string' && + badge.__op.toLowerCase() == 'increment' && + Number(badge.amount) + ); } const localizableKeys = ['alert', 'title']; @@ -15,14 +26,18 @@ export function getLocalesFromPush(body) { if (!data) { return []; } - return [...new Set(Object.keys(data).reduce((memo, key) => { - localizableKeys.forEach((localizableKey) => { - if (key.indexOf(`${localizableKey}-`) == 0) { - memo.push(key.slice(localizableKey.length + 1)); - } - }); - return memo; - }, []))]; + return [ + ...new Set( + Object.keys(data).reduce((memo, key) => { + localizableKeys.forEach(localizableKey => { + if (key.indexOf(`${localizableKey}-`) == 0) { + memo.push(key.slice(localizableKey.length + 1)); + } + }); + return memo; + }, []) + ), + ]; } export function transformPushBodyForLocale(body, locale) { @@ -31,7 +46,7 @@ export function transformPushBodyForLocale(body, locale) { return body; } body = deepcopy(body); - localizableKeys.forEach((key) => { + localizableKeys.forEach(key => { const localeValue = body.data[`${key}-${locale}`]; if (localeValue) { body.data[key] = localeValue; @@ -41,9 +56,11 @@ export function transformPushBodyForLocale(body, locale) { } export function stripLocalesFromBody(body) { - if (!body.data) { return body; } - Object.keys(body.data).forEach((key) => { - localizableKeys.forEach((localizableKey) => { + if (!body.data) { + return body; + } + Object.keys(body.data).forEach(key => { + localizableKeys.forEach(localizableKey => { if (key.indexOf(`${localizableKey}-`) == 0) { delete body.data[key]; } @@ -64,23 +81,29 @@ export function bodiesPerLocales(body, locales = []) { } export function groupByLocaleIdentifier(installations, locales = []) { - return installations.reduce((map, installation) => { - let added = false; - locales.forEach((locale) => { - if (added) { - return; + return installations.reduce( + (map, installation) => { + let added = false; + locales.forEach(locale => { + if (added) { + return; + } + if ( + installation.localeIdentifier && + installation.localeIdentifier.indexOf(locale) === 0 + ) { + added = true; + map[locale] = map[locale] || []; + map[locale].push(installation); + } + }); + if (!added) { + map.default.push(installation); } - if (installation.localeIdentifier && installation.localeIdentifier.indexOf(locale) === 0) { - added = true; - map[locale] = map[locale] || []; - map[locale].push(installation); - } - }); - if (!added) { - map.default.push(installation); - } - return map; - }, {default: []}); + return map; + }, + { default: [] } + ); } /** @@ -99,16 +122,18 @@ export function validatePushType(where = {}, validPushTypes = []) { for (var i = 0; i < deviceTypes.length; i++) { var deviceType = deviceTypes[i]; if (validPushTypes.indexOf(deviceType) < 0) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - deviceType + ' is not supported push type.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + deviceType + ' is not supported push type.' + ); } } } export function applyDeviceTokenExists(where) { where = deepcopy(where); - if (!where.hasOwnProperty('deviceToken')) { - where['deviceToken'] = {'$exists': true}; + if (!Object.prototype.hasOwnProperty.call(where, 'deviceToken')) { + where['deviceToken'] = { $exists: true }; } return where; } diff --git a/src/RestQuery.js b/src/RestQuery.js index 7126814152..468446561c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -4,8 +4,8 @@ var SchemaController = require('./Controllers/SchemaController'); var Parse = require('parse/node').Parse; const triggers = require('./triggers'); - -const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt']; +const { continueWhile } = require('parse/lib/node/promiseUtils'); +const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; // restOptions can include: // skip // limit @@ -13,37 +13,55 @@ const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt']; // count // include // keys +// excludeKeys // redirectClassNameForKey -function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, clientSDK) { - +// readPreference +// includeReadPreference +// subqueryReadPreference +function RestQuery( + config, + auth, + className, + restWhere = {}, + restOptions = {}, + clientSDK, + runAfterFind = true +) { this.config = config; this.auth = auth; this.className = className; this.restWhere = restWhere; this.restOptions = restOptions; this.clientSDK = clientSDK; + this.runAfterFind = runAfterFind; this.response = null; this.findOptions = {}; + if (!this.auth.isMaster) { - this.findOptions.acl = this.auth.user ? [this.auth.user.id] : null; if (this.className == '_Session') { - if (!this.findOptions.acl) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'This session token is invalid.'); + if (!this.auth.user) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); } this.restWhere = { - '$and': [this.restWhere, { - 'user': { - __type: 'Pointer', - className: '_User', - objectId: this.auth.user.id - } - }] + $and: [ + this.restWhere, + { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id, + }, + }, + ], }; } } this.doCount = false; + this.includeAll = false; // The format for this.include is not the same as the format for the // include option - it's the paths we should include, in order, @@ -55,15 +73,19 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl // If we have keys, we probably want to force some includes (n-1 level) // See issue: https://github.com/parse-community/parse-server/issues/3185 - if (restOptions.hasOwnProperty('keys')) { - const keysForInclude = restOptions.keys.split(',').filter((key) => { - // At least 2 components - return key.split(".").length > 1; - }).map((key) => { - // Slice the last component (a.b.c -> a.b) - // Otherwise we'll include one level too much. - return key.slice(0, key.lastIndexOf(".")); - }).join(','); + if (Object.prototype.hasOwnProperty.call(restOptions, 'keys')) { + const keysForInclude = restOptions.keys + .split(',') + .filter(key => { + // At least 2 components + return key.split('.').length > 1; + }) + .map(key => { + // Slice the last component (a.b.c -> a.b) + // Otherwise we'll include one level too much. + return key.slice(0, key.lastIndexOf('.')); + }) + .join(','); // Concat the possibly present include string with the one from the keys // Dedup / sorting is handle in 'include' case. @@ -71,70 +93,92 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl if (!restOptions.include || restOptions.include.length == 0) { restOptions.include = keysForInclude; } else { - restOptions.include += "," + keysForInclude; + restOptions.include += ',' + keysForInclude; } } } for (var option in restOptions) { - switch(option) { - case 'keys': { - const keys = restOptions.keys.split(',').concat(AlwaysSelectedKeys); - this.keys = Array.from(new Set(keys)); - break; - } - case 'count': - this.doCount = true; - break; - case 'skip': - case 'limit': - case 'readPreference': - this.findOptions[option] = restOptions[option]; - break; - case 'order': - var fields = restOptions.order.split(','); - this.findOptions.sort = fields.reduce((sortMap, field) => { - field = field.trim(); - if (field === '$score') { - sortMap.score = {$meta: 'textScore'}; - } else if (field[0] == '-') { - sortMap[field.slice(1)] = -1; - } else { - sortMap[field] = 1; + switch (option) { + case 'keys': { + const keys = restOptions.keys.split(',').concat(AlwaysSelectedKeys); + this.keys = Array.from(new Set(keys)); + break; + } + case 'excludeKeys': { + const exclude = restOptions.excludeKeys + .split(',') + .filter(k => AlwaysSelectedKeys.indexOf(k) < 0); + this.excludeKeys = Array.from(new Set(exclude)); + break; + } + case 'count': + this.doCount = true; + break; + case 'includeAll': + this.includeAll = true; + break; + case 'explain': + case 'hint': + case 'distinct': + case 'pipeline': + case 'skip': + case 'limit': + case 'readPreference': + this.findOptions[option] = restOptions[option]; + break; + case 'order': + var fields = restOptions.order.split(','); + this.findOptions.sort = fields.reduce((sortMap, field) => { + field = field.trim(); + if (field === '$score') { + sortMap.score = { $meta: 'textScore' }; + } else if (field[0] == '-') { + sortMap[field.slice(1)] = -1; + } else { + sortMap[field] = 1; + } + return sortMap; + }, {}); + break; + case 'include': { + const paths = restOptions.include.split(','); + if (paths.includes('*')) { + this.includeAll = true; + break; } - return sortMap; - }, {}); - break; - case 'include': { - const paths = restOptions.include.split(','); - // Load the existing includes (from keys) - const pathSet = paths.reduce((memo, path) => { - // Split each paths on . (a.b.c -> [a,b,c]) - // reduce to create all paths - // ([a,b,c] -> {a: true, 'a.b': true, 'a.b.c': true}) - return path.split('.').reduce((memo, path, index, parts) => { - memo[parts.slice(0, index + 1).join('.')] = true; - return memo; - }, memo); - }, {}); - - this.include = Object.keys(pathSet).map((s) => { - return s.split('.'); - }).sort((a, b) => { - return a.length - b.length; // Sort by number of components - }); - break; - } - case 'redirectClassNameForKey': - this.redirectKey = restOptions.redirectClassNameForKey; - this.redirectClassName = null; - break; - case 'includeReadPreference': - case 'subqueryReadPreference': - break; - default: - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad option: ' + option); + // Load the existing includes (from keys) + const pathSet = paths.reduce((memo, path) => { + // Split each paths on . (a.b.c -> [a,b,c]) + // reduce to create all paths + // ([a,b,c] -> {a: true, 'a.b': true, 'a.b.c': true}) + return path.split('.').reduce((memo, path, index, parts) => { + memo[parts.slice(0, index + 1).join('.')] = true; + return memo; + }, memo); + }, {}); + + this.include = Object.keys(pathSet) + .map(s => { + return s.split('.'); + }) + .sort((a, b) => { + return a.length - b.length; // Sort by number of components + }); + break; + } + case 'redirectClassNameForKey': + this.redirectKey = restOptions.redirectClassNameForKey; + this.redirectClassName = null; + break; + case 'includeReadPreference': + case 'subqueryReadPreference': + break; + default: + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad option: ' + option + ); } } } @@ -145,52 +189,111 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl // 'results' and 'count'. // TODO: consolidate the replaceX functions RestQuery.prototype.execute = function(executeOptions) { - return Promise.resolve().then(() => { - return this.buildRestWhere(); - }).then(() => { - return this.runFind(executeOptions); - }).then(() => { - return this.runCount(); - }).then(() => { - return this.handleInclude(); - }).then(() => { - return this.runAfterFindTrigger(); - }).then(() => { - return this.response; - }); + return Promise.resolve() + .then(() => { + return this.buildRestWhere(); + }) + .then(() => { + return this.handleIncludeAll(); + }) + .then(() => { + return this.handleExcludeKeys(); + }) + .then(() => { + return this.runFind(executeOptions); + }) + .then(() => { + return this.runCount(); + }) + .then(() => { + return this.handleInclude(); + }) + .then(() => { + return this.runAfterFindTrigger(); + }) + .then(() => { + return this.response; + }); +}; + +RestQuery.prototype.each = function(callback) { + const { config, auth, className, restWhere, restOptions, clientSDK } = this; + // if the limit is set, use it + restOptions.limit = restOptions.limit || 100; + restOptions.order = 'objectId'; + let finished = false; + + return continueWhile( + () => { + return !finished; + }, + async () => { + const query = new RestQuery( + config, + auth, + className, + restWhere, + restOptions, + clientSDK + ); + const { results } = await query.execute(); + results.forEach(callback); + finished = results.length < restOptions.limit; + if (!finished) { + restWhere.objectId = Object.assign({}, restWhere.objectId, { + $gt: results[results.length - 1].objectId, + }); + } + } + ); }; RestQuery.prototype.buildRestWhere = function() { - return Promise.resolve().then(() => { - return this.getUserAndRoleACL(); - }).then(() => { - return this.redirectClassNameForKey(); - }).then(() => { - return this.validateClientClassCreation(); - }).then(() => { - return this.replaceSelect(); - }).then(() => { - return this.replaceDontSelect(); - }).then(() => { - return this.replaceInQuery(); - }).then(() => { - return this.replaceNotInQuery(); - }).then(() => { - return this.replaceEquality(); - }); -} + return Promise.resolve() + .then(() => { + return this.getUserAndRoleACL(); + }) + .then(() => { + return this.redirectClassNameForKey(); + }) + .then(() => { + return this.validateClientClassCreation(); + }) + .then(() => { + return this.replaceSelect(); + }) + .then(() => { + return this.replaceDontSelect(); + }) + .then(() => { + return this.replaceInQuery(); + }) + .then(() => { + return this.replaceNotInQuery(); + }) + .then(() => { + return this.replaceEquality(); + }); +}; // Uses the Auth object to get the list of roles, adds the user id RestQuery.prototype.getUserAndRoleACL = function() { - if (this.auth.isMaster || !this.auth.user) { + if (this.auth.isMaster) { return Promise.resolve(); } - return this.auth.getUserRoles().then((roles) => { - // Concat with the roles to prevent duplications on multiple calls - const aclSet = new Set([].concat(this.findOptions.acl, roles)); - this.findOptions.acl = Array.from(aclSet); + + this.findOptions.acl = ['*']; + + if (this.auth.user) { + return this.auth.getUserRoles().then(roles => { + this.findOptions.acl = this.findOptions.acl.concat(roles, [ + this.auth.user.id, + ]); + return; + }); + } else { return Promise.resolve(); - }); + } }; // Changes the className if redirectClassNameForKey is set. @@ -201,8 +304,9 @@ 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) => { + return this.config.database + .redirectClassNameForKey(this.className, this.redirectKey) + .then(newClassName => { this.className = newClassName; this.redirectClassName = newClassName; }); @@ -210,15 +314,22 @@ RestQuery.prototype.redirectClassNameForKey = function() { // Validates this operation against the allowClientClassCreation config. RestQuery.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster - && SchemaController.systemClasses.indexOf(this.className) === -1) { - return this.config.database.loadSchema() + if ( + this.config.allowClientClassCreation === false && + !this.auth.isMaster && + SchemaController.systemClasses.indexOf(this.className) === -1 + ) { + return this.config.database + .loadSchema() .then(schemaController => schemaController.hasClass(this.className)) .then(hasClass => { if (hasClass !== true) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, 'This user is not allowed to access ' + - 'non-existent class: ' + this.className); + 'non-existent class: ' + + this.className + ); } }); } else { @@ -232,7 +343,7 @@ function transformInQuery(inQueryObject, className, results) { values.push({ __type: 'Pointer', className: className, - objectId: result.objectId + objectId: result.objectId, }); } delete inQueryObject['$inQuery']; @@ -256,23 +367,31 @@ RestQuery.prototype.replaceInQuery = function() { // The inQuery value must have precisely two keys - where and className var inQueryValue = inQueryObject['$inQuery']; if (!inQueryValue.where || !inQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $inQuery'); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'improper usage of $inQuery' + ); } const additionalOptions = { - redirectClassNameForKey: inQueryValue.redirectClassNameForKey + redirectClassNameForKey: inQueryValue.redirectClassNameForKey, }; if (this.restOptions.subqueryReadPreference) { additionalOptions.readPreference = this.restOptions.subqueryReadPreference; additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; } var subquery = new RestQuery( - this.config, this.auth, inQueryValue.className, - inQueryValue.where, additionalOptions); - return subquery.execute().then((response) => { + this.config, + this.auth, + inQueryValue.className, + inQueryValue.where, + additionalOptions + ); + return subquery.execute().then(response => { transformInQuery(inQueryObject, subquery.className, response.results); // Recurse to repeat return this.replaceInQuery(); @@ -285,7 +404,7 @@ function transformNotInQuery(notInQueryObject, className, results) { values.push({ __type: 'Pointer', className: className, - objectId: result.objectId + objectId: result.objectId, }); } delete notInQueryObject['$notInQuery']; @@ -309,33 +428,49 @@ RestQuery.prototype.replaceNotInQuery = function() { // The notInQuery value must have precisely two keys - where and className var notInQueryValue = notInQueryObject['$notInQuery']; if (!notInQueryValue.where || !notInQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $notInQuery'); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'improper usage of $notInQuery' + ); } const additionalOptions = { - redirectClassNameForKey: notInQueryValue.redirectClassNameForKey + redirectClassNameForKey: notInQueryValue.redirectClassNameForKey, }; if (this.restOptions.subqueryReadPreference) { additionalOptions.readPreference = this.restOptions.subqueryReadPreference; additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; } var subquery = new RestQuery( - this.config, this.auth, notInQueryValue.className, - notInQueryValue.where, additionalOptions); - return subquery.execute().then((response) => { + this.config, + this.auth, + notInQueryValue.className, + notInQueryValue.where, + additionalOptions + ); + return subquery.execute().then(response => { transformNotInQuery(notInQueryObject, subquery.className, response.results); // Recurse to repeat return this.replaceNotInQuery(); }); }; -const transformSelect = (selectObject, key ,objects) => { +// Used to get the deepest object from json using dot notation. +const getDeepestObjectFromKey = (json, key, idx, src) => { + if (key in json) { + return json[key]; + } + src.splice(1); // Exit Early +}; + +const transformSelect = (selectObject, key, objects) => { var values = []; for (var result of objects) { - values.push(result[key]); + values.push(key.split('.').reduce(getDeepestObjectFromKey, result)); } delete selectObject['$select']; if (Array.isArray(selectObject['$in'])) { @@ -343,7 +478,7 @@ const transformSelect = (selectObject, key ,objects) => { } else { selectObject['$in'] = values; } -} +}; // Replaces a $select clause by running the subquery, if there is a // $select clause. @@ -359,38 +494,48 @@ RestQuery.prototype.replaceSelect = function() { // The select value must have precisely two keys - query and key var selectValue = selectObject['$select']; // iOS SDK don't send where if not set, let it pass - if (!selectValue.query || - !selectValue.key || - typeof selectValue.query !== 'object' || - !selectValue.query.className || - Object.keys(selectValue).length !== 2) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $select'); + if ( + !selectValue.query || + !selectValue.key || + typeof selectValue.query !== 'object' || + !selectValue.query.className || + Object.keys(selectValue).length !== 2 + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'improper usage of $select' + ); } const additionalOptions = { - redirectClassNameForKey: selectValue.query.redirectClassNameForKey + redirectClassNameForKey: selectValue.query.redirectClassNameForKey, }; if (this.restOptions.subqueryReadPreference) { additionalOptions.readPreference = this.restOptions.subqueryReadPreference; additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; } var subquery = new RestQuery( - this.config, this.auth, selectValue.query.className, - selectValue.query.where, additionalOptions); - return subquery.execute().then((response) => { + this.config, + this.auth, + selectValue.query.className, + selectValue.query.where, + additionalOptions + ); + return subquery.execute().then(response => { transformSelect(selectObject, selectValue.key, response.results); // Keep replacing $select clauses return this.replaceSelect(); - }) + }); }; const transformDontSelect = (dontSelectObject, key, objects) => { var values = []; for (var result of objects) { - values.push(result[key]); + values.push(key.split('.').reduce(getDeepestObjectFromKey, result)); } delete dontSelectObject['$dontSelect']; if (Array.isArray(dontSelectObject['$nin'])) { @@ -398,7 +543,7 @@ const transformDontSelect = (dontSelectObject, key, objects) => { } else { dontSelectObject['$nin'] = values; } -} +}; // Replaces a $dontSelect clause by running the subquery, if there is a // $dontSelect clause. @@ -413,48 +558,51 @@ RestQuery.prototype.replaceDontSelect = function() { // The dontSelect value must have precisely two keys - query and key var dontSelectValue = dontSelectObject['$dontSelect']; - if (!dontSelectValue.query || - !dontSelectValue.key || - typeof dontSelectValue.query !== 'object' || - !dontSelectValue.query.className || - Object.keys(dontSelectValue).length !== 2) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $dontSelect'); + if ( + !dontSelectValue.query || + !dontSelectValue.key || + typeof dontSelectValue.query !== 'object' || + !dontSelectValue.query.className || + Object.keys(dontSelectValue).length !== 2 + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'improper usage of $dontSelect' + ); } const additionalOptions = { - redirectClassNameForKey: dontSelectValue.query.redirectClassNameForKey + redirectClassNameForKey: dontSelectValue.query.redirectClassNameForKey, }; if (this.restOptions.subqueryReadPreference) { additionalOptions.readPreference = this.restOptions.subqueryReadPreference; additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; } var subquery = new RestQuery( - this.config, this.auth, dontSelectValue.query.className, - dontSelectValue.query.where, additionalOptions); - return subquery.execute().then((response) => { - transformDontSelect(dontSelectObject, dontSelectValue.key, response.results); + this.config, + this.auth, + dontSelectValue.query.className, + dontSelectValue.query.where, + additionalOptions + ); + return subquery.execute().then(response => { + transformDontSelect( + dontSelectObject, + dontSelectValue.key, + response.results + ); // Keep replacing $dontSelect clauses return this.replaceDontSelect(); - }) + }); }; -const cleanResultOfSensitiveUserInfo = function (result, auth, config) { +const cleanResultAuthData = function(result) { delete result.password; - - if (auth.isMaster || (auth.user && auth.user.id === result.objectId)) { - return; - } - - for (const field of config.userSensitiveFields) { - delete result[field]; - } -}; - -const cleanResultAuthData = function (result) { if (result.authData) { - Object.keys(result.authData).forEach((provider) => { + Object.keys(result.authData).forEach(provider => { if (result.authData[provider] === null) { delete result.authData[provider]; } @@ -466,7 +614,7 @@ const cleanResultAuthData = function (result) { } }; -const replaceEqualityConstraint = (constraint) => { +const replaceEqualityConstraint = constraint => { if (typeof constraint !== 'object') { return constraint; } @@ -483,12 +631,12 @@ const replaceEqualityConstraint = (constraint) => { } if (hasDirectConstraint && hasOperatorConstraint) { constraint['$eq'] = equalToObject; - Object.keys(equalToObject).forEach((key) => { + Object.keys(equalToObject).forEach(key => { delete constraint[key]; }); } return constraint; -} +}; RestQuery.prototype.replaceEquality = function() { if (typeof this.restWhere !== 'object') { @@ -497,29 +645,29 @@ RestQuery.prototype.replaceEquality = function() { for (const key in this.restWhere) { this.restWhere[key] = replaceEqualityConstraint(this.restWhere[key]); } -} +}; // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. RestQuery.prototype.runFind = function(options = {}) { if (this.findOptions.limit === 0) { - this.response = {results: []}; + this.response = { results: [] }; return Promise.resolve(); } const findOptions = Object.assign({}, this.findOptions); if (this.keys) { - findOptions.keys = this.keys.map((key) => { + findOptions.keys = this.keys.map(key => { return key.split('.')[0]; }); } if (options.op) { findOptions.op = options.op; } - return this.config.database.find(this.className, this.restWhere, findOptions) - .then((results) => { + return this.config.database + .find(this.className, this.restWhere, findOptions, this.auth) + .then(results => { if (this.className === '_User') { for (var result of results) { - cleanResultOfSensitiveUserInfo(result, this.auth, this.config); cleanResultAuthData(result); } } @@ -531,7 +679,7 @@ RestQuery.prototype.runFind = function(options = {}) { r.className = this.redirectClassName; } } - this.response = {results: results}; + this.response = { results: results }; }); }; @@ -544,22 +692,75 @@ 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) => { + return this.config.database + .find(this.className, this.restWhere, this.findOptions) + .then(c => { this.response.count = c; }); }; +// Augments this.response with all pointers on an object +RestQuery.prototype.handleIncludeAll = function() { + if (!this.includeAll) { + return; + } + return this.config.database + .loadSchema() + .then(schemaController => schemaController.getOneSchema(this.className)) + .then(schema => { + const includeFields = []; + const keyFields = []; + for (const field in schema.fields) { + if ( + schema.fields[field].type && + schema.fields[field].type === 'Pointer' + ) { + includeFields.push([field]); + keyFields.push(field); + } + } + // Add fields to include, keys, remove dups + this.include = [...new Set([...this.include, ...includeFields])]; + // if this.keys not set, then all keys are already included + if (this.keys) { + this.keys = [...new Set([...this.keys, ...keyFields])]; + } + }); +}; + +// Updates property `this.keys` to contain all keys but the ones unselected. +RestQuery.prototype.handleExcludeKeys = function() { + if (!this.excludeKeys) { + return; + } + if (this.keys) { + this.keys = this.keys.filter(k => !this.excludeKeys.includes(k)); + return; + } + return this.config.database + .loadSchema() + .then(schemaController => schemaController.getOneSchema(this.className)) + .then(schema => { + const fields = Object.keys(schema.fields); + this.keys = fields.filter(k => !this.excludeKeys.includes(k)); + }); +}; + // Augments this.response with data at the paths provided in this.include. RestQuery.prototype.handleInclude = function() { if (this.include.length == 0) { return; } - var pathResponse = includePath(this.config, this.auth, - this.response, this.include[0], this.restOptions); + var pathResponse = includePath( + this.config, + this.auth, + this.response, + this.include[0], + this.restOptions + ); if (pathResponse.then) { - return pathResponse.then((newResponse) => { + return pathResponse.then(newResponse => { this.response = newResponse; this.include = this.include.slice(1); return this.handleInclude(); @@ -577,15 +778,45 @@ RestQuery.prototype.runAfterFindTrigger = function() { if (!this.response) { return; } + if (!this.runAfterFind) { + return; + } // Avoid doing any setup for triggers if there is no 'afterFind' trigger for this class. - const hasAfterFindHook = triggers.triggerExists(this.className, triggers.Types.afterFind, this.config.applicationId); + const hasAfterFindHook = triggers.triggerExists( + this.className, + triggers.Types.afterFind, + this.config.applicationId + ); if (!hasAfterFindHook) { return Promise.resolve(); } + // Skip Aggregate and Distinct Queries + if (this.findOptions.pipeline || this.findOptions.distinct) { + return Promise.resolve(); + } // Run afterFind trigger and set the new results - return triggers.maybeRunAfterFindTrigger(triggers.Types.afterFind, this.auth, this.className,this.response.results, this.config).then((results) => { - this.response.results = results; - }); + return triggers + .maybeRunAfterFindTrigger( + triggers.Types.afterFind, + this.auth, + this.className, + this.response.results, + this.config + ) + .then(results => { + // Ensure we properly set the className back + if (this.redirectClassName) { + this.response.results = results.map(object => { + if (object instanceof Parse.Object) { + object = object.toJSON(); + } + object.className = this.redirectClassName; + return object; + }); + } else { + this.response.results = results; + } + }); }; // Adds included values to the response. @@ -631,42 +862,51 @@ function includePath(config, auth, response, path, restOptions = {}) { if (restOptions.includeReadPreference) { includeRestOptions.readPreference = restOptions.includeReadPreference; - includeRestOptions.includeReadPreference = restOptions.includeReadPreference; + includeRestOptions.includeReadPreference = + restOptions.includeReadPreference; + } else if (restOptions.readPreference) { + includeRestOptions.readPreference = restOptions.readPreference; } - const queryPromises = Object.keys(pointersHash).map((className) => { + const queryPromises = Object.keys(pointersHash).map(className => { const objectIds = Array.from(pointersHash[className]); let where; if (objectIds.length === 1) { - where = {'objectId': objectIds[0]}; + where = { objectId: objectIds[0] }; } else { - where = {'objectId': {'$in': objectIds}}; + where = { objectId: { $in: objectIds } }; } - var query = new RestQuery(config, auth, className, where, includeRestOptions); - return query.execute({op: 'get'}).then((results) => { + var query = new RestQuery( + config, + auth, + className, + where, + includeRestOptions + ); + return query.execute({ op: 'get' }).then(results => { results.className = className; return Promise.resolve(results); - }) - }) + }); + }); // Get the objects for all these object ids - return Promise.all(queryPromises).then((responses) => { + return Promise.all(queryPromises).then(responses => { var replace = responses.reduce((replace, includeResponse) => { for (var obj of includeResponse.results) { obj.__type = 'Object'; obj.className = includeResponse.className; - if (obj.className == "_User" && !auth.isMaster) { + if (obj.className == '_User' && !auth.isMaster) { delete obj.sessionToken; delete obj.authData; } replace[obj.objectId] = obj; } return replace; - }, {}) + }, {}); var resp = { - results: replacePointers(response.results, path, replace) + results: replacePointers(response.results, path, replace), }; if (response.count) { resp.count = response.count; @@ -715,8 +955,9 @@ function findPointers(object, path) { // pointers inflated. function replacePointers(object, path, replace) { if (object instanceof Array) { - return object.map((obj) => replacePointers(obj, path, replace)) - .filter((obj) => typeof obj !== 'undefined'); + return object + .map(obj => replacePointers(obj, path, replace)) + .filter(obj => typeof obj !== 'undefined'); } if (typeof object !== 'object' || !object) { diff --git a/src/RestWrite.js b/src/RestWrite.js index 61b8eea409..38a795bb4d 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -5,15 +5,15 @@ var SchemaController = require('./Controllers/SchemaController'); var deepcopy = require('deepcopy'); -var Auth = require('./Auth'); +const Auth = require('./Auth'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); var ClientSDK = require('./ClientSDK'); import RestQuery from './RestQuery'; -import _ from 'lodash'; -import logger from './logger'; +import _ from 'lodash'; +import logger from './logger'; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -24,9 +24,21 @@ import logger from './logger'; // RestWrite will handle objectId, createdAt, and updatedAt for // everything. It also knows to use triggers and special modifications // for the _User class. -function RestWrite(config, auth, className, query, data, originalData, clientSDK) { +function RestWrite( + config, + auth, + className, + query, + data, + originalData, + clientSDK, + action +) { if (auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot perform a write operation when using readOnlyMasterKey' + ); } this.config = config; this.auth = auth; @@ -34,8 +46,37 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK this.clientSDK = clientSDK; this.storage = {}; this.runOptions = {}; - if (!query && data.objectId) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId is an invalid field name.'); + this.context = {}; + + if (action) { + this.runOptions.action = action; + } + + if (!query) { + if (this.config.allowCustomObjectId) { + if ( + Object.prototype.hasOwnProperty.call(data, 'objectId') && + !data.objectId + ) { + throw new Parse.Error( + Parse.Error.MISSING_OBJECT_ID, + 'objectId must not be empty, null or undefined' + ); + } + } else { + if (data.objectId) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'objectId is an invalid field name.' + ); + } + if (data.id) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'id is an invalid field name.' + ); + } + } } // When the operation is complete, this.response may have several @@ -54,6 +95,10 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK // The timestamp we'll use for this whole operation this.updatedAt = Parse._encode(new Date()).iso; + + // Shared SchemaController to be reused to reduce the number of loadSchema() calls per request + // Once set the schemaData should be immutable + this.validSchemaController = null; } // A convenient method to perform all the steps of processing the @@ -61,39 +106,62 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK // Returns a promise for a {response, status, location} object. // status and location are optional. RestWrite.prototype.execute = function() { - return Promise.resolve().then(() => { - return this.getUserAndRoleACL(); - }).then(() => { - return this.validateClientClassCreation(); - }).then(() => { - return this.handleInstallation(); - }).then(() => { - return this.handleSession(); - }).then(() => { - return this.validateAuthData(); - }).then(() => { - return this.runBeforeTrigger(); - }).then(() => { - return this.validateSchema(); - }).then(() => { - return this.setRequiredFieldsIfNeeded(); - }).then(() => { - return this.transformUser(); - }).then(() => { - return this.expandFilesForExistingObjects(); - }).then(() => { - return this.runDatabaseOperation(); - }).then(() => { - return this.createSessionTokenIfNeeded(); - }).then(() => { - return this.handleFollowup(); - }).then(() => { - return this.runAfterTrigger(); - }).then(() => { - return this.cleanUserAuthData(); - }).then(() => { - return this.response; - }) + return Promise.resolve() + .then(() => { + return this.getUserAndRoleACL(); + }) + .then(() => { + return this.validateClientClassCreation(); + }) + .then(() => { + return this.handleInstallation(); + }) + .then(() => { + return this.handleSession(); + }) + .then(() => { + return this.validateAuthData(); + }) + .then(() => { + return this.runBeforeSaveTrigger(); + }) + .then(() => { + return this.deleteEmailResetTokenIfNeeded(); + }) + .then(() => { + return this.validateSchema(); + }) + .then(schemaController => { + this.validSchemaController = schemaController; + return this.setRequiredFieldsIfNeeded(); + }) + .then(() => { + return this.transformUser(); + }) + .then(() => { + return this.expandFilesForExistingObjects(); + }) + .then(() => { + return this.destroyDuplicatedSessions(); + }) + .then(() => { + return this.runDatabaseOperation(); + }) + .then(() => { + return this.createSessionTokenIfNeeded(); + }) + .then(() => { + return this.handleFollowup(); + }) + .then(() => { + return this.runAfterSaveTrigger(); + }) + .then(() => { + return this.cleanUserAuthData(); + }) + .then(() => { + return this.response; + }); }; // Uses the Auth object to get the list of roles, adds the user id @@ -105,8 +173,10 @@ RestWrite.prototype.getUserAndRoleACL = function() { this.runOptions.acl = ['*']; if (this.auth.user) { - return this.auth.getUserRoles().then((roles) => { - this.runOptions.acl = this.runOptions.acl.concat(roles, [this.auth.user.id]); + return this.auth.getUserRoles().then(roles => { + this.runOptions.acl = this.runOptions.acl.concat(roles, [ + this.auth.user.id, + ]); return; }); } else { @@ -116,15 +186,22 @@ RestWrite.prototype.getUserAndRoleACL = function() { // Validates this operation against the allowClientClassCreation config. RestWrite.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster - && SchemaController.systemClasses.indexOf(this.className) === -1) { - return this.config.database.loadSchema() + if ( + this.config.allowClientClassCreation === false && + !this.auth.isMaster && + SchemaController.systemClasses.indexOf(this.className) === -1 + ) { + return this.config.database + .loadSchema() .then(schemaController => schemaController.hasClass(this.className)) .then(hasClass => { if (hasClass !== true) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, 'This user is not allowed to access ' + - 'non-existent class: ' + this.className); + 'non-existent class: ' + + this.className + ); } }); } else { @@ -134,23 +211,34 @@ RestWrite.prototype.validateClientClassCreation = function() { // Validates this operation against the schema. RestWrite.prototype.validateSchema = function() { - return this.config.database.validateObject(this.className, this.data, this.query, this.runOptions); + return this.config.database.validateObject( + this.className, + this.data, + this.query, + this.runOptions + ); }; // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. -RestWrite.prototype.runBeforeTrigger = function() { +RestWrite.prototype.runBeforeSaveTrigger = function() { if (this.response) { return; } // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. - if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) { + if ( + !triggers.triggerExists( + this.className, + triggers.Types.beforeSave, + this.config.applicationId + ) + ) { return Promise.resolve(); } // Cloud code gets a bit of extra data for its objects - var extraData = {className: this.className}; + var extraData = { className: this.className }; if (this.query && this.query.objectId) { extraData.objectId = this.query.objectId; } @@ -162,37 +250,160 @@ RestWrite.prototype.runBeforeTrigger = function() { originalObject = triggers.inflate(extraData, this.originalData); } - return Promise.resolve().then(() => { - return triggers.maybeRunTrigger(triggers.Types.beforeSave, this.auth, updatedObject, originalObject, this.config); - }).then((response) => { - if (response && response.object) { - this.storage.fieldsChangedByTrigger = _.reduce(response.object, (result, value, key) => { - if (!_.isEqual(this.data[key], value)) { - result.push(key); + return Promise.resolve() + .then(() => { + // Before calling the trigger, validate the permissions for the save operation + let databasePromise = null; + if (this.query) { + // Validate for updating + databasePromise = this.config.database.update( + this.className, + this.query, + this.data, + this.runOptions, + false, + true + ); + } else { + // Validate for creating + databasePromise = this.config.database.create( + this.className, + this.data, + this.runOptions, + true + ); + } + // In the case that there is no permission for the operation, it throws an error + return databasePromise.then(result => { + if (!result || result.length <= 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } + }); + }) + .then(() => { + return triggers.maybeRunTrigger( + triggers.Types.beforeSave, + this.auth, + updatedObject, + originalObject, + this.config, + this.context + ); + }) + .then(response => { + if (response && response.object) { + this.storage.fieldsChangedByTrigger = _.reduce( + response.object, + (result, value, key) => { + if (!_.isEqual(this.data[key], value)) { + result.push(key); + } + return result; + }, + [] + ); + this.data = response.object; + // We should delete the objectId for an update write + if (this.query && this.query.objectId) { + delete this.data.objectId; } - return result; - }, []); - this.data = response.object; - // We should delete the objectId for an update write - if (this.query && this.query.objectId) { - delete this.data.objectId } - } - }); + }); +}; + +RestWrite.prototype.runBeforeLoginTrigger = async function(userData) { + // Avoid doing any setup for triggers if there is no 'beforeLogin' trigger + if ( + !triggers.triggerExists( + this.className, + triggers.Types.beforeLogin, + this.config.applicationId + ) + ) { + return; + } + + // Cloud code gets a bit of extra data for its objects + const extraData = { className: this.className }; + const user = triggers.inflate(extraData, userData); + + // no need to return a response + await triggers.maybeRunTrigger( + triggers.Types.beforeLogin, + this.auth, + user, + null, + this.config, + this.context + ); }; RestWrite.prototype.setRequiredFieldsIfNeeded = function() { if (this.data) { - // Add default fields - this.data.updatedAt = this.updatedAt; - if (!this.query) { - this.data.createdAt = this.updatedAt; + return this.validSchemaController.getAllClasses().then(allClasses => { + const schema = allClasses.find( + oneClass => oneClass.className === this.className + ); + const setRequiredFieldIfNeeded = (fieldName, setDefault) => { + if ( + this.data[fieldName] === undefined || + this.data[fieldName] === null || + this.data[fieldName] === '' || + (typeof this.data[fieldName] === 'object' && + this.data[fieldName].__op === 'Delete') + ) { + if ( + setDefault && + schema.fields[fieldName] && + schema.fields[fieldName].defaultValue !== null && + schema.fields[fieldName].defaultValue !== undefined && + (this.data[fieldName] === undefined || + (typeof this.data[fieldName] === 'object' && + this.data[fieldName].__op === 'Delete')) + ) { + this.data[fieldName] = schema.fields[fieldName].defaultValue; + this.storage.fieldsChangedByTrigger = + this.storage.fieldsChangedByTrigger || []; + if (this.storage.fieldsChangedByTrigger.indexOf(fieldName) < 0) { + this.storage.fieldsChangedByTrigger.push(fieldName); + } + } else if ( + schema.fields[fieldName] && + schema.fields[fieldName].required === true + ) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `${fieldName} is required` + ); + } + } + }; - // Only assign new objectId if we are creating new object - if (!this.data.objectId) { - this.data.objectId = cryptoUtils.newObjectId(this.config.objectIdSize); + // Add default fields + this.data.updatedAt = this.updatedAt; + if (!this.query) { + this.data.createdAt = this.updatedAt; + + // Only assign new objectId if we are creating new object + if (!this.data.objectId) { + this.data.objectId = cryptoUtils.newObjectId( + this.config.objectIdSize + ); + } + if (schema) { + Object.keys(schema.fields).forEach(fieldName => { + setRequiredFieldIfNeeded(fieldName, true); + }); + } + } else if (schema) { + Object.keys(this.data).forEach(fieldName => { + setRequiredFieldIfNeeded(fieldName, false); + }); } - } + }); } return Promise.resolve(); }; @@ -206,18 +417,41 @@ RestWrite.prototype.validateAuthData = function() { } if (!this.query && !this.data.authData) { - if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'bad or missing username'); + if ( + typeof this.data.username !== 'string' || + _.isEmpty(this.data.username) + ) { + throw new Parse.Error( + Parse.Error.USERNAME_MISSING, + 'bad or missing username' + ); } - if (typeof this.data.password !== 'string' || _.isEmpty(this.data.password)) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required'); + if ( + typeof this.data.password !== 'string' || + _.isEmpty(this.data.password) + ) { + throw new Parse.Error( + Parse.Error.PASSWORD_MISSING, + 'password is required' + ); } } - if (!this.data.authData || !Object.keys(this.data.authData).length) { + if ( + (this.data.authData && !Object.keys(this.data.authData).length) || + !Object.prototype.hasOwnProperty.call(this.data, 'authData') + ) { + // Handle saving authData to {} or if authData doesn't exist return; + } else if ( + Object.prototype.hasOwnProperty.call(this.data, 'authData') && + !this.data.authData + ) { + // Handle saving authData to null + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); } var authData = this.data.authData; @@ -225,74 +459,87 @@ RestWrite.prototype.validateAuthData = function() { if (providers.length > 0) { const canHandleAuthData = providers.reduce((canHandle, provider) => { var providerAuthData = authData[provider]; - var hasToken = (providerAuthData && providerAuthData.id); + var hasToken = providerAuthData && providerAuthData.id; return canHandle && (hasToken || providerAuthData == null); }, true); if (canHandleAuthData) { return this.handleAuthData(authData); } } - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); }; RestWrite.prototype.handleAuthDataValidation = function(authData) { - const validations = Object.keys(authData).map((provider) => { + const validations = Object.keys(authData).map(provider => { if (authData[provider] === null) { return Promise.resolve(); } - const validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); + const validateAuthData = this.config.authDataManager.getValidatorForProvider( + provider + ); if (!validateAuthData) { - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); } return validateAuthData(authData[provider]); }); return Promise.all(validations); -} +}; RestWrite.prototype.findUsersWithAuthData = function(authData) { const providers = Object.keys(authData); - const query = providers.reduce((memo, provider) => { - if (!authData[provider]) { + const query = providers + .reduce((memo, provider) => { + if (!authData[provider]) { + return memo; + } + const queryKey = `authData.${provider}.id`; + const query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); return memo; - } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []).filter((q) => { - return typeof q !== 'undefined'; - }); + }, []) + .filter(q => { + return typeof q !== 'undefined'; + }); let findPromise = Promise.resolve([]); if (query.length > 0) { - findPromise = this.config.database.find( - this.className, - {'$or': query}, {}) + findPromise = this.config.database.find(this.className, { $or: query }, {}); } return findPromise; -} +}; +RestWrite.prototype.filteredObjectsByACL = function(objects) { + if (this.auth.isMaster) { + return objects; + } + return objects.filter(object => { + if (!object.ACL) { + return true; // legacy users that have no ACL field on them + } + // Regular users that have been locked out. + return object.ACL && Object.keys(object.ACL).length > 0; + }); +}; RestWrite.prototype.handleAuthData = function(authData) { let results; - return this.findUsersWithAuthData(authData).then((r) => { - results = r; - if (results.length > 1) { - // More than 1 user with the passed id's - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); - } + return this.findUsersWithAuthData(authData).then(async r => { + results = this.filteredObjectsByACL(r); - this.storage['authProvider'] = Object.keys(authData).join(','); + if (results.length == 1) { + this.storage['authProvider'] = Object.keys(authData).join(','); - if (results.length > 0) { const userResult = results[0]; const mutatedAuthData = {}; - Object.keys(authData).forEach((provider) => { + Object.keys(authData).forEach(provider => { const providerData = authData[provider]; const userAuthData = userResult.authData[provider]; if (!_.isEqual(providerData, userAuthData)) { @@ -306,7 +553,8 @@ RestWrite.prototype.handleAuthData = function(authData) { } else if (this.auth && this.auth.user && this.auth.user.id) { userId = this.auth.user.id; } - if (!userId || userId === userResult.objectId) { // no user making the call + if (!userId || userId === userResult.objectId) { + // no user making the call // OR the user making the call is the right one // Login with auth data delete results[0].password; @@ -314,12 +562,18 @@ RestWrite.prototype.handleAuthData = function(authData) { // need to set the objectId first otherwise location has trailing undefined this.data.objectId = userResult.objectId; - if (!this.query || !this.query.objectId) { // this a login call, no userId passed + if (!this.query || !this.query.objectId) { + // this a login call, no userId passed this.response = { response: userResult, - location: this.location() + location: this.location(), }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(userResult)); } + // If we didn't change the auth data, just keep going if (!hasMutatedAuthData) { return; @@ -328,28 +582,37 @@ RestWrite.prototype.handleAuthData = function(authData) { // that can happen when token are refreshed, // We should update the token and let the user in // We should only check the mutated keys - return this.handleAuthDataValidation(mutatedAuthData).then(() => { + return this.handleAuthDataValidation(mutatedAuthData).then(async () => { // IF we have a response, we'll skip the database operation / beforeSave / afterSave etc... // we need to set it up there. // We are supposed to have a response only on LOGIN with authData, so we skip those // If we're not logging in, but just updating the current user, we can safely skip that part if (this.response) { // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach((provider) => { - this.response.response.authData[provider] = mutatedAuthData[provider]; + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = + mutatedAuthData[provider]; }); + // Run the DB update directly, as 'master' // Just update the authData part // Then we're good for the user, early exit of sorts - return this.config.database.update(this.className, {objectId: this.data.objectId}, {authData: mutatedAuthData}, {}); + return this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: mutatedAuthData }, + {} + ); } }); } else if (userId) { // Trying to update auth data but users // are different if (userResult.objectId !== userId) { - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); + throw new Parse.Error( + Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used' + ); } // No auth data was mutated, just keep going if (!hasMutatedAuthData) { @@ -357,10 +620,17 @@ RestWrite.prototype.handleAuthData = function(authData) { } } } - return this.handleAuthDataValidation(authData); + return this.handleAuthDataValidation(authData).then(() => { + if (results.length > 1) { + // More than 1 user with the passed id's + throw new Parse.Error( + Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used' + ); + } + }); }); -} - +}; // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function() { @@ -370,8 +640,8 @@ RestWrite.prototype.transformUser = function() { return promise; } - if (!this.auth.isMaster && "emailVerified" in this.data) { - const error = `Clients aren't allowed to manually update email verification.` + if (!this.auth.isMaster && 'emailVerified' in this.data) { + const error = `Clients aren't allowed to manually update email verification.`; throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } @@ -381,45 +651,51 @@ RestWrite.prototype.transformUser = function() { // session tokens, and remove them from the cache. promise = new RestQuery(this.config, Auth.master(this.config), '_Session', { user: { - __type: "Pointer", - className: "_User", + __type: 'Pointer', + className: '_User', objectId: this.objectId(), - } - }).execute() + }, + }) + .execute() .then(results => { - results.results.forEach(session => this.config.cacheController.user.del(session.sessionToken)); + results.results.forEach(session => + this.config.cacheController.user.del(session.sessionToken) + ); }); } - return promise.then(() => { - // Transform the password - if (this.data.password === undefined) { // ignore only if undefined. should proceed if empty ('') - return Promise.resolve(); - } + return promise + .then(() => { + // Transform the password + if (this.data.password === undefined) { + // ignore only if undefined. should proceed if empty ('') + return Promise.resolve(); + } - if (this.query) { - this.storage['clearSessions'] = true; - // Generate a new session only if the user requested - if (!this.auth.isMaster) { - this.storage['generateNewSession'] = true; + if (this.query) { + this.storage['clearSessions'] = true; + // Generate a new session only if the user requested + if (!this.auth.isMaster) { + this.storage['generateNewSession'] = true; + } } - } - return this._validatePasswordPolicy().then(() => { - return passwordCrypto.hash(this.data.password).then((hashedPassword) => { - this.data._hashed_password = hashedPassword; - delete this.data.password; + return this._validatePasswordPolicy().then(() => { + return passwordCrypto.hash(this.data.password).then(hashedPassword => { + this.data._hashed_password = hashedPassword; + delete this.data.password; + }); }); + }) + .then(() => { + return this._validateUserName(); + }) + .then(() => { + return this._validateEmail(); }); - - }).then(() => { - return this._validateUserName(); - }).then(() => { - return this._validateEmail(); - }); }; -RestWrite.prototype._validateUserName = function () { +RestWrite.prototype._validateUserName = function() { // Check for username uniqueness if (!this.data.username) { if (!this.query) { @@ -428,81 +704,148 @@ RestWrite.prototype._validateUserName = function () { } return Promise.resolve(); } - // We need to a find to check for duplicate username in case they are missing the unique index on usernames - // TODO: Check if there is a unique index, and if so, skip this query. - return this.config.database.find( - this.className, - {username: this.data.username, objectId: {'$ne': this.objectId()}}, - {limit: 1} - ).then(results => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.'); - } - return; - }); + /* + Usernames should be unique when compared case insensitively + + Users should be able to make case sensitive usernames and + login using the case they entered. I.e. 'Snoopy' should preclude + 'snoopy' as a valid username. + */ + return this.config.database + .find( + this.className, + { + username: this.data.username, + objectId: { $ne: this.objectId() }, + }, + { limit: 1, caseInsensitive: true }, + {}, + this.validSchemaController + ) + .then(results => { + if (results.length > 0) { + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); + } + return; + }); }; +/* + As with usernames, Parse should not allow case insensitive collisions of email. + unlike with usernames (which can have case insensitive collisions in the case of + auth adapters), emails should never have a case insensitive collision. + + This behavior can be enforced through a properly configured index see: + https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index + which could be implemented instead of this code based validation. + + Given that this lookup should be a relatively low use case and that the case sensitive + unique index will be used by the db for the query, this is an adequate solution. +*/ RestWrite.prototype._validateEmail = function() { if (!this.data.email || this.data.email.__op === 'Delete') { return Promise.resolve(); } // Validate basic email address format if (!this.data.email.match(/^.+@.+$/)) { - return Promise.reject(new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'Email address format is invalid.')); + return Promise.reject( + new Parse.Error( + Parse.Error.INVALID_EMAIL_ADDRESS, + 'Email address format is invalid.' + ) + ); } - // Same problem for email as above for username - return this.config.database.find( - this.className, - {email: this.data.email, objectId: {'$ne': this.objectId()}}, - {limit: 1} - ).then(results => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.'); - } - if ( - !this.data.authData || - !Object.keys(this.data.authData).length || - Object.keys(this.data.authData).length === 1 && Object.keys(this.data.authData)[0] === 'anonymous' - ) { - // We updated the email, send a new validation - this.storage['sendVerificationEmail'] = true; - this.config.userController.setEmailVerifyToken(this.data); - } - }); + // Case insensitive match, see note above function. + return this.config.database + .find( + this.className, + { + email: this.data.email, + objectId: { $ne: this.objectId() }, + }, + { limit: 1, caseInsensitive: true }, + {}, + this.validSchemaController + ) + .then(results => { + if (results.length > 0) { + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); + } + if ( + !this.data.authData || + !Object.keys(this.data.authData).length || + (Object.keys(this.data.authData).length === 1 && + Object.keys(this.data.authData)[0] === 'anonymous') + ) { + // We updated the email, send a new validation + this.storage['sendVerificationEmail'] = true; + this.config.userController.setEmailVerifyToken(this.data); + } + }); }; RestWrite.prototype._validatePasswordPolicy = function() { - if (!this.config.passwordPolicy) - return Promise.resolve(); + if (!this.config.passwordPolicy) return Promise.resolve(); return this._validatePasswordRequirements().then(() => { return this._validatePasswordHistory(); }); }; - RestWrite.prototype._validatePasswordRequirements = function() { // check if the password conforms to the defined password policy if configured - const policyError = 'Password does not meet the Password Policy requirements.'; + // If we specified a custom error in our configuration use it. + // Example: "Passwords must include a Capital Letter, Lowercase Letter, and a number." + // + // This is especially useful on the generic "password reset" page, + // as it allows the programmer to communicate specific requirements instead of: + // a. making the user guess whats wrong + // b. making a custom password reset page that shows the requirements + const policyError = this.config.passwordPolicy.validationError + ? this.config.passwordPolicy.validationError + : 'Password does not meet the Password Policy requirements.'; + const containsUsernameError = 'Password cannot contain your username.'; // check whether the password meets the password strength requirements - if (this.config.passwordPolicy.patternValidator && !this.config.passwordPolicy.patternValidator(this.data.password) || - this.config.passwordPolicy.validatorCallback && !this.config.passwordPolicy.validatorCallback(this.data.password)) { - return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError)); + if ( + (this.config.passwordPolicy.patternValidator && + !this.config.passwordPolicy.patternValidator(this.data.password)) || + (this.config.passwordPolicy.validatorCallback && + !this.config.passwordPolicy.validatorCallback(this.data.password)) + ) { + return Promise.reject( + new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError) + ); } // check whether password contain username if (this.config.passwordPolicy.doNotAllowUsername === true) { - if (this.data.username) { // username is not passed during password reset + if (this.data.username) { + // username is not passed during password reset if (this.data.password.indexOf(this.data.username) >= 0) - return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError)); - } else { // retrieve the User object using objectId during password reset - return this.config.database.find('_User', {objectId: this.objectId()}) + return Promise.reject( + new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) + ); + } else { + // retrieve the User object using objectId during password reset + return this.config.database + .find('_User', { objectId: this.objectId() }) .then(results => { if (results.length != 1) { throw undefined; } if (this.data.password.indexOf(results[0].username) >= 0) - return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError)); + return Promise.reject( + new Parse.Error( + Parse.Error.VALIDATION_ERROR, + containsUsernameError + ) + ); return Promise.resolve(); }); } @@ -513,7 +856,12 @@ RestWrite.prototype._validatePasswordRequirements = function() { RestWrite.prototype._validatePasswordHistory = function() { // check whether password is repeating from specified history if (this.query && this.config.passwordPolicy.maxPasswordHistory) { - return this.config.database.find('_User', {objectId: this.objectId()}, {keys: ["_password_history", "_hashed_password"]}) + return this.config.database + .find( + '_User', + { objectId: this.objectId() }, + { keys: ['_password_history', '_hashed_password'] } + ) .then(results => { if (results.length != 1) { throw undefined; @@ -521,25 +869,37 @@ RestWrite.prototype._validatePasswordHistory = function() { const user = results[0]; let oldPasswords = []; if (user._password_history) - oldPasswords = _.take(user._password_history, this.config.passwordPolicy.maxPasswordHistory - 1); + oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory - 1 + ); oldPasswords.push(user.password); const newPassword = this.data.password; // compare the new password hash with all old password hashes - const promises = oldPasswords.map(function (hash) { - return passwordCrypto.compare(newPassword, hash).then((result) => { - if (result) // reject if there is a match - return Promise.reject("REPEAT_PASSWORD"); + const promises = oldPasswords.map(function(hash) { + return passwordCrypto.compare(newPassword, hash).then(result => { + if (result) + // reject if there is a match + return Promise.reject('REPEAT_PASSWORD'); return Promise.resolve(); - }) + }); }); // wait for all comparisons to complete - return Promise.all(promises).then(() => { - return Promise.resolve(); - }).catch(err => { - if (err === "REPEAT_PASSWORD") // a match was found - return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.`)); - throw err; - }); + return Promise.all(promises) + .then(() => { + return Promise.resolve(); + }) + .catch(err => { + if (err === 'REPEAT_PASSWORD') + // a match was found + return Promise.reject( + new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` + ) + ); + throw err; + }); }); } return Promise.resolve(); @@ -549,77 +909,112 @@ RestWrite.prototype.createSessionTokenIfNeeded = function() { if (this.className !== '_User') { return; } - if (this.query) { + // Don't generate session for updating user (this.query is set) unless authData exists + if (this.query && !this.data.authData) { + return; + } + // Don't generate new sessionToken if linking via sessionToken + if (this.auth.user && this.data.authData) { return; } - if (!this.storage['authProvider'] // signup call, with - && this.config.preventLoginWithUnverifiedEmail // no login without verification - && this.config.verifyUserEmails) { // verification is on + if ( + !this.storage['authProvider'] && // signup call, with + this.config.preventLoginWithUnverifiedEmail && // no login without verification + this.config.verifyUserEmails + ) { + // verification is on return; // do not create the session token in that case! } return this.createSessionToken(); -} +}; -RestWrite.prototype.createSessionToken = function() { +RestWrite.prototype.createSessionToken = async function() { // cloud installationId from Cloud Code, // never create session tokens from there. if (this.auth.installationId && this.auth.installationId === 'cloud') { return; } - var token = 'r:' + cryptoUtils.newToken(); - var expiresAt = this.config.generateSessionExpiresAt(); - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId() - }, + const { sessionData, createSession } = Auth.createSession(this.config, { + userId: this.objectId(), createdWith: { - 'action': this.storage['authProvider'] ? 'login' : 'signup', - 'authProvider': this.storage['authProvider'] || 'password' + action: this.storage['authProvider'] ? 'login' : 'signup', + authProvider: this.storage['authProvider'] || 'password', }, - restricted: false, installationId: this.auth.installationId, - expiresAt: Parse._encode(expiresAt) - }; + }); + if (this.response && this.response.response) { - this.response.response.sessionToken = token; + this.response.response.sessionToken = sessionData.sessionToken; + } + + return createSession(); +}; + +// Delete email reset tokens if user is changing password or email. +RestWrite.prototype.deleteEmailResetTokenIfNeeded = function() { + if (this.className !== '_User' || this.query === null) { + // null query means create + return; } + if ('password' in this.data || 'email' in this.data) { + const addOps = { + _perishable_token: { __op: 'Delete' }, + _perishable_token_expires_at: { __op: 'Delete' }, + }; + this.data = Object.assign(this.data, addOps); + } +}; + +RestWrite.prototype.destroyDuplicatedSessions = function() { + // Only for _Session, and at creation time + if (this.className != '_Session' || this.query) { + return; + } // Destroy the sessions in 'Background' - this.config.database.destroy('_Session', { - user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId() + const { user, installationId, sessionToken } = this.data; + if (!user || !installationId) { + return; + } + if (!user.objectId) { + return; + } + this.config.database.destroy( + '_Session', + { + user, + installationId, + sessionToken: { $ne: sessionToken }, }, - installationId: this.auth.installationId, - sessionToken: { '$ne': token }, - }); - return new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData).execute(); -} + {}, + this.validSchemaController + ); +}; // Handles any followup logic RestWrite.prototype.handleFollowup = function() { - if (this.storage && this.storage['clearSessions'] && this.config.revokeSessionOnPasswordReset) { + if ( + this.storage && + this.storage['clearSessions'] && + this.config.revokeSessionOnPasswordReset + ) { var sessionQuery = { user: { __type: 'Pointer', className: '_User', - objectId: this.objectId() - } + objectId: this.objectId(), + }, }; delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) + return this.config.database + .destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } if (this.storage && this.storage['generateNewSession']) { delete this.storage['generateNewSession']; - return this.createSessionToken() - .then(this.handleFollowup.bind(this)); + return this.createSessionToken().then(this.handleFollowup.bind(this)); } if (this.storage && this.storage['sendVerificationEmail']) { @@ -638,18 +1033,26 @@ RestWrite.prototype.handleSession = function() { } if (!this.auth.user && !this.auth.isMaster) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.' + ); } // TODO: Verify proper error to throw if (this.data.ACL) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + - 'ACL on a Session.'); + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Cannot set ' + 'ACL on a Session.' + ); } if (this.query) { - if (this.data.user && !this.auth.isMaster && this.data.user.objectId != this.auth.user.id) { + if ( + this.data.user && + !this.auth.isMaster && + this.data.user.objectId != this.auth.user.id + ) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); } else if (this.data.installationId) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); @@ -659,38 +1062,34 @@ RestWrite.prototype.handleSession = function() { } if (!this.query && !this.auth.isMaster) { - var token = 'r:' + cryptoUtils.newToken(); - var expiresAt = this.config.generateSessionExpiresAt(); - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: this.auth.user.id - }, - createdWith: { - 'action': 'create' - }, - restricted: true, - expiresAt: Parse._encode(expiresAt) - }; + const additionalSessionData = {}; for (var key in this.data) { if (key === 'objectId' || key === 'user') { continue; } - sessionData[key] = this.data[key]; + additionalSessionData[key] = this.data[key]; } - var create = new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData); - return create.execute().then((results) => { + + const { sessionData, createSession } = Auth.createSession(this.config, { + userId: this.auth.user.id, + createdWith: { + action: 'create', + }, + additionalSessionData, + }); + + return createSession().then(results => { if (!results.response) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, - 'Error creating session.'); + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Error creating session.' + ); } sessionData['objectId'] = results.response['objectId']; this.response = { status: 201, location: results.location, - response: sessionData + response: sessionData, }; }); } @@ -706,10 +1105,17 @@ RestWrite.prototype.handleInstallation = function() { return; } - if (!this.query && !this.data.deviceToken && !this.data.installationId && !this.auth.installationId) { - throw new Parse.Error(135, + if ( + !this.query && + !this.data.deviceToken && + !this.data.installationId && + !this.auth.installationId + ) { + throw new Parse.Error( + 135, 'at least one ID field (deviceToken, installationId) ' + - 'must be specified in this operation'); + 'must be specified in this operation' + ); } // If the device token is 64 characters long, we assume it is for iOS @@ -735,8 +1141,12 @@ RestWrite.prototype.handleInstallation = function() { } // Updating _Installation but not updating anything critical - if (this.query && !this.data.deviceToken - && !installationId && !this.data.deviceType) { + if ( + this.query && + !this.data.deviceToken && + !installationId && + !this.data.deviceType + ) { return; } @@ -751,111 +1161,140 @@ RestWrite.prototype.handleInstallation = function() { const orQueries = []; if (this.query && this.query.objectId) { orQueries.push({ - objectId: this.query.objectId + objectId: this.query.objectId, }); } if (installationId) { orQueries.push({ - 'installationId': installationId + installationId: installationId, }); } if (this.data.deviceToken) { - orQueries.push({'deviceToken': this.data.deviceToken}); + orQueries.push({ deviceToken: this.data.deviceToken }); } if (orQueries.length == 0) { return; } - promise = promise.then(() => { - return this.config.database.find('_Installation', { - '$or': orQueries - }, {}); - }).then((results) => { - results.forEach((result) => { - if (this.query && this.query.objectId && result.objectId == this.query.objectId) { - objectIdMatch = result; - } - if (result.installationId == installationId) { - installationIdMatch = result; - } - if (result.deviceToken == this.data.deviceToken) { - deviceTokenMatches.push(result); - } - }); + promise = promise + .then(() => { + return this.config.database.find( + '_Installation', + { + $or: orQueries, + }, + {} + ); + }) + .then(results => { + results.forEach(result => { + if ( + this.query && + this.query.objectId && + result.objectId == this.query.objectId + ) { + objectIdMatch = result; + } + if (result.installationId == installationId) { + installationIdMatch = result; + } + if (result.deviceToken == this.data.deviceToken) { + deviceTokenMatches.push(result); + } + }); - // Sanity checks when running a query - if (this.query && this.query.objectId) { - if (!objectIdMatch) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found for update.'); - } - if (this.data.installationId && objectIdMatch.installationId && - this.data.installationId !== objectIdMatch.installationId) { - throw new Parse.Error(136, - 'installationId may not be changed in this ' + - 'operation'); - } - if (this.data.deviceToken && objectIdMatch.deviceToken && + // Sanity checks when running a query + if (this.query && this.query.objectId) { + if (!objectIdMatch) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found for update.' + ); + } + if ( + this.data.installationId && + objectIdMatch.installationId && + this.data.installationId !== objectIdMatch.installationId + ) { + throw new Parse.Error( + 136, + 'installationId may not be changed in this ' + 'operation' + ); + } + if ( + this.data.deviceToken && + objectIdMatch.deviceToken && this.data.deviceToken !== objectIdMatch.deviceToken && - !this.data.installationId && !objectIdMatch.installationId) { - throw new Parse.Error(136, - 'deviceToken may not be changed in this ' + - 'operation'); - } - if (this.data.deviceType && this.data.deviceType && - this.data.deviceType !== objectIdMatch.deviceType) { - throw new Parse.Error(136, - 'deviceType may not be changed in this ' + - 'operation'); + !this.data.installationId && + !objectIdMatch.installationId + ) { + throw new Parse.Error( + 136, + 'deviceToken may not be changed in this ' + 'operation' + ); + } + if ( + this.data.deviceType && + this.data.deviceType && + this.data.deviceType !== objectIdMatch.deviceType + ) { + throw new Parse.Error( + 136, + 'deviceType may not be changed in this ' + 'operation' + ); + } } - } - if (this.query && this.query.objectId && objectIdMatch) { - idMatch = objectIdMatch; - } - - if (installationId && installationIdMatch) { - idMatch = installationIdMatch; - } - // need to specify deviceType only if it's new - if (!this.query && !this.data.deviceType && !idMatch) { - throw new Parse.Error(135, - 'deviceType must be specified in this operation'); - } + if (this.query && this.query.objectId && objectIdMatch) { + idMatch = objectIdMatch; + } - }).then(() => { - if (!idMatch) { - if (!deviceTokenMatches.length) { - return; - } else if (deviceTokenMatches.length == 1 && - (!deviceTokenMatches[0]['installationId'] || !installationId) - ) { - // Single match on device token but none on installationId, and either - // the passed object or the match is missing an installationId, so we - // can just return the match. - return deviceTokenMatches[0]['objectId']; - } else if (!this.data.installationId) { - throw new Parse.Error(132, - 'Must specify installationId when deviceToken ' + - 'matches multiple Installation objects'); - } else { - // Multiple device token matches and we specified an installation ID, - // or a single match where both the passed and matching objects have - // an installation ID. Try cleaning out old installations that match - // the deviceToken, and return nil to signal that a new object should - // be created. - var delQuery = { - 'deviceToken': this.data.deviceToken, - 'installationId': { - '$ne': installationId + if (installationId && installationIdMatch) { + idMatch = installationIdMatch; + } + // need to specify deviceType only if it's new + if (!this.query && !this.data.deviceType && !idMatch) { + throw new Parse.Error( + 135, + 'deviceType must be specified in this operation' + ); + } + }) + .then(() => { + if (!idMatch) { + if (!deviceTokenMatches.length) { + return; + } else if ( + deviceTokenMatches.length == 1 && + (!deviceTokenMatches[0]['installationId'] || !installationId) + ) { + // Single match on device token but none on installationId, and either + // the passed object or the match is missing an installationId, so we + // can just return the match. + return deviceTokenMatches[0]['objectId']; + } else if (!this.data.installationId) { + throw new Parse.Error( + 132, + 'Must specify installationId when deviceToken ' + + 'matches multiple Installation objects' + ); + } else { + // Multiple device token matches and we specified an installation ID, + // or a single match where both the passed and matching objects have + // an installation ID. Try cleaning out old installations that match + // the deviceToken, and return nil to signal that a new object should + // be created. + var delQuery = { + deviceToken: this.data.deviceToken, + installationId: { + $ne: installationId, + }, + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; } - }; - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; - } - this.config.database.destroy('_Installation', delQuery) - .catch(err => { + this.config.database.destroy('_Installation', delQuery).catch(err => { if (err.code == Parse.Error.OBJECT_NOT_FOUND) { // no deletions were made. Can be ignored. return; @@ -863,77 +1302,87 @@ RestWrite.prototype.handleInstallation = function() { // rethrow the error throw err; }); - return; - } - } else { - if (deviceTokenMatches.length == 1 && - !deviceTokenMatches[0]['installationId']) { - // Exactly one device token match and it doesn't have an installation - // ID. This is the one case where we want to merge with the existing - // object. - const delQuery = {objectId: idMatch.objectId}; - return this.config.database.destroy('_Installation', delQuery) - .then(() => { - return deviceTokenMatches[0]['objectId']; - }) - .catch(err => { - if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored - return; - } - // rethrow the error - throw err; - }); + return; + } } else { - if (this.data.deviceToken && - idMatch.deviceToken != this.data.deviceToken) { - // We're setting the device token on an existing installation, so - // we should try cleaning out old installations that match this - // device token. - const delQuery = { - 'deviceToken': this.data.deviceToken, - }; - // We have a unique install Id, use that to preserve - // the interesting installation - if (this.data.installationId) { - delQuery['installationId'] = { - '$ne': this.data.installationId - } - } else if (idMatch.objectId && this.data.objectId - && idMatch.objectId == this.data.objectId) { - // we passed an objectId, preserve that instalation - delQuery['objectId'] = { - '$ne': idMatch.objectId - } - } else { - // What to do here? can't really clean up everything... - return idMatch.objectId; - } - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; - } - this.config.database.destroy('_Installation', delQuery) + if ( + deviceTokenMatches.length == 1 && + !deviceTokenMatches[0]['installationId'] + ) { + // Exactly one device token match and it doesn't have an installation + // ID. This is the one case where we want to merge with the existing + // object. + const delQuery = { objectId: idMatch.objectId }; + return this.config.database + .destroy('_Installation', delQuery) + .then(() => { + return deviceTokenMatches[0]['objectId']; + }) .catch(err => { if (err.code == Parse.Error.OBJECT_NOT_FOUND) { - // no deletions were made. Can be ignored. + // no deletions were made. Can be ignored return; } // rethrow the error throw err; }); + } else { + if ( + this.data.deviceToken && + idMatch.deviceToken != this.data.deviceToken + ) { + // We're setting the device token on an existing installation, so + // we should try cleaning out old installations that match this + // device token. + const delQuery = { + deviceToken: this.data.deviceToken, + }; + // We have a unique install Id, use that to preserve + // the interesting installation + if (this.data.installationId) { + delQuery['installationId'] = { + $ne: this.data.installationId, + }; + } else if ( + idMatch.objectId && + this.data.objectId && + idMatch.objectId == this.data.objectId + ) { + // we passed an objectId, preserve that instalation + delQuery['objectId'] = { + $ne: idMatch.objectId, + }; + } else { + // What to do here? can't really clean up everything... + return idMatch.objectId; + } + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + this.config.database + .destroy('_Installation', delQuery) + .catch(err => { + if (err.code == Parse.Error.OBJECT_NOT_FOUND) { + // no deletions were made. Can be ignored. + return; + } + // rethrow the error + throw err; + }); + } + // In non-merge scenarios, just return the installation match id + return idMatch.objectId; } - // In non-merge scenarios, just return the installation match id - return idMatch.objectId; } - } - }).then((objId) => { - if (objId) { - this.query = {objectId: objId}; - delete this.data.objectId; - delete this.data.createdAt; - } - // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) - }); + }) + .then(objId => { + if (objId) { + this.query = { objectId: objId }; + delete this.data.objectId; + delete this.data.createdAt; + } + // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) + }); return promise; }; @@ -943,7 +1392,10 @@ RestWrite.prototype.handleInstallation = function() { RestWrite.prototype.expandFilesForExistingObjects = function() { // Check whether we have a short-circuited response - only then run expansion. if (this.response && this.response.response) { - this.config.filesController.expandFilesInObject(this.config, this.response.response); + this.config.filesController.expandFilesInObject( + this.config, + this.response.response + ); } }; @@ -956,10 +1408,15 @@ RestWrite.prototype.runDatabaseOperation = function() { this.config.cacheController.role.clear(); } - if (this.className === '_User' && - this.query && - !this.auth.couldUpdateUserId(this.query.objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, `Cannot modify user ${this.query.objectId}.`); + if ( + this.className === '_User' && + this.query && + this.auth.isUnauthenticated() + ) { + throw new Parse.Error( + Parse.Error.SESSION_MISSING, + `Cannot modify user ${this.query.objectId}.` + ); } if (this.className === '_Product' && this.data.download) { @@ -975,11 +1432,20 @@ RestWrite.prototype.runDatabaseOperation = function() { if (this.query) { // Force the user to not lockout // Matched with parse.com - if (this.className === '_User' && this.data.ACL) { + if ( + this.className === '_User' && + this.data.ACL && + this.auth.isMaster !== true + ) { this.data.ACL[this.query.objectId] = { read: true, write: true }; } // update password timestamp if user password is being changed - if (this.className === '_User' && this.data._hashed_password && this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { + if ( + this.className === '_User' && + this.data._hashed_password && + this.config.passwordPolicy && + this.config.passwordPolicy.maxPasswordAge + ) { this.data._password_changed_at = Parse._encode(new Date()); } // Ignore createdAt when update @@ -987,28 +1453,54 @@ RestWrite.prototype.runDatabaseOperation = function() { let defer = Promise.resolve(); // if password history is enabled then save the current password to history - if (this.className === '_User' && this.data._hashed_password && this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordHistory) { - defer = this.config.database.find('_User', {objectId: this.objectId()}, {keys: ["_password_history", "_hashed_password"]}).then(results => { - if (results.length != 1) { - throw undefined; - } - const user = results[0]; - let oldPasswords = []; - if (user._password_history) { - oldPasswords = _.take(user._password_history, this.config.passwordPolicy.maxPasswordHistory); - } - //n-1 passwords go into history including last password - while (oldPasswords.length > this.config.passwordPolicy.maxPasswordHistory - 2) { - oldPasswords.shift(); - } - oldPasswords.push(user.password); - this.data._password_history = oldPasswords; - }); + if ( + this.className === '_User' && + this.data._hashed_password && + this.config.passwordPolicy && + this.config.passwordPolicy.maxPasswordHistory + ) { + defer = this.config.database + .find( + '_User', + { objectId: this.objectId() }, + { keys: ['_password_history', '_hashed_password'] } + ) + .then(results => { + if (results.length != 1) { + throw undefined; + } + const user = results[0]; + let oldPasswords = []; + if (user._password_history) { + oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory + ); + } + //n-1 passwords go into history including last password + while ( + oldPasswords.length > + Math.max(0, this.config.passwordPolicy.maxPasswordHistory - 2) + ) { + oldPasswords.shift(); + } + oldPasswords.push(user.password); + this.data._password_history = oldPasswords; + }); } return defer.then(() => { // Run an update - return this.config.database.update(this.className, this.query, this.data, this.runOptions) + return this.config.database + .update( + this.className, + this.query, + this.data, + this.runOptions, + false, + false, + this.validSchemaController + ) .then(response => { response.updatedAt = this.updatedAt; this._updateResponseWithData(response, this.data); @@ -1028,51 +1520,91 @@ RestWrite.prototype.runDatabaseOperation = function() { ACL[this.data.objectId] = { read: true, write: true }; this.data.ACL = ACL; // password timestamp to be used when password expiry policy is enforced - if (this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { + if ( + this.config.passwordPolicy && + this.config.passwordPolicy.maxPasswordAge + ) { this.data._password_changed_at = Parse._encode(new Date()); } } // Run a create - return this.config.database.create(this.className, this.data, this.runOptions) + return this.config.database + .create( + this.className, + this.data, + this.runOptions, + false, + this.validSchemaController + ) .catch(error => { - if (this.className !== '_User' || error.code !== Parse.Error.DUPLICATE_VALUE) { + if ( + this.className !== '_User' || + error.code !== Parse.Error.DUPLICATE_VALUE + ) { throw error; } // Quick check, if we were able to infer the duplicated field name - if (error && error.userInfo && error.userInfo.duplicated_field === 'username') { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.'); + if ( + error && + error.userInfo && + error.userInfo.duplicated_field === 'username' + ) { + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); } - if (error && error.userInfo && error.userInfo.duplicated_field === 'email') { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.'); + if ( + error && + error.userInfo && + error.userInfo.duplicated_field === 'email' + ) { + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); } // If this was a failed user creation due to username or email already taken, we need to // check whether it was username or email and return the appropriate error. // Fallback to the original method // TODO: See if we can later do this without additional queries by using named indexes. - return this.config.database.find( - this.className, - { username: this.data.username, objectId: {'$ne': this.objectId()} }, - { limit: 1 } - ) + return this.config.database + .find( + this.className, + { + username: this.data.username, + objectId: { $ne: this.objectId() }, + }, + { limit: 1 } + ) .then(results => { if (results.length > 0) { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.'); + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); } return this.config.database.find( this.className, - { email: this.data.email, objectId: {'$ne': this.objectId()} }, + { email: this.data.email, objectId: { $ne: this.objectId() } }, { limit: 1 } ); }) .then(results => { if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.'); + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); } - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); }); }) .then(response => { @@ -1086,26 +1618,32 @@ RestWrite.prototype.runDatabaseOperation = function() { this.response = { status: 201, response, - location: this.location() + location: this.location(), }; }); } }; // Returns nothing - doesn't wait for the trigger. -RestWrite.prototype.runAfterTrigger = function() { +RestWrite.prototype.runAfterSaveTrigger = function() { if (!this.response || !this.response.response) { return; } // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. - const hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId); - const hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className); + const hasAfterSaveHook = triggers.triggerExists( + this.className, + triggers.Types.afterSave, + this.config.applicationId + ); + const hasLiveQuery = this.config.liveQueryController.hasLiveQuery( + this.className + ); if (!hasAfterSaveHook && !hasLiveQuery) { return Promise.resolve(); } - var extraData = {className: this.className}; + var extraData = { className: this.className }; if (this.query && this.query.objectId) { extraData.objectId = this.query.objectId; } @@ -1119,22 +1657,48 @@ RestWrite.prototype.runAfterTrigger = function() { // Build the inflated object, different from beforeSave, originalData is not empty // since developers can change data in the beforeSave. const updatedObject = this.buildUpdatedObject(extraData); - updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); - - // Notifiy LiveQueryServer if possible - this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject); + updatedObject._handleSaveResponse( + this.response.response, + this.response.status || 200 + ); + + this.config.database.loadSchema().then(schemaController => { + // Notifiy LiveQueryServer if possible + const perms = schemaController.getClassLevelPermissions( + updatedObject.className + ); + this.config.liveQueryController.onAfterSave( + updatedObject.className, + updatedObject, + originalObject, + perms + ); + }); // Run afterSave trigger - return triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config) + return triggers + .maybeRunTrigger( + triggers.Types.afterSave, + this.auth, + updatedObject, + originalObject, + this.config, + this.context + ) + .then(result => { + if (result && typeof result === 'object') { + this.response.response = result; + } + }) .catch(function(err) { logger.warn('afterSave caught an error', err); - }) + }); }; // A helper to figure out what location this operation happens at. RestWrite.prototype.location = function() { - var middle = (this.className === '_User' ? '/users/' : - '/classes/' + this.className + '/'); + var middle = + this.className === '_User' ? '/users/' : '/classes/' + this.className + '/'; return this.config.mount + middle + this.data.objectId; }; @@ -1148,24 +1712,24 @@ RestWrite.prototype.objectId = function() { RestWrite.prototype.sanitizedData = function() { const data = Object.keys(this.data).reduce((data, key) => { // Regexp comes from Parse.Object.prototype.validate - if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) { + if (!/^[A-Za-z][0-9A-Za-z_]*$/.test(key)) { delete data[key]; } return data; }, deepcopy(this.data)); return Parse._decode(undefined, data); -} +}; // Returns an updated copy of the object -RestWrite.prototype.buildUpdatedObject = function (extraData) { +RestWrite.prototype.buildUpdatedObject = function(extraData) { const updatedObject = triggers.inflate(extraData, this.originalData); - Object.keys(this.data).reduce(function (data, key) { - if (key.indexOf(".") > 0) { + Object.keys(this.data).reduce(function(data, key) { + if (key.indexOf('.') > 0) { // subdocument key with dot notation ('x.y':v => 'x':{'y':v}) - const splittedKey = key.split("."); + const splittedKey = key.split('.'); const parentProp = splittedKey[0]; let parentVal = updatedObject.get(parentProp); - if(typeof parentVal !== 'object') { + if (typeof parentVal !== 'object') { parentVal = {}; } parentVal[splittedKey[1]] = data[key]; @@ -1183,7 +1747,7 @@ RestWrite.prototype.cleanUserAuthData = function() { if (this.response && this.response.response && this.className === '_User') { const user = this.response.response; if (user.authData) { - Object.keys(user.authData).forEach((provider) => { + Object.keys(user.authData).forEach(provider => { if (user.authData[provider] === null) { delete user.authData[provider]; } @@ -1203,7 +1767,7 @@ RestWrite.prototype._updateResponseWithData = function(response, data) { this.storage.fieldsChangedByTrigger.forEach(fieldName => { const dataValue = data[fieldName]; - if(!response.hasOwnProperty(fieldName)) { + if (!Object.prototype.hasOwnProperty.call(response, fieldName)) { response[fieldName] = dataValue; } @@ -1216,7 +1780,7 @@ RestWrite.prototype._updateResponseWithData = function(response, data) { } }); return response; -} +}; export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js new file mode 100644 index 0000000000..94544282e2 --- /dev/null +++ b/src/Routers/AggregateRouter.js @@ -0,0 +1,162 @@ +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import * as middleware from '../middlewares'; +import Parse from 'parse/node'; +import UsersRouter from './UsersRouter'; + +const BASE_KEYS = ['where', 'distinct', 'pipeline', 'hint', 'explain']; + +const PIPELINE_KEYS = [ + 'addFields', + 'bucket', + 'bucketAuto', + 'collStats', + 'count', + 'currentOp', + 'facet', + 'geoNear', + 'graphLookup', + 'group', + 'indexStats', + 'limit', + 'listLocalSessions', + 'listSessions', + 'lookup', + 'match', + 'out', + 'project', + 'redact', + 'replaceRoot', + 'sample', + 'skip', + 'sort', + 'sortByCount', + 'unwind', +]; + +const ALLOWED_KEYS = [...BASE_KEYS, ...PIPELINE_KEYS]; + +export class AggregateRouter extends ClassesRouter { + handleFind(req) { + const body = Object.assign( + req.body, + ClassesRouter.JSONFromQuery(req.query) + ); + const options = {}; + if (body.distinct) { + options.distinct = String(body.distinct); + } + if (body.hint) { + options.hint = body.hint; + delete body.hint; + } + if (body.explain) { + options.explain = body.explain; + delete body.explain; + } + options.pipeline = AggregateRouter.getPipeline(body); + if (typeof body.where === 'string') { + body.where = JSON.parse(body.where); + } + return rest + .find( + req.config, + req.auth, + this.className(req), + body.where, + options, + req.info.clientSDK + ) + .then(response => { + for (const result of response.results) { + if (typeof result === 'object') { + UsersRouter.removeHiddenProperties(result); + } + } + return { response }; + }); + } + + /* Builds a pipeline from the body. Originally the body could be passed as a single object, + * and now we support many options + * + * Array + * + * body: [{ + * group: { objectId: '$name' }, + * }] + * + * Object + * + * body: { + * group: { objectId: '$name' }, + * } + * + * + * Pipeline Operator with an Array or an Object + * + * body: { + * pipeline: { + * group: { objectId: '$name' }, + * } + * } + * + */ + static getPipeline(body) { + let pipeline = body.pipeline || body; + if (!Array.isArray(pipeline)) { + pipeline = Object.keys(pipeline).map(key => { + return { [key]: pipeline[key] }; + }); + } + + return pipeline.map(stage => { + const keys = Object.keys(stage); + if (keys.length != 1) { + throw new Error( + `Pipeline stages should only have one key found ${keys.join(', ')}` + ); + } + return AggregateRouter.transformStage(keys[0], stage); + }); + } + + static transformStage(stageName, stage) { + if (ALLOWED_KEYS.indexOf(stageName) === -1) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid parameter for query: ${stageName}` + ); + } + if (stageName === 'group') { + if (Object.prototype.hasOwnProperty.call(stage[stageName], '_id')) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid parameter for query: group. Please use objectId instead of _id` + ); + } + if (!Object.prototype.hasOwnProperty.call(stage[stageName], 'objectId')) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid parameter for query: group. objectId is required` + ); + } + stage[stageName]._id = stage[stageName].objectId; + delete stage[stageName].objectId; + } + return { [`$${stageName}`]: stage[stageName] }; + } + + mountRoutes() { + this.route( + 'GET', + '/aggregate/:className', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.handleFind(req); + } + ); + } +} + +export default AggregateRouter; diff --git a/src/Routers/AnalyticsRouter.js b/src/Routers/AnalyticsRouter.js index 511781d807..90ffcdcc4a 100644 --- a/src/Routers/AnalyticsRouter.js +++ b/src/Routers/AnalyticsRouter.js @@ -11,10 +11,9 @@ function trackEvent(req) { return analyticsController.trackEvent(req); } - export class AnalyticsRouter extends PromiseRouter { mountRoutes() { - this.route('POST','/events/AppOpened', appOpened); - this.route('POST','/events/:eventName', trackEvent); + this.route('POST', '/events/AppOpened', appOpened); + this.route('POST', '/events/:eventName', trackEvent); } } diff --git a/src/Routers/AudiencesRouter.js b/src/Routers/AudiencesRouter.js index 3dbb94cf85..312ea9b5ac 100644 --- a/src/Routers/AudiencesRouter.js +++ b/src/Routers/AudiencesRouter.js @@ -3,41 +3,84 @@ import rest from '../rest'; import * as middleware from '../middlewares'; export class AudiencesRouter extends ClassesRouter { - className() { return '_Audience'; } handleFind(req) { - const body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); + const body = Object.assign( + req.body, + ClassesRouter.JSONFromQuery(req.query) + ); const options = ClassesRouter.optionsFromBody(body); - return rest.find(req.config, req.auth, '_Audience', body.where, options, req.info.clientSDK) - .then((response) => { - - response.results.forEach((item) => { + 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}; + return { response: response }; }); } handleGet(req) { - return super.handleGet(req) - .then((data) => { - data.response.query = JSON.parse(data.response.query); + return super.handleGet(req).then(data => { + data.response.query = JSON.parse(data.response.query); - return data; - }); + return data; + }); } 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); }); + 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); + } + ); } } diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 6801f3bc1c..d85101af6e 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -1,21 +1,29 @@ - import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import _ from 'lodash'; -import Parse from 'parse/node'; +import rest from '../rest'; +import _ from 'lodash'; +import Parse from 'parse/node'; -const ALLOWED_GET_QUERY_KEYS = ['keys', 'include']; +const ALLOWED_GET_QUERY_KEYS = [ + 'keys', + 'include', + 'excludeKeys', + 'readPreference', + 'includeReadPreference', + 'subqueryReadPreference', +]; export class ClassesRouter extends PromiseRouter { - className(req) { return req.params.className; } handleFind(req) { - const body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); + const body = Object.assign( + req.body, + ClassesRouter.JSONFromQuery(req.query) + ); const options = ClassesRouter.optionsFromBody(body); - if (req.config.maxLimit && (body.limit > req.config.maxLimit)) { + if (req.config.maxLimit && body.limit > req.config.maxLimit) { // Silently replace the limit on the query with the max configured options.limit = Number(req.config.maxLimit); } @@ -25,48 +33,77 @@ export class ClassesRouter extends PromiseRouter { if (typeof body.where === 'string') { body.where = JSON.parse(body.where); } - return rest.find(req.config, req.auth, this.className(req), body.where, options, req.info.clientSDK) - .then((response) => { - if (response && response.results) { - for (const result of response.results) { - if (result.sessionToken) { - result.sessionToken = req.info.sessionToken || result.sessionToken; - } - } - } + return rest + .find( + req.config, + req.auth, + this.className(req), + body.where, + options, + req.info.clientSDK + ) + .then(response => { return { response: response }; }); } // Returns a promise for a {response} object. handleGet(req) { - const body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); + const body = Object.assign( + req.body, + ClassesRouter.JSONFromQuery(req.query) + ); const options = {}; for (const key of Object.keys(body)) { if (ALLOWED_GET_QUERY_KEYS.indexOf(key) === -1) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Improper encode of parameter'); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Improper encode of parameter' + ); } } - if (typeof body.keys == 'string') { + if (typeof body.keys === 'string') { options.keys = body.keys; } if (body.include) { options.include = String(body.include); } + if (typeof body.excludeKeys == 'string') { + options.excludeKeys = body.excludeKeys; + } + if (typeof body.readPreference === 'string') { + options.readPreference = body.readPreference; + } + if (typeof body.includeReadPreference === 'string') { + options.includeReadPreference = body.includeReadPreference; + } + if (typeof body.subqueryReadPreference === 'string') { + options.subqueryReadPreference = body.subqueryReadPreference; + } - return rest.get(req.config, req.auth, this.className(req), req.params.objectId, options, req.info.clientSDK) - .then((response) => { + return rest + .get( + req.config, + req.auth, + this.className(req), + req.params.objectId, + options, + req.info.clientSDK + ) + .then(response => { if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); } - if (this.className(req) === "_User") { - + if (this.className(req) === '_User') { delete response.results[0].sessionToken; - const user = response.results[0]; + const user = response.results[0]; if (req.auth.user && user.objectId == req.auth.user.id) { // Force the session token @@ -78,18 +115,38 @@ export class ClassesRouter extends PromiseRouter { } handleCreate(req) { - return rest.create(req.config, req.auth, this.className(req), req.body, req.info.clientSDK); + return rest.create( + req.config, + req.auth, + this.className(req), + req.body, + req.info.clientSDK + ); } handleUpdate(req) { - const where = { objectId: req.params.objectId } - return rest.update(req.config, req.auth, this.className(req), where, req.body, req.info.clientSDK); + const where = { objectId: req.params.objectId }; + return rest.update( + req.config, + req.auth, + this.className(req), + where, + req.body, + req.info.clientSDK + ); } handleDelete(req) { - return rest.del(req.config, req.auth, this.className(req), req.params.objectId, req.info.clientSDK) + return rest + .del( + req.config, + req.auth, + this.className(req), + req.params.objectId, + req.info.clientSDK + ) .then(() => { - return {response: {}}; + return { response: {} }; }); } @@ -102,16 +159,34 @@ export class ClassesRouter extends PromiseRouter { json[key] = value; } } - return json + return json; } static optionsFromBody(body) { - const allowConstraints = ['skip', 'limit', 'order', 'count', 'keys', - 'include', 'redirectClassNameForKey', 'where']; + const allowConstraints = [ + 'skip', + 'limit', + 'order', + 'count', + 'keys', + 'excludeKeys', + 'include', + 'includeAll', + 'redirectClassNameForKey', + 'where', + 'readPreference', + 'includeReadPreference', + 'subqueryReadPreference', + 'hint', + 'explain', + ]; for (const key of Object.keys(body)) { if (allowConstraints.indexOf(key) === -1) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `Invalid parameter for query: ${key}`); + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid parameter for query: ${key}` + ); } } const options = {}; @@ -132,18 +207,52 @@ export class ClassesRouter extends PromiseRouter { if (typeof body.keys == 'string') { options.keys = body.keys; } + if (typeof body.excludeKeys == 'string') { + options.excludeKeys = body.excludeKeys; + } if (body.include) { options.include = String(body.include); } + if (body.includeAll) { + options.includeAll = true; + } + if (typeof body.readPreference === 'string') { + options.readPreference = body.readPreference; + } + if (typeof body.includeReadPreference === 'string') { + options.includeReadPreference = body.includeReadPreference; + } + if (typeof body.subqueryReadPreference === 'string') { + options.subqueryReadPreference = body.subqueryReadPreference; + } + if ( + body.hint && + (typeof body.hint === 'string' || typeof body.hint === 'object') + ) { + options.hint = body.hint; + } + if (body.explain) { + options.explain = body.explain; + } return options; } mountRoutes() { - this.route('GET', '/classes/:className', (req) => { return this.handleFind(req); }); - this.route('GET', '/classes/:className/:objectId', (req) => { return this.handleGet(req); }); - this.route('POST', '/classes/:className', (req) => { return this.handleCreate(req); }); - this.route('PUT', '/classes/:className/:objectId', (req) => { return this.handleUpdate(req); }); - this.route('DELETE', '/classes/:className/:objectId', (req) => { return this.handleDelete(req); }); + this.route('GET', '/classes/:className', req => { + return this.handleFind(req); + }); + this.route('GET', '/classes/:className/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/classes/:className', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/classes/:className/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/classes/:className/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/CloudCodeRouter.js b/src/Routers/CloudCodeRouter.js index d1ca9009e6..dea27faec7 100644 --- a/src/Routers/CloudCodeRouter.js +++ b/src/Routers/CloudCodeRouter.js @@ -1,8 +1,8 @@ -import PromiseRouter from '../PromiseRouter'; -import Parse from 'parse/node'; -import rest from '../rest'; -const triggers = require('../triggers'); -const middleware = require('../middlewares'); +import PromiseRouter from '../PromiseRouter'; +import Parse from 'parse/node'; +import rest from '../rest'; +const triggers = require('../triggers'); +const middleware = require('../middlewares'); function formatJobSchedule(job_schedule) { if (typeof job_schedule.startAfter === 'undefined') { @@ -14,63 +14,111 @@ function formatJobSchedule(job_schedule) { function validateJobSchedule(config, job_schedule) { const jobs = triggers.getJobs(config.applicationId) || {}; if (job_schedule.jobName && !jobs[job_schedule.jobName]) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Cannot Schedule a job that is not deployed'); + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Cannot Schedule a job that is not deployed' + ); } } export class CloudCodeRouter extends PromiseRouter { mountRoutes() { - this.route('GET', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobs); - this.route('GET', '/cloud_code/jobs/data', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobsData); - this.route('POST', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.createJob); - this.route('PUT', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.editJob); - this.route('DELETE', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.deleteJob); + this.route( + 'GET', + '/cloud_code/jobs', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getJobs + ); + this.route( + 'GET', + '/cloud_code/jobs/data', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getJobsData + ); + this.route( + 'POST', + '/cloud_code/jobs', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.createJob + ); + this.route( + 'PUT', + '/cloud_code/jobs/:objectId', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.editJob + ); + this.route( + 'DELETE', + '/cloud_code/jobs/:objectId', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.deleteJob + ); } static getJobs(req) { - return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => { - return { - response: scheduledJobs.results - } - }); + return rest + .find(req.config, req.auth, '_JobSchedule', {}, {}) + .then(scheduledJobs => { + return { + response: scheduledJobs.results, + }; + }); } static getJobsData(req) { const config = req.config; const jobs = triggers.getJobs(config.applicationId) || {}; - return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => { - return { - response: { - in_use: scheduledJobs.results.map((job) => job.jobName), - jobs: Object.keys(jobs), - } - }; - }); + return rest + .find(req.config, req.auth, '_JobSchedule', {}, {}) + .then(scheduledJobs => { + return { + response: { + in_use: scheduledJobs.results.map(job => job.jobName), + jobs: Object.keys(jobs), + }, + }; + }); } static createJob(req) { const { job_schedule } = req.body; validateJobSchedule(req.config, job_schedule); - return rest.create(req.config, req.auth, '_JobSchedule', formatJobSchedule(job_schedule), req.client); + return rest.create( + req.config, + req.auth, + '_JobSchedule', + formatJobSchedule(job_schedule), + req.client + ); } static editJob(req) { const { objectId } = req.params; const { job_schedule } = req.body; validateJobSchedule(req.config, job_schedule); - return rest.update(req.config, req.auth, '_JobSchedule', { objectId }, formatJobSchedule(job_schedule)).then((response) => { - return { - response - } - }); + return rest + .update( + req.config, + req.auth, + '_JobSchedule', + { objectId }, + formatJobSchedule(job_schedule) + ) + .then(response => { + return { + response, + }; + }); } static deleteJob(req) { const { objectId } = req.params; - return rest.del(req.config, req.auth, '_JobSchedule', objectId).then((response) => { - return { - response - } - }); + return rest + .del(req.config, req.auth, '_JobSchedule', objectId) + .then(response => { + return { + response, + }; + }); } } diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 47d1a0980c..c0cc56d716 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -1,56 +1,64 @@ -import { version } from '../../package.json'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import { version } from '../../package.json'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; export class FeaturesRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/serverInfo', middleware.promiseEnforceMasterKeyAccess, req => { - const features = { - globalConfig: { - create: true, - read: true, - update: true, - delete: true, - }, - hooks: { - create: true, - read: true, - update: true, - delete: true, - }, - cloudCode: { - jobs: true, - }, - logs: { - level: true, - size: true, - order: true, - until: true, - from: true, - }, - push: { - immediatePush: req.config.hasPushSupport, - scheduledPush: req.config.hasPushScheduledSupport, - storedPushData: req.config.hasPushSupport, - pushAudiences: true, - localization: true, - }, - schemas: { - addField: true, - removeField: true, - addClass: true, - removeClass: true, - clearAllDataFromClass: true, - exportClass: false, - editClassLevelPermissions: true, - editPointerPermissions: true, - }, - }; + this.route( + 'GET', + '/serverInfo', + middleware.promiseEnforceMasterKeyAccess, + req => { + const { config } = req; + const features = { + globalConfig: { + create: true, + read: true, + update: true, + delete: true, + }, + hooks: { + create: true, + read: true, + update: true, + delete: true, + }, + cloudCode: { + jobs: true, + }, + logs: { + level: true, + size: true, + order: true, + until: true, + from: true, + }, + push: { + immediatePush: config.hasPushSupport, + scheduledPush: config.hasPushScheduledSupport, + storedPushData: config.hasPushSupport, + pushAudiences: true, + localization: true, + }, + schemas: { + addField: true, + removeField: true, + addClass: true, + removeClass: true, + clearAllDataFromClass: true, + exportClass: false, + editClassLevelPermissions: true, + editPointerPermissions: true, + }, + }; - return { response: { - features: features, - parseServerVersion: version, - } }; - }); + return { + response: { + features: features, + parseServerVersion: version, + }, + }; + } + ); } } diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 5e09d045f7..56fb5a3cc8 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -1,31 +1,36 @@ -import express from 'express'; -import BodyParser from 'body-parser'; -import * as Middlewares from '../middlewares'; -import Parse from 'parse/node'; -import Config from '../Config'; -import mime from 'mime'; -import logger from '../logger'; +import express from 'express'; +import BodyParser from 'body-parser'; +import * as Middlewares from '../middlewares'; +import Parse from 'parse/node'; +import Config from '../Config'; +import mime from 'mime'; +import logger from '../logger'; export class FilesRouter { - expressRouter({ maxUploadSize = '20Mb' } = {}) { var router = express.Router(); router.get('/files/:appId/:filename', this.getHandler); router.post('/files', function(req, res, next) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename not provided.')); + next( + new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.') + ); }); - router.post('/files/:filename', - Middlewares.allowCrossDomain, - BodyParser.raw({type: () => { return true; }, limit: maxUploadSize }), // Allow uploads without Content-Type, or with any Content-Type. + router.post( + '/files/:filename', + BodyParser.raw({ + type: () => { + return true; + }, + limit: maxUploadSize, + }), // Allow uploads without Content-Type, or with any Content-Type. Middlewares.handleParseHeaders, this.createHandler ); - router.delete('/files/:filename', - Middlewares.allowCrossDomain, + router.delete( + '/files/:filename', Middlewares.handleParseHeaders, Middlewares.enforceMasterKeyAccess, this.deleteHandler @@ -37,149 +42,92 @@ export class FilesRouter { const config = Config.get(req.params.appId); const filesController = config.filesController; const filename = req.params.filename; - const contentType = mime.lookup(filename); + const contentType = mime.getType(filename); if (isFileStreamable(req, filesController)) { - filesController.getFileStream(config, filename).then((stream) => { - handleFileStream(stream, req, res, contentType); - }).catch(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); + filesController + .handleFileStream(config, filename, req, res, contentType) + .catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); } else { - filesController.getFileData(config, filename).then((data) => { - res.status(200); - res.set('Content-Type', contentType); - res.set('Content-Length', data.length); - res.end(data); - }).catch(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); + filesController + .getFileData(config, filename) + .then(data => { + res.status(200); + res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + res.end(data); + }) + .catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); } } createHandler(req, res, next) { - if (!req.body || !req.body.length) { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Invalid file upload.')); - return; - } + const config = req.config; + const filesController = config.filesController; + const filename = req.params.filename; + const contentType = req.get('Content-type'); - if (req.params.filename.length > 128) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename too long.')); + if (!req.body || !req.body.length) { + next( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.') + ); return; } - if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename contains invalid characters.')); + const error = filesController.validateFilename(filename); + if (error) { + next(error); return; } - const filename = req.params.filename; - const contentType = req.get('Content-type'); - const config = req.config; - const filesController = config.filesController; - - filesController.createFile(config, filename, req.body, contentType).then((result) => { - res.status(201); - res.set('Location', result.url); - res.json(result); - }).catch((e) => { - logger.error(e.message, e); - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Could not store file.')); - }); + filesController + .createFile(config, filename, req.body, contentType) + .then(result => { + res.status(201); + res.set('Location', result.url); + res.json(result); + }) + .catch(e => { + logger.error('Error creating a file: ', e); + next( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `Could not store file: ${filename}.` + ) + ); + }); } deleteHandler(req, res, next) { const filesController = req.config.filesController; - filesController.deleteFile(req.config, req.params.filename).then(() => { - res.status(200); - // TODO: return useful JSON here? - res.end(); - }).catch(() => { - next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR, - 'Could not delete file.')); - }); + filesController + .deleteFile(req.config, req.params.filename) + .then(() => { + res.status(200); + // TODO: return useful JSON here? + res.end(); + }) + .catch(() => { + next( + new Parse.Error( + Parse.Error.FILE_DELETE_ERROR, + 'Could not delete file.' + ) + ); + }); } } -function isFileStreamable(req, filesController){ - return req.get('Range') && typeof filesController.adapter.getFileStream === 'function'; -} - -function getRange(req) { - const parts = req.get('Range').replace(/bytes=/, "").split("-"); - return { start: parseInt(parts[0], 10), end: parseInt(parts[1], 10) }; -} - -// handleFileStream is licenced under Creative Commons Attribution 4.0 International License (https://creativecommons.org/licenses/by/4.0/). -// Author: LEROIB at weightingformypizza (https://weightingformypizza.wordpress.com/2015/06/24/stream-html5-media-content-like-video-audio-from-mongodb-using-express-and-gridstore/). -function handleFileStream(stream, req, res, contentType) { - const buffer_size = 1024 * 1024; //1024Kb - // Range request, partiall stream the file - let { - start, end - } = getRange(req); - - const notEnded = (!end && end !== 0); - const notStarted = (!start && start !== 0); - // No end provided, we want all bytes - if (notEnded) { - end = stream.length - 1; - } - // No start provided, we're reading backwards - if (notStarted) { - start = stream.length - end; - end = start + end - 1; - } - - // Data exceeds the buffer_size, cap - if (end - start >= buffer_size) { - end = start + buffer_size - 1; - } - - const contentLength = (end - start) + 1; - - res.writeHead(206, { - 'Content-Range': 'bytes ' + start + '-' + end + '/' + stream.length, - 'Accept-Ranges': 'bytes', - 'Content-Length': contentLength, - 'Content-Type': contentType, - }); - - stream.seek(start, function () { - // get gridFile stream - const gridFileStream = stream.stream(true); - let bufferAvail = 0; - let remainingBytesToWrite = contentLength; - let totalBytesWritten = 0; - // write to response - gridFileStream.on('data', function (data) { - bufferAvail += data.length; - if (bufferAvail > 0) { - // slice returns the same buffer if overflowing - // safe to call in any case - const buffer = data.slice(0, remainingBytesToWrite); - // write the buffer - res.write(buffer); - // increment total - totalBytesWritten += buffer.length; - // decrement remaining - remainingBytesToWrite -= data.length; - // decrement the avaialbe buffer - bufferAvail -= buffer.length; - } - // in case of small slices, all values will be good at that point - // we've written enough, end... - if (totalBytesWritten >= contentLength) { - stream.close(); - res.end(); - this.destroy(); - } - }); - }); +function isFileStreamable(req, filesController) { + return ( + req.get('Range') && + typeof filesController.adapter.handleFileStream === 'function' + ); } diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index f3ea4d3872..5e6f422718 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -11,7 +11,7 @@ import { logger } from '../logger'; function parseObject(obj) { if (Array.isArray(obj)) { - return obj.map((item) => { + return obj.map(item => { return parseObject(item); }); } else if (obj && obj.__type == 'Date') { @@ -30,12 +30,20 @@ function parseParams(params) { } export class FunctionsRouter extends PromiseRouter { - mountRoutes() { - this.route('POST', '/functions/:functionName', FunctionsRouter.handleCloudFunction); - this.route('POST', '/jobs/:jobName', promiseEnforceMasterKeyAccess, function(req) { - return FunctionsRouter.handleCloudJob(req); - }); + this.route( + 'POST', + '/functions/:functionName', + FunctionsRouter.handleCloudFunction + ); + this.route( + 'POST', + '/jobs/:jobName', + promiseEnforceMasterKeyAccess, + function(req) { + return FunctionsRouter.handleCloudJob(req); + } + ); this.route('POST', '/jobs', promiseEnforceMasterKeyAccess, function(req) { return FunctionsRouter.handleCloudJob(req); }); @@ -56,25 +64,33 @@ export class FunctionsRouter extends PromiseRouter { log: req.config.loggerController, headers: req.config.headers, ip: req.config.ip, - jobName + jobName, + message: jobHandler.setMessage.bind(jobHandler), }; - const status = { - success: jobHandler.setSucceeded.bind(jobHandler), - error: jobHandler.setFailed.bind(jobHandler), - message: jobHandler.setMessage.bind(jobHandler) - } - return jobHandler.setRunning(jobName, params).then((jobStatus) => { - request.jobId = jobStatus.objectId + + return jobHandler.setRunning(jobName, params).then(jobStatus => { + request.jobId = jobStatus.objectId; // run the function async process.nextTick(() => { - jobFunction(request, status); + Promise.resolve() + .then(() => { + return jobFunction(request); + }) + .then( + result => { + jobHandler.setSucceeded(result); + }, + error => { + jobHandler.setFailed(error); + } + ); }); return { headers: { - 'X-Parse-Job-Status-Id': jobStatus.objectId + 'X-Parse-Job-Status-Id': jobStatus.objectId, }, - response: {} - } + response: {}, + }; }); } @@ -83,55 +99,79 @@ export class FunctionsRouter extends PromiseRouter { success: function(result) { resolve({ response: { - result: Parse._encode(result) - } + result: Parse._encode(result), + }, }); }, - error: function(code, message) { - if (!message) { - message = code; - code = Parse.Error.SCRIPT_FAILED; + 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)); + } + if (message instanceof Error) { + message = message.message; } reject(new Parse.Error(code, message)); }, - message: message - } + 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) { - let params = Object.assign({}, req.body, req.query); - params = parseParams(params); - var request = { - params: params, - master: req.auth && req.auth.isMaster, - user: req.auth && req.auth.user, - installationId: req.info.installationId, - log: req.config.loggerController, - headers: req.config.headers, - ip: req.config.ip, - functionName - }; + const theValidator = triggers.getValidator( + req.params.functionName, + applicationId + ); + if (!theFunction) { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + `Invalid function: "${functionName}"` + ); + } + let params = Object.assign({}, req.body, req.query); + params = parseParams(params); + const request = { + params: params, + master: req.auth && req.auth.isMaster, + user: req.auth && req.auth.user, + installationId: req.info.installationId, + log: req.config.loggerController, + headers: req.config.headers, + ip: req.config.ip, + functionName, + }; - if (theValidator && typeof theValidator === "function") { - var result = theValidator(request); - if (!result) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Validation failed.'); - } + 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; - const cleanInput = logger.truncateLogMessage(JSON.stringify(params)); - var response = FunctionsRouter.createResponseObject((result) => { + return new Promise(function(resolve, reject) { + const userString = + req.auth && req.auth.user ? req.auth.user.id : undefined; + const cleanInput = logger.truncateLogMessage(JSON.stringify(params)); + const { success, error, message } = FunctionsRouter.createResponseObject( + result => { try { - const cleanResult = logger.truncateLogMessage(JSON.stringify(result.response.result)); + 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 }`, + `Ran cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Result: ${cleanResult}`, { functionName, params, @@ -142,30 +182,30 @@ export class FunctionsRouter extends PromiseRouter { } catch (e) { reject(e); } - }, (error) => { + }, + error => { try { logger.error( - `Failed running cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Error: ` + JSON.stringify(error), + `Failed running cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Error: ` + + JSON.stringify(error), { functionName, error, params, - user: userString + user: userString, } ); reject(error); } catch (e) { reject(e); } - }); - // Force the keys before the function calls. - Parse.applicationId = req.config.applicationId; - Parse.javascriptKey = req.config.javascriptKey; - Parse.masterKey = req.config.masterKey; - theFunction(request, response); - }); - } else { - throw new Parse.Error(Parse.Error.SCRIPT_FAILED, `Invalid function: "${functionName}"`); - } + } + ); + return Promise.resolve() + .then(() => { + return theFunction(request, { message }); + }) + .then(success, error); + }); } } diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js index 42ba581938..ca189ead68 100644 --- a/src/Routers/GlobalConfigRouter.js +++ b/src/Routers/GlobalConfigRouter.js @@ -1,36 +1,67 @@ // global_config.js -import Parse from 'parse/node'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import Parse from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; export class GlobalConfigRouter extends PromiseRouter { getGlobalConfig(req) { - return req.config.database.find('_GlobalConfig', { objectId: "1" }, { limit: 1 }).then((results) => { - if (results.length != 1) { - // If there is no config in the database - return empty config. - return { response: { params: {} } }; - } - const globalConfig = results[0]; - return { response: { params: globalConfig.params } }; - }); + return req.config.database + .find('_GlobalConfig', { objectId: '1' }, { limit: 1 }) + .then(results => { + if (results.length != 1) { + // If there is no config in the database - return empty config. + return { response: { params: {} } }; + } + const globalConfig = results[0]; + if (!req.auth.isMaster && globalConfig.masterKeyOnly !== undefined) { + for (const param in globalConfig.params) { + if (globalConfig.masterKeyOnly[param]) { + delete globalConfig.params[param]; + delete globalConfig.masterKeyOnly[param]; + } + } + } + return { + response: { + params: globalConfig.params, + masterKeyOnly: globalConfig.masterKeyOnly, + }, + }; + }); } updateGlobalConfig(req) { if (req.auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update the config.'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update the config." + ); } const params = req.body.params; + const masterKeyOnly = req.body.masterKeyOnly || {}; // Transform in dot notation to make sure it works const update = Object.keys(params).reduce((acc, key) => { acc[`params.${key}`] = params[key]; + acc[`masterKeyOnly.${key}`] = masterKeyOnly[key] || false; return acc; }, {}); - return req.config.database.update('_GlobalConfig', {objectId: "1"}, update, {upsert: true}).then(() => ({ response: { result: true } })); + return req.config.database + .update('_GlobalConfig', { objectId: '1' }, update, { upsert: true }) + .then(() => ({ response: { result: true } })); } mountRoutes() { - this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); - this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { return this.updateGlobalConfig(req) }); + this.route('GET', '/config', req => { + return this.getGlobalConfig(req); + }); + this.route( + 'PUT', + '/config', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.updateGlobalConfig(req); + } + ); } } diff --git a/src/Routers/GraphQLRouter.js b/src/Routers/GraphQLRouter.js new file mode 100644 index 0000000000..cdf2565926 --- /dev/null +++ b/src/Routers/GraphQLRouter.js @@ -0,0 +1,50 @@ +import Parse from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; + +const GraphQLConfigPath = '/graphql-config'; + +export class GraphQLRouter extends PromiseRouter { + async getGraphQLConfig(req) { + const result = await req.config.parseGraphQLController.getGraphQLConfig(); + return { + response: result, + }; + } + + async updateGraphQLConfig(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update the GraphQL config." + ); + } + const data = await req.config.parseGraphQLController.updateGraphQLConfig( + req.body.params + ); + return { + response: data, + }; + } + + mountRoutes() { + this.route( + 'GET', + GraphQLConfigPath, + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.getGraphQLConfig(req); + } + ); + this.route( + 'PUT', + GraphQLConfigPath, + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.updateGraphQLConfig(req); + } + ); + } +} + +export default GraphQLRouter; diff --git a/src/Routers/HooksRouter.js b/src/Routers/HooksRouter.js index 6c299c82ca..404094cbb9 100644 --- a/src/Routers/HooksRouter.js +++ b/src/Routers/HooksRouter.js @@ -1,14 +1,18 @@ -import { Parse } from 'parse/node'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; export class HooksRouter extends PromiseRouter { createHook(aHook, config) { - return config.hooksController.createHook(aHook).then((hook) => ({response: hook})); + return config.hooksController + .createHook(aHook) + .then(hook => ({ response: hook })); } updateHook(aHook, config) { - return config.hooksController.updateHook(aHook).then((hook) => ({response: hook})); + return config.hooksController + .updateHook(aHook) + .then(hook => ({ response: hook })); } handlePost(req) { @@ -18,67 +22,84 @@ export class HooksRouter extends PromiseRouter { handleGetFunctions(req) { var hooksController = req.config.hooksController; if (req.params.functionName) { - return hooksController.getFunction(req.params.functionName).then((foundFunction) => { - if (!foundFunction) { - throw new Parse.Error(143, `no function named: ${req.params.functionName} is defined`); - } - return Promise.resolve({response: foundFunction}); - }); + return hooksController + .getFunction(req.params.functionName) + .then(foundFunction => { + if (!foundFunction) { + throw new Parse.Error( + 143, + `no function named: ${req.params.functionName} is defined` + ); + } + return Promise.resolve({ response: foundFunction }); + }); } - return hooksController.getFunctions().then((functions) => { - return { response: functions || [] }; - }, (err) => { - throw err; - }); + return hooksController.getFunctions().then( + functions => { + return { response: functions || [] }; + }, + err => { + throw err; + } + ); } handleGetTriggers(req) { var hooksController = req.config.hooksController; if (req.params.className && req.params.triggerName) { - - return hooksController.getTrigger(req.params.className, req.params.triggerName).then((foundTrigger) => { - if (!foundTrigger) { - throw new Parse.Error(143,`class ${req.params.className} does not exist`); - } - return Promise.resolve({response: foundTrigger}); - }); + return hooksController + .getTrigger(req.params.className, req.params.triggerName) + .then(foundTrigger => { + if (!foundTrigger) { + throw new Parse.Error( + 143, + `class ${req.params.className} does not exist` + ); + } + return Promise.resolve({ response: foundTrigger }); + }); } - return hooksController.getTriggers().then((triggers) => ({ response: triggers || [] })); + return hooksController + .getTriggers() + .then(triggers => ({ response: triggers || [] })); } handleDelete(req) { var hooksController = req.config.hooksController; if (req.params.functionName) { - return hooksController.deleteFunction(req.params.functionName).then(() => ({response: {}})) - + return hooksController + .deleteFunction(req.params.functionName) + .then(() => ({ response: {} })); } else if (req.params.className && req.params.triggerName) { - return hooksController.deleteTrigger(req.params.className, req.params.triggerName).then(() => ({response: {}})) + return hooksController + .deleteTrigger(req.params.className, req.params.triggerName) + .then(() => ({ response: {} })); } - return Promise.resolve({response: {}}); + return Promise.resolve({ response: {} }); } handleUpdate(req) { var hook; if (req.params.functionName && req.body.url) { - hook = {} + hook = {}; hook.functionName = req.params.functionName; hook.url = req.body.url; } else if (req.params.className && req.params.triggerName && req.body.url) { - hook = {} + hook = {}; hook.className = req.params.className; hook.triggerName = req.params.triggerName; - hook.url = req.body.url + hook.url = req.body.url; } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } return this.updateHook(hook, req.config); } handlePut(req) { var body = req.body; - if (body.__op == "Delete") { + if (body.__op == 'Delete') { return this.handleDelete(req); } else { return this.handleUpdate(req); @@ -86,14 +107,54 @@ export class HooksRouter extends PromiseRouter { } mountRoutes() { - this.route('GET', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('GET', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('POST', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('POST', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('PUT', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); - this.route('PUT', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); + this.route( + 'GET', + '/hooks/functions', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetFunctions.bind(this) + ); + this.route( + 'GET', + '/hooks/triggers', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetTriggers.bind(this) + ); + this.route( + 'GET', + '/hooks/functions/:functionName', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetFunctions.bind(this) + ); + this.route( + 'GET', + '/hooks/triggers/:className/:triggerName', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetTriggers.bind(this) + ); + this.route( + 'POST', + '/hooks/functions', + middleware.promiseEnforceMasterKeyAccess, + this.handlePost.bind(this) + ); + this.route( + 'POST', + '/hooks/triggers', + middleware.promiseEnforceMasterKeyAccess, + this.handlePost.bind(this) + ); + this.route( + 'PUT', + '/hooks/functions/:functionName', + middleware.promiseEnforceMasterKeyAccess, + this.handlePut.bind(this) + ); + this.route( + 'PUT', + '/hooks/triggers/:className/:triggerName', + middleware.promiseEnforceMasterKeyAccess, + this.handlePut.bind(this) + ); } } diff --git a/src/Routers/IAPValidationRouter.js b/src/Routers/IAPValidationRouter.js index 565b87079b..fc21d0df2c 100644 --- a/src/Routers/IAPValidationRouter.js +++ b/src/Routers/IAPValidationRouter.js @@ -1,81 +1,95 @@ import PromiseRouter from '../PromiseRouter'; -var request = require("request"); -var rest = require("../rest"); +const request = require('../request'); +const rest = require('../rest'); import Parse from 'parse/node'; // TODO move validation logic in IAPValidationController -const IAP_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"; -const IAP_PRODUCTION_URL = "https://buy.itunes.apple.com/verifyReceipt"; +const IAP_SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt'; +const IAP_PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt'; const APP_STORE_ERRORS = { - 21000: "The App Store could not read the JSON object you provided.", - 21002: "The data in the receipt-data property was malformed or missing.", - 21003: "The receipt could not be authenticated.", - 21004: "The shared secret you provided does not match the shared secret on file for your account.", - 21005: "The receipt server is not currently available.", - 21006: "This receipt is valid but the subscription has expired.", - 21007: "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.", - 21008: "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead." -} + 21000: 'The App Store could not read the JSON object you provided.', + 21002: 'The data in the receipt-data property was malformed or missing.', + 21003: 'The receipt could not be authenticated.', + 21004: 'The shared secret you provided does not match the shared secret on file for your account.', + 21005: 'The receipt server is not currently available.', + 21006: 'This receipt is valid but the subscription has expired.', + 21007: 'This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.', + 21008: 'This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.', +}; function appStoreError(status) { status = parseInt(status); - var errorString = APP_STORE_ERRORS[status] || "unknown error."; - return { status: status, error: errorString } + var errorString = APP_STORE_ERRORS[status] || 'unknown error.'; + return { status: status, error: errorString }; } function validateWithAppStore(url, receipt) { - return new Promise(function(fulfill, reject) { - request.post({ - url: url, - body: { "receipt-data": receipt }, - json: true, - }, function(err, res, body) { - var status = body.status; - if (status == 0) { - // No need to pass anything, status is OK - return fulfill(); - } - // receipt is from test and should go to test - return reject(body); - }); + return request({ + url: url, + method: 'POST', + body: { 'receipt-data': receipt }, + headers: { + 'Content-Type': 'application/json', + }, + }).then(httpResponse => { + const body = httpResponse.data; + if (body && body.status === 0) { + // No need to pass anything, status is OK + return; + } + // receipt is from test and should go to test + throw body; }); } function getFileForProductIdentifier(productIdentifier, req) { - return rest.find(req.config, req.auth, '_Product', { productIdentifier: productIdentifier }, undefined, req.info.clientSDK).then(function(result){ - const products = result.results; - if (!products || products.length != 1) { - // Error not found or too many - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.') - } + return rest + .find( + req.config, + req.auth, + '_Product', + { productIdentifier: productIdentifier }, + undefined, + req.info.clientSDK + ) + .then(function(result) { + const products = result.results; + if (!products || products.length != 1) { + // Error not found or too many + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.' + ); + } - var download = products[0].download; - return Promise.resolve({response: download}); - }); + var download = products[0].download; + return Promise.resolve({ response: download }); + }); } - export class IAPValidationRouter extends PromiseRouter { - handleRequest(req) { let receipt = req.body.receipt; const productIdentifier = req.body.productIdentifier; - if (!receipt || ! productIdentifier) { + if (!receipt || !productIdentifier) { // TODO: Error, malformed request - throw new Parse.Error(Parse.Error.INVALID_JSON, "missing receipt or productIdentifier"); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'missing receipt or productIdentifier' + ); } // Transform the object if there // otherwise assume it's in Base64 already - if (typeof receipt == "object") { - if (receipt["__type"] == "Bytes") { + if (typeof receipt == 'object') { + if (receipt['__type'] == 'Bytes') { receipt = receipt.base64; } } - if (process.env.TESTING == "1" && req.body.bypassAppStoreValidation) { + if (process.env.TESTING == '1' && req.body.bypassAppStoreValidation) { return getFileForProductIdentifier(productIdentifier, req); } @@ -84,28 +98,31 @@ export class IAPValidationRouter extends PromiseRouter { } function errorCallback(error) { - return Promise.resolve({response: appStoreError(error.status) }); + return Promise.resolve({ response: appStoreError(error.status) }); } - return validateWithAppStore(IAP_PRODUCTION_URL, receipt).then(() => { - - return successCallback(); - - }, (error) => { - if (error.status == 21007) { - return validateWithAppStore(IAP_SANDBOX_URL, receipt).then(() => { - return successCallback(); - }, (error) => { - return errorCallback(error); + return validateWithAppStore(IAP_PRODUCTION_URL, receipt).then( + () => { + return successCallback(); + }, + error => { + if (error.status == 21007) { + return validateWithAppStore(IAP_SANDBOX_URL, receipt).then( + () => { + return successCallback(); + }, + error => { + return errorCallback(error); + } + ); } - ); - } - return errorCallback(error); - }); + return errorCallback(error); + } + ); } mountRoutes() { - this.route("POST","/validate_purchase", this.handleRequest); + this.route('POST', '/validate_purchase', this.handleRequest); } } diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 90ab113eb6..a35afd9bb1 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -9,21 +9,41 @@ export class InstallationsRouter extends ClassesRouter { } handleFind(req) { - const body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); + const body = Object.assign( + req.body, + ClassesRouter.JSONFromQuery(req.query) + ); const options = ClassesRouter.optionsFromBody(body); - return rest.find(req.config, req.auth, - '_Installation', body.where, options, req.info.clientSDK) - .then((response) => { - return {response: response}; + return rest + .find( + req.config, + req.auth, + '_Installation', + body.where, + options, + req.info.clientSDK + ) + .then(response => { + return { response: response }; }); } mountRoutes() { - this.route('GET','/installations', req => { return this.handleFind(req); }); - this.route('GET','/installations/:objectId', req => { return this.handleGet(req); }); - this.route('POST','/installations', req => { return this.handleCreate(req); }); - this.route('PUT','/installations/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE','/installations/:objectId', req => { return this.handleDelete(req); }); + this.route('GET', '/installations', req => { + return this.handleFind(req); + }); + this.route('GET', '/installations/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/installations', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/installations/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/installations/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index 5848aa4738..0bc2e23455 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -1,19 +1,26 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import * as middleware from '../middlewares'; export class LogsRouter extends PromiseRouter { - mountRoutes() { - this.route('GET','/scriptlog', middleware.promiseEnforceMasterKeyAccess, this.validateRequest, (req) => { - return this.handleGET(req); - }); + this.route( + 'GET', + '/scriptlog', + middleware.promiseEnforceMasterKeyAccess, + this.validateRequest, + req => { + return this.handleGET(req); + } + ); } validateRequest(req) { if (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not available'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not available' + ); } } @@ -33,21 +40,21 @@ export class LogsRouter extends PromiseRouter { size = req.query.n; } - const order = req.query.order + const order = req.query.order; const level = req.query.level; const options = { from, until, size, order, - level + level, }; - return req.config.loggerController.getLogs(options).then((result) => { + return req.config.loggerController.getLogs(options).then(result => { return Promise.resolve({ - response: result + response: result, }); - }) + }); } } diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 889c13a937..7453835473 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -4,17 +4,24 @@ import express from 'express'; import path from 'path'; import fs from 'fs'; import qs from 'querystring'; +import { Parse } from 'parse/node'; -const public_html = path.resolve(__dirname, "../../public_html"); +const public_html = path.resolve(__dirname, '../../public_html'); const views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { - verifyEmail(req) { - const { token, username } = req.query; + const { username, token: rawToken } = req.query; + const token = + rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const appId = req.params.appId; const config = Config.get(appId); + if (!config) { + this.invalidRequest(); + } + if (!config.publicServerURL) { return this.missingPublicServerURL(); } @@ -24,15 +31,18 @@ export class PublicAPIRouter extends PromiseRouter { } const userController = config.userController; - return userController.verifyEmail(username, token).then(() => { - const params = qs.stringify({username}); - return Promise.resolve({ - status: 302, - location: `${config.verifyEmailSuccessURL}?${params}` - }); - }, ()=> { - return this.invalidVerificationLink(req); - }) + return userController.verifyEmail(username, token).then( + () => { + const params = qs.stringify({ username }); + return Promise.resolve({ + status: 302, + location: `${config.verifyEmailSuccessURL}?${params}`, + }); + }, + () => { + return this.invalidVerificationLink(req); + } + ); } resendVerificationEmail(req) { @@ -40,6 +50,10 @@ export class PublicAPIRouter extends PromiseRouter { const appId = req.params.appId; const config = Config.get(appId); + if (!config) { + this.invalidRequest(); + } + if (!config.publicServerURL) { return this.missingPublicServerURL(); } @@ -50,114 +64,190 @@ export class PublicAPIRouter extends PromiseRouter { const userController = config.userController; - return userController.resendVerificationEmail(username).then(() => { - return Promise.resolve({ - status: 302, - location: `${config.linkSendSuccessURL}` - }); - }, ()=> { - return Promise.resolve({ - status: 302, - location: `${config.linkSendFailURL}` - }); - }) + return userController.resendVerificationEmail(username).then( + () => { + return Promise.resolve({ + status: 302, + location: `${config.linkSendSuccessURL}`, + }); + }, + () => { + return Promise.resolve({ + status: 302, + location: `${config.linkSendFailURL}`, + }); + } + ); } changePassword(req) { return new Promise((resolve, reject) => { const config = Config.get(req.query.id); + + if (!config) { + this.invalidRequest(); + } + if (!config.publicServerURL) { return resolve({ status: 404, - text: 'Not found.' + text: 'Not found.', }); } // Should we keep the file in memory or leave like that? - fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { - if (err) { - return reject(err); + fs.readFile( + path.resolve(views, 'choose_password'), + 'utf-8', + (err, data) => { + if (err) { + return reject(err); + } + data = data.replace( + 'PARSE_SERVER_URL', + `'${config.publicServerURL}'` + ); + resolve({ + text: data, + }); } - data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); - resolve({ - text: data - }) - }); + ); }); } requestResetPassword(req) { - const config = req.config; + if (!config) { + this.invalidRequest(); + } + if (!config.publicServerURL) { return this.missingPublicServerURL(); } - const { username, token } = req.query; + const { username, token: rawToken } = req.query; + const token = + rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; if (!username || !token) { return this.invalidLink(req); } - return config.userController.checkResetTokenValidity(username, token).then(() => { - const params = qs.stringify({token, id: config.applicationId, username, app: config.appName, }); - return Promise.resolve({ - status: 302, - location: `${config.choosePasswordURL}?${params}` - }) - }, () => { - return this.invalidLink(req); - }) + return config.userController.checkResetTokenValidity(username, token).then( + () => { + const params = qs.stringify({ + token, + id: config.applicationId, + username, + app: config.appName, + }); + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?${params}`, + }); + }, + () => { + return this.invalidLink(req); + } + ); } resetPassword(req) { - const config = req.config; + if (!config) { + this.invalidRequest(); + } + if (!config.publicServerURL) { return this.missingPublicServerURL(); } - const { - username, - token, - new_password - } = req.body; + const { username, new_password, token: rawToken } = req.body; + const token = + rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; - if (!username || !token || !new_password) { + if ((!username || !token || !new_password) && req.xhr === false) { return this.invalidLink(req); } - return config.userController.updatePassword(username, token, new_password).then(() => { - const params = qs.stringify({username: username}); - return Promise.resolve({ - status: 302, - location: `${config.passwordResetSuccessURL}?${params}` - }); - }, (err) => { - const params = qs.stringify({username: username, token: token, id: config.applicationId, error:err, app:config.appName}) - return Promise.resolve({ - status: 302, - location: `${config.choosePasswordURL}?${params}` - }); - }); + if (!username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'Missing username'); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token'); + } + if (!new_password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Missing password'); + } + + return config.userController + .updatePassword(username, token, new_password) + .then( + () => { + return Promise.resolve({ + success: true, + }); + }, + err => { + return Promise.resolve({ + success: false, + err, + }); + } + ) + .then(result => { + const params = qs.stringify({ + username: username, + token: token, + id: config.applicationId, + error: result.err, + app: config.appName, + }); + + if (req.xhr) { + if (result.success) { + return Promise.resolve({ + status: 200, + response: 'Password successfully reset', + }); + } + if (result.err) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, `${result.err}`); + } + } + + const encodedUsername = encodeURIComponent(username); + const location = result.success + ? `${config.passwordResetSuccessURL}?username=${encodedUsername}` + : `${config.choosePasswordURL}?${params}`; + + return Promise.resolve({ + status: 302, + location, + }); + }); } invalidLink(req) { return Promise.resolve({ status: 302, - location: req.config.invalidLinkURL + location: req.config.invalidLinkURL, }); } invalidVerificationLink(req) { const config = req.config; if (req.query.username && req.params.appId) { - const params = qs.stringify({username: req.query.username, appId: req.params.appId}); + const params = qs.stringify({ + username: req.query.username, + appId: req.params.appId, + }); return Promise.resolve({ status: 302, - location: `${config.invalidVerificationLinkURL}?${params}` + location: `${config.invalidVerificationLinkURL}?${params}`, }); } else { return this.invalidLink(req); @@ -166,41 +256,77 @@ export class PublicAPIRouter extends PromiseRouter { missingPublicServerURL() { return Promise.resolve({ - text: 'Not found.', - status: 404 + text: 'Not found.', + status: 404, }); } + invalidRequest() { + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + setConfig(req) { req.config = Config.get(req.params.appId); return Promise.resolve(); } mountRoutes() { - this.route('GET','/apps/:appId/verify_email', - req => { this.setConfig(req) }, - req => { return this.verifyEmail(req); }); - - this.route('POST', '/apps/:appId/resend_verification_email', - req => { this.setConfig(req); }, - req => { return this.resendVerificationEmail(req); }); - - this.route('GET','/apps/choose_password', - req => { return this.changePassword(req); }); + this.route( + 'GET', + '/apps/:appId/verify_email', + req => { + this.setConfig(req); + }, + req => { + return this.verifyEmail(req); + } + ); + + this.route( + 'POST', + '/apps/:appId/resend_verification_email', + req => { + this.setConfig(req); + }, + req => { + return this.resendVerificationEmail(req); + } + ); - this.route('POST','/apps/:appId/request_password_reset', - req => { this.setConfig(req) }, - req => { return this.resetPassword(req); }); + this.route('GET', '/apps/choose_password', req => { + return this.changePassword(req); + }); - this.route('GET','/apps/:appId/request_password_reset', - req => { this.setConfig(req) }, - req => { return this.requestResetPassword(req); }); + this.route( + 'POST', + '/apps/:appId/request_password_reset', + req => { + this.setConfig(req); + }, + req => { + return this.resetPassword(req); + } + ); + + this.route( + 'GET', + '/apps/:appId/request_password_reset', + req => { + this.setConfig(req); + }, + req => { + return this.requestResetPassword(req); + } + ); } expressRouter() { const router = express.Router(); - router.use("/apps", express.static(public_html)); - router.use("/", super.expressRouter()); + router.use('/apps', express.static(public_html)); + router.use('/', super.expressRouter()); return router; } } diff --git a/src/Routers/PurgeRouter.js b/src/Routers/PurgeRouter.js index 96dd3bf40c..6d0aca0c3e 100644 --- a/src/Routers/PurgeRouter.js +++ b/src/Routers/PurgeRouter.js @@ -1,10 +1,17 @@ import PromiseRouter from '../PromiseRouter'; import * as middleware from '../middlewares'; +import Parse from 'parse/node'; export class PurgeRouter extends PromiseRouter { - handlePurge(req) { - return req.config.database.purgeCollection(req.params.className) + if (req.auth.isReadOnly) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to purge a schema." + ); + } + return req.config.database + .purgeCollection(req.params.className) .then(() => { var cacheAdapter = req.config.cacheController; if (req.params.className == '_Session') { @@ -12,12 +19,25 @@ export class PurgeRouter extends PromiseRouter { } else if (req.params.className == '_Role') { cacheAdapter.role.clear(); } - return {response: {}}; + return { response: {} }; + }) + .catch(error => { + if (!error || (error && error.code === Parse.Error.OBJECT_NOT_FOUND)) { + return { response: {} }; + } + throw error; }); } mountRoutes() { - this.route('DELETE', '/purge/:className', middleware.promiseEnforceMasterKeyAccess, (req) => { return this.handlePurge(req); }); + this.route( + 'DELETE', + '/purge/:className', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.handlePurge(req); + } + ); } } diff --git a/src/Routers/PushRouter.js b/src/Routers/PushRouter.js index 14def667e6..b6542fce57 100644 --- a/src/Routers/PushRouter.js +++ b/src/Routers/PushRouter.js @@ -1,41 +1,56 @@ -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; -import { Parse } from "parse/node"; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { Parse } from 'parse/node'; export class PushRouter extends PromiseRouter { - mountRoutes() { - this.route("POST", "/push", middleware.promiseEnforceMasterKeyAccess, PushRouter.handlePOST); + this.route( + 'POST', + '/push', + middleware.promiseEnforceMasterKeyAccess, + PushRouter.handlePOST + ); } static handlePOST(req) { if (req.auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to send push notifications.'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to send push notifications." + ); } const pushController = req.config.pushController; if (!pushController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push controller is not set'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Push controller is not set' + ); } const where = PushRouter.getQueryCondition(req); let resolve; - const promise = new Promise((_resolve) => { + const promise = new Promise(_resolve => { resolve = _resolve; }); let pushStatusId; - pushController.sendPush(req.body, where, req.config, req.auth, (objectId) => { - pushStatusId = objectId; - resolve({ - headers: { - 'X-Parse-Push-Status-Id': pushStatusId - }, - response: { - result: true - } + pushController + .sendPush(req.body, where, req.config, req.auth, objectId => { + pushStatusId = objectId; + resolve({ + headers: { + 'X-Parse-Push-Status-Id': pushStatusId, + }, + response: { + result: true, + }, + }); + }) + .catch(err => { + req.config.loggerController.error( + `_PushStatus ${pushStatusId}: error while sending push`, + err + ); }); - }).catch((err) => { - req.config.loggerController.error(`_PushStatus ${pushStatusId}: error while sending push`, err); - }); return promise; } @@ -51,18 +66,23 @@ export class PushRouter extends PromiseRouter { let where; if (hasWhere && hasChannels) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Channels and query can not be set at the same time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Channels and query can not be set at the same time.' + ); } else if (hasWhere) { where = body.where; } else if (hasChannels) { where = { - "channels": { - "$in": body.channels - } - } + channels: { + $in: body.channels, + }, + }; } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Sending a push requires either "channels" or a "where" query.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Sending a push requires either "channels" or a "where" query.' + ); } return where; } diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js index 67fea13817..e6a10df77b 100644 --- a/src/Routers/RolesRouter.js +++ b/src/Routers/RolesRouter.js @@ -1,4 +1,3 @@ - import ClassesRouter from './ClassesRouter'; export class RolesRouter extends ClassesRouter { @@ -7,11 +6,21 @@ export class RolesRouter extends ClassesRouter { } mountRoutes() { - this.route('GET','/roles', req => { return this.handleFind(req); }); - this.route('GET','/roles/:objectId', req => { return this.handleGet(req); }); - this.route('POST','/roles', req => { return this.handleCreate(req); }); - this.route('PUT','/roles/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE','/roles/:objectId', req => { return this.handleDelete(req); }); + this.route('GET', '/roles', req => { + return this.handleFind(req); + }); + this.route('GET', '/roles/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/roles', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/roles/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/roles/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 0e572c88a5..efc831dc04 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -3,8 +3,8 @@ var Parse = require('parse/node').Parse, SchemaController = require('../Controllers/SchemaController'); -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; function classNameMismatchResponse(bodyClass, pathClass) { throw new Parse.Error( @@ -14,32 +14,46 @@ function classNameMismatchResponse(bodyClass, pathClass) { } function getAllSchemas(req) { - return req.config.database.loadSchema({ clearCache: true}) + return req.config.database + .loadSchema({ clearCache: true }) .then(schemaController => schemaController.getAllClasses(true)) .then(schemas => ({ response: { results: schemas } })); } function getOneSchema(req) { const className = req.params.className; - return req.config.database.loadSchema({ clearCache: true}) + return req.config.database + .loadSchema({ clearCache: true }) .then(schemaController => schemaController.getOneSchema(className, true)) .then(schema => ({ response: schema })) .catch(error => { if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} does not exist.` + ); } else { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error.'); + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Database adapter error.' + ); } }); } function createSchema(req) { if (req.auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to create a schema.'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to create a schema." + ); } if (req.params.className && req.body.className) { if (req.params.className != req.body.className) { - return classNameMismatchResponse(req.body.className, req.params.className); + return classNameMismatchResponse( + req.body.className, + req.params.className + ); } } @@ -48,14 +62,25 @@ function createSchema(req) { throw new Parse.Error(135, `POST ${req.path} needs a class name.`); } - return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) + return req.config.database + .loadSchema({ clearCache: true }) + .then(schema => + schema.addClassIfNotExists( + className, + req.body.fields, + req.body.classLevelPermissions, + req.body.indexes + ) + ) .then(schema => ({ response: schema })); } function modifySchema(req) { if (req.auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update a schema.'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update a schema." + ); } if (req.body.className && req.body.className != req.params.className) { return classNameMismatchResponse(req.body.className, req.params.className); @@ -64,29 +89,75 @@ function modifySchema(req) { const submittedFields = req.body.fields || {}; const className = req.params.className; - return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database)) - .then(result => ({response: result})); + return req.config.database + .loadSchema({ clearCache: true }) + .then(schema => + schema.updateClass( + className, + submittedFields, + req.body.classLevelPermissions, + req.body.indexes, + req.config.database + ) + ) + .then(result => ({ response: result })); } const deleteSchema = req => { if (req.auth.isReadOnly) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to delete a schema.'); + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to delete a schema." + ); } if (!SchemaController.classNameIsValid(req.params.className)) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, SchemaController.invalidClassNameMessage(req.params.className)); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + SchemaController.invalidClassNameMessage(req.params.className) + ); } - return req.config.database.deleteSchema(req.params.className) + return req.config.database + .deleteSchema(req.params.className) .then(() => ({ response: {} })); -} +}; export class SchemasRouter extends PromiseRouter { mountRoutes() { - this.route('GET', '/schemas', middleware.promiseEnforceMasterKeyAccess, getAllSchemas); - this.route('GET', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, getOneSchema); - this.route('POST', '/schemas', middleware.promiseEnforceMasterKeyAccess, createSchema); - this.route('POST', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, createSchema); - this.route('PUT', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, modifySchema); - this.route('DELETE', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, deleteSchema); + this.route( + 'GET', + '/schemas', + middleware.promiseEnforceMasterKeyAccess, + getAllSchemas + ); + this.route( + 'GET', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + getOneSchema + ); + this.route( + 'POST', + '/schemas', + middleware.promiseEnforceMasterKeyAccess, + createSchema + ); + this.route( + 'POST', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + createSchema + ); + this.route( + 'PUT', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + modifySchema + ); + this.route( + 'DELETE', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + deleteSchema + ); } } diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js index ed9b3830f7..4b8488bf43 100644 --- a/src/Routers/SessionsRouter.js +++ b/src/Routers/SessionsRouter.js @@ -1,13 +1,9 @@ - import ClassesRouter from './ClassesRouter'; -import Parse from 'parse/node'; -import rest from '../rest'; -import Auth from '../Auth'; -import RestWrite from '../RestWrite'; -import { newToken } from '../cryptoUtils'; +import Parse from 'parse/node'; +import rest from '../rest'; +import Auth from '../Auth'; export class SessionsRouter extends ClassesRouter { - className() { return '_Session'; } @@ -15,66 +11,89 @@ export class SessionsRouter extends ClassesRouter { handleMe(req) { // TODO: Verify correct behavior if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.' + ); } - return rest.find(req.config, Auth.master(req.config), '_Session', { sessionToken: req.info.sessionToken }, undefined, req.info.clientSDK) - .then((response) => { + return rest + .find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken: req.info.sessionToken }, + undefined, + req.info.clientSDK + ) + .then(response => { if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.' + ); } return { - response: response.results[0] + response: response.results[0], }; }); } handleUpdateToRevocableSession(req) { const config = req.config; - const masterAuth = Auth.master(config) const user = req.auth.user; // Issue #2720 // Calling without a session token would result in a not found user if (!user) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'invalid session'); } - const expiresAt = config.generateSessionExpiresAt(); - const sessionData = { - sessionToken: 'r:' + newToken(), - user: { - __type: 'Pointer', - className: '_User', - objectId: user.id - }, + const { sessionData, createSession } = Auth.createSession(config, { + userId: user.id, createdWith: { - 'action': 'upgrade', + action: 'upgrade', }, - restricted: false, installationId: req.auth.installationId, - expiresAt: Parse._encode(expiresAt) - }; - const create = new RestWrite(config, masterAuth, '_Session', null, sessionData); - return create.execute().then(() => { - // delete the session token, use the db to skip beforeSave - return config.database.update('_User', { - objectId: user.id - }, { - sessionToken: {__op: 'Delete'} - }); - }).then(() => { - return Promise.resolve({ response: sessionData }); }); + + return createSession() + .then(() => { + // delete the session token, use the db to skip beforeSave + return config.database.update( + '_User', + { + objectId: user.id, + }, + { + sessionToken: { __op: 'Delete' }, + } + ); + }) + .then(() => { + return Promise.resolve({ response: sessionData }); + }); } mountRoutes() { - this.route('GET','/sessions/me', req => { return this.handleMe(req); }); - this.route('GET', '/sessions', req => { return this.handleFind(req); }); - this.route('GET', '/sessions/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/sessions', req => { return this.handleCreate(req); }); - this.route('PUT', '/sessions/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE', '/sessions/:objectId', req => { return this.handleDelete(req); }); - this.route('POST', '/upgradeToRevocableSession', req => { return this.handleUpdateToRevocableSession(req); }) + this.route('GET', '/sessions/me', req => { + return this.handleMe(req); + }); + this.route('GET', '/sessions', req => { + return this.handleFind(req); + }); + this.route('GET', '/sessions/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/sessions', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/sessions/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/sessions/:objectId', req => { + return this.handleDelete(req); + }); + this.route('POST', '/upgradeToRevocableSession', req => { + return this.handleUpdateToRevocableSession(req); + }); } } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 63e14b8a59..c2587bed6e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,203 +1,356 @@ // These methods handle the User-related routes. -import Parse from 'parse/node'; -import Config from '../Config'; +import Parse from 'parse/node'; +import Config from '../Config'; import AccountLockout from '../AccountLockout'; -import ClassesRouter from './ClassesRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import Auth from '../Auth'; import passwordCrypto from '../password'; -import RestWrite from '../RestWrite'; -const cryptoUtils = require('../cryptoUtils'); +import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; export class UsersRouter extends ClassesRouter { - className() { return '_User'; } + /** + * Removes all "_" prefixed properties from an object, except "__type" + * @param {Object} obj An object. + */ + static removeHiddenProperties(obj) { + for (var key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + // Regexp comes from Parse.Object.prototype.validate + if (key !== '__type' && !/^[A-Za-z][0-9A-Za-z_]*$/.test(key)) { + delete obj[key]; + } + } + } + } + + /** + * Validates a password request in login and verifyPassword + * @param {Object} req The request + * @returns {Object} User object + * @private + */ + _authenticateUserFromRequest(req) { + return new Promise((resolve, reject) => { + // Use query parameters instead if provided in url + let payload = req.body; + if ( + (!payload.username && req.query.username) || + (!payload.email && req.query.email) + ) { + payload = req.query; + } + const { username, email, password } = payload; + + // TODO: use the right error codes / descriptions. + if (!username && !email) { + throw new Parse.Error( + Parse.Error.USERNAME_MISSING, + 'username/email is required.' + ); + } + if (!password) { + throw new Parse.Error( + Parse.Error.PASSWORD_MISSING, + 'password is required.' + ); + } + if ( + typeof password !== 'string' || + (email && typeof email !== 'string') || + (username && typeof username !== 'string') + ) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.' + ); + } + + let user; + let isValidPassword = false; + let query; + if (email && username) { + query = { email, username }; + } else if (email) { + query = { email }; + } else { + query = { $or: [{ username }, { email: username }] }; + } + return req.config.database + .find('_User', query) + .then(results => { + if (!results.length) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.' + ); + } + + if (results.length > 1) { + // corner case where user1 has username == user2 email + req.config.loggerController.warn( + "There is a user which email is the same as another user's username, logging in based on username" + ); + user = results.filter(user => user.username === username)[0]; + } else { + user = results[0]; + } + + return passwordCrypto.compare(password, user.password); + }) + .then(correct => { + isValidPassword = correct; + const accountLockoutPolicy = new AccountLockout(user, req.config); + return accountLockoutPolicy.handleLoginAttempt(isValidPassword); + }) + .then(() => { + if (!isValidPassword) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.' + ); + } + // Ensure the user isn't locked out + // A locked out user won't be able to login + // To lock a user out, just set the ACL to `masterKey` only ({}). + // Empty ACL is OK + if ( + !req.auth.isMaster && + user.ACL && + Object.keys(user.ACL).length == 0 + ) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.' + ); + } + if ( + req.config.verifyUserEmails && + req.config.preventLoginWithUnverifiedEmail && + !user.emailVerified + ) { + throw new Parse.Error( + Parse.Error.EMAIL_NOT_FOUND, + 'User email is not verified.' + ); + } + + delete user.password; + + // Sometimes the authData still has null on that keys + // https://github.com/parse-community/parse-server/issues/935 + if (user.authData) { + Object.keys(user.authData).forEach(provider => { + if (user.authData[provider] === null) { + delete user.authData[provider]; + } + }); + if (Object.keys(user.authData).length == 0) { + delete user.authData; + } + } + + return resolve(user); + }) + .catch(error => { + return reject(error); + }); + }); + } + handleMe(req) { if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); } const sessionToken = req.info.sessionToken; - return rest.find(req.config, Auth.master(req.config), '_Session', - { sessionToken }, - { include: 'user' }, req.info.clientSDK) - .then((response) => { - if (!response.results || + return rest + .find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken }, + { include: 'user' }, + req.info.clientSDK + ) + .then(response => { + if ( + !response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); + !response.results[0].user + ) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); } else { const user = response.results[0].user; // Send token back on the login, because SDKs expect that. user.sessionToken = sessionToken; // Remove hidden properties. - for (var key in user) { - if (user.hasOwnProperty(key)) { - // Regexp comes from Parse.Object.prototype.validate - if (key !== "__type" && !(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) { - delete user[key]; - } - } - } + UsersRouter.removeHiddenProperties(user); return { response: user }; } }); } - handleLogIn(req) { - // Use query parameters instead if provided in url - let payload = req.body; - if (!payload.username && req.query.username || !payload.email && req.query.email) { - payload = req.query; - } - const { - username, - email, - password, - } = payload; - - // TODO: use the right error codes / descriptions. - if (!username && !email) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); - } - if (!password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); - } - if (typeof password !== 'string' - || email && typeof email !== 'string' - || username && typeof username !== 'string') { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } + async handleLogIn(req) { + const user = await this._authenticateUserFromRequest(req); - let user; - let isValidPassword = false; - const query = Object.assign({}, username ? { username } : {}, email ? { email } : {}); - return req.config.database.find('_User', query) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } - user = results[0]; + // handle password expiry policy + if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { + let changedAt = user._password_changed_at; - if (req.config.verifyUserEmails && req.config.preventLoginWithUnverifiedEmail && !user.emailVerified) { - throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); - } - return passwordCrypto.compare(password, user.password); - }) - .then((correct) => { - isValidPassword = correct; - const accountLockoutPolicy = new AccountLockout(user, req.config); - return accountLockoutPolicy.handleLoginAttempt(isValidPassword); - }) - .then(() => { - if (!isValidPassword) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + if (!changedAt) { + // password was created before expiry policy was enabled. + // simply update _User object so that it will start enforcing from now + changedAt = new Date(); + req.config.database.update( + '_User', + { username: user.username }, + { _password_changed_at: Parse._encode(changedAt) } + ); + } else { + // check whether the password has expired + if (changedAt.__type == 'Date') { + changedAt = new Date(changedAt.iso); } + // Calculate the expiry time. + const expiresAt = new Date( + changedAt.getTime() + + 86400000 * req.config.passwordPolicy.maxPasswordAge + ); + if (expiresAt < new Date()) + // fail of current time is past password expiry time + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your password has expired. Please reset your password.' + ); + } + } - // handle password expiry policy - if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { - let changedAt = user._password_changed_at; + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); - if (!changedAt) { - // password was created before expiry policy was enabled. - // simply update _User object so that it will start enforcing from now - changedAt = new Date(); - req.config.database.update('_User', {username: user.username}, - {_password_changed_at: Parse._encode(changedAt)}); - } else { - // check whether the password has expired - if (changedAt.__type == 'Date') { - changedAt = new Date(changedAt.iso); - } - // Calculate the expiry time. - const expiresAt = new Date(changedAt.getTime() + 86400000 * req.config.passwordPolicy.maxPasswordAge); - if (expiresAt < new Date()) // fail of current time is past password expiry time - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Your password has expired. Please reset your password.'); - } - } + req.config.filesController.expandFilesInObject(req.config, user); - const token = 'r:' + cryptoUtils.newToken(); - user.sessionToken = token; - delete user.password; + // Before login trigger; throws if failure + await maybeRunTrigger( + TriggerTypes.beforeLogin, + req.auth, + Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + null, + req.config + ); - // Sometimes the authData still has null on that keys - // https://github.com/parse-community/parse-server/issues/935 - if (user.authData) { - Object.keys(user.authData).forEach((provider) => { - if (user.authData[provider] === null) { - delete user.authData[provider]; - } - }); - if (Object.keys(user.authData).length == 0) { - delete user.authData; - } - } + const { sessionData, createSession } = Auth.createSession(req.config, { + userId: user.objectId, + createdWith: { + action: 'login', + authProvider: 'password', + }, + installationId: req.info.installationId, + }); - req.config.filesController.expandFilesInObject(req.config, user); - - const expiresAt = req.config.generateSessionExpiresAt(); - const sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } + user.sessionToken = sessionData.sessionToken; + + await createSession(); + + const afterLoginUser = Parse.User.fromJSON( + Object.assign({ className: '_User' }, user) + ); + maybeRunTrigger( + TriggerTypes.afterLogin, + { ...req.auth, user: afterLoginUser }, + afterLoginUser, + null, + req.config + ); + + return { response: user }; + } + + handleVerifyPassword(req) { + return this._authenticateUserFromRequest(req) + .then(user => { + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); - const create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData); - return create.execute(); - }).then(() => { return { response: user }; + }) + .catch(error => { + throw error; }); } handleLogOut(req) { - const success = {response: {}}; + const success = { response: {} }; if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - { sessionToken: req.info.sessionToken }, undefined, req.info.clientSDK - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); + return rest + .find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken: req.info.sessionToken }, + undefined, + req.info.clientSDK + ) + .then(records => { + if (records.results && records.results.length) { + return rest + .del( + req.config, + Auth.master(req.config), + '_Session', + records.results[0].objectId + ) + .then(() => { + this._runAfterLogoutTrigger(req, records.results[0]); + return Promise.resolve(success); + }); + } + return Promise.resolve(success); + }); } return Promise.resolve(success); } + _runAfterLogoutTrigger(req, session) { + // After logout trigger + maybeRunTrigger( + TriggerTypes.afterLogout, + req.auth, + Parse.Session.fromJSON(Object.assign({ className: '_Session' }, session)), + null, + req.config + ); + } + _throwOnBadEmailConfig(req) { try { Config.validateEmailConfiguration({ emailAdapter: req.config.userController.adapter, appName: req.config.appName, publicServerURL: req.config.publicServerURL, - emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration + emailVerifyTokenValidityDuration: + req.config.emailVerifyTokenValidityDuration, }); } catch (e) { if (typeof e === 'string') { // Maybe we need a Bad Configuration error, but the SDKs won't understand it. For now, Internal Server Error. - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.'); + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); } else { throw e; } @@ -209,23 +362,36 @@ export class UsersRouter extends ClassesRouter { const { email } = req.body; if (!email) { - throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); + throw new Parse.Error( + Parse.Error.EMAIL_MISSING, + 'you must provide an email' + ); } if (typeof email !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'you must provide a valid email string'); + throw new Parse.Error( + Parse.Error.INVALID_EMAIL_ADDRESS, + 'you must provide a valid email string' + ); } const userController = req.config.userController; - return userController.sendPasswordResetEmail(email).then(() => { - return Promise.resolve({ - response: {} - }); - }, err => { - if (err.code === Parse.Error.OBJECT_NOT_FOUND) { - throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}.`); - } else { - throw err; + return userController.sendPasswordResetEmail(email).then( + () => { + return Promise.resolve({ + response: {}, + }); + }, + err => { + if (err.code === Parse.Error.OBJECT_NOT_FOUND) { + // Return success so that this endpoint can't + // be used to enumerate valid emails + return Promise.resolve({ + response: {}, + }); + } else { + throw err; + } } - }); + ); } handleVerificationEmailRequest(req) { @@ -233,41 +399,82 @@ export class UsersRouter extends ClassesRouter { const { email } = req.body; if (!email) { - throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); + throw new Parse.Error( + Parse.Error.EMAIL_MISSING, + 'you must provide an email' + ); } if (typeof email !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'you must provide a valid email string'); + throw new Parse.Error( + Parse.Error.INVALID_EMAIL_ADDRESS, + 'you must provide a valid email string' + ); } - return req.config.database.find('_User', { email: email }).then((results) => { + return req.config.database.find('_User', { email: email }).then(results => { if (!results.length || results.length < 1) { - throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`); + throw new Parse.Error( + Parse.Error.EMAIL_NOT_FOUND, + `No user found with email ${email}` + ); } const user = results[0]; + // remove password field, messes with saving on postgres + delete user.password; + if (user.emailVerified) { - throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`); + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `Email ${email} is already verified.` + ); } const userController = req.config.userController; - userController.sendVerificationEmail(user); - return { response: {} }; + return userController.regenerateEmailVerifyToken(user).then(() => { + userController.sendVerificationEmail(user); + return { response: {} }; + }); }); } - mountRoutes() { - this.route('GET', '/users', req => { return this.handleFind(req); }); - this.route('POST', '/users', req => { return this.handleCreate(req); }); - this.route('GET', '/users/me', req => { return this.handleMe(req); }); - this.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); - this.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); - this.route('GET', '/login', req => { return this.handleLogIn(req); }); - this.route('POST', '/login', req => { return this.handleLogIn(req); }); - this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }); - this.route('POST', '/verificationEmailRequest', req => { return this.handleVerificationEmailRequest(req); }); + this.route('GET', '/users', req => { + return this.handleFind(req); + }); + this.route('POST', '/users', req => { + return this.handleCreate(req); + }); + this.route('GET', '/users/me', req => { + return this.handleMe(req); + }); + this.route('GET', '/users/:objectId', req => { + return this.handleGet(req); + }); + this.route('PUT', '/users/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/users/:objectId', req => { + return this.handleDelete(req); + }); + this.route('GET', '/login', req => { + return this.handleLogIn(req); + }); + this.route('POST', '/login', req => { + return this.handleLogIn(req); + }); + this.route('POST', '/logout', req => { + return this.handleLogOut(req); + }); + this.route('POST', '/requestPasswordReset', req => { + return this.handleResetRequest(req); + }); + this.route('POST', '/verificationEmailRequest', req => { + return this.handleVerificationEmailRequest(req); + }); + this.route('GET', '/verifyPassword', req => { + return this.handleVerifyPassword(req); + }); } } diff --git a/src/StatusHandler.js b/src/StatusHandler.js index f77c0bbcce..db8e816596 100644 --- a/src/StatusHandler.js +++ b/src/StatusHandler.js @@ -1,24 +1,24 @@ import { md5Hash, newObjectId } from './cryptoUtils'; -import { logger } from './logger'; -import rest from './rest'; -import Auth from './Auth'; +import { logger } from './logger'; +import rest from './rest'; +import Auth from './Auth'; const PUSH_STATUS_COLLECTION = '_PushStatus'; const JOB_STATUS_COLLECTION = '_JobStatus'; const incrementOp = function(object = {}, key, amount = 1) { if (!object[key]) { - object[key] = {__op: 'Increment', amount: amount} + object[key] = { __op: 'Increment', amount: amount }; } else { object[key].amount += amount; } return object[key]; -} +}; export function flatten(array) { var flattened = []; - for(var i = 0; i < array.length; i++) { - if(Array.isArray(array[i])) { + for (var i = 0; i < array.length; i++) { + if (Array.isArray(array[i])) { flattened = flattened.concat(flatten(array[i])); } else { flattened.push(array[i]); @@ -48,8 +48,8 @@ function statusHandler(className, database) { return Object.freeze({ create, - update - }) + update, + }); } function restStatusHandler(className, config) { @@ -57,7 +57,8 @@ function restStatusHandler(className, config) { const auth = Auth.master(config); function create(object) { lastPromise = lastPromise.then(() => { - return rest.create(config, auth, className, object) + return rest + .create(config, auth, className, object) .then(({ response }) => { // merge the objects return Promise.resolve(Object.assign({}, object, response)); @@ -69,7 +70,8 @@ function restStatusHandler(className, config) { function update(where, object) { // TODO: when we have updateWhere, use that for proper interfacing lastPromise = lastPromise.then(() => { - return rest.update(config, auth, className, { objectId: where.objectId }, object) + return rest + .update(config, auth, className, { objectId: where.objectId }, object) .then(({ response }) => { // merge the objects return Promise.resolve(Object.assign({}, object, response)); @@ -80,8 +82,8 @@ function restStatusHandler(className, config) { return Object.freeze({ create, - update - }) + update, + }); } export function jobStatusHandler(config) { @@ -99,26 +101,26 @@ export function jobStatusHandler(config) { source: 'api', createdAt: now, // lockdown! - ACL: {} - } + ACL: {}, + }; return handler.create(jobStatus); - } + }; const setMessage = function(message) { if (!message || typeof message !== 'string') { return Promise.resolve(); } return handler.update({ objectId }, { message }); - } + }; const setSucceeded = function(message) { return setFinalStatus('succeeded', message); - } + }; const setFailed = function(message) { return setFinalStatus('failed', message); - } + }; const setFinalStatus = function(status, message = undefined) { const finishedAt = new Date(); @@ -127,37 +129,38 @@ export function jobStatusHandler(config) { update.message = message; } return handler.update({ objectId }, update); - } + }; return Object.freeze({ setRunning, setSucceeded, setMessage, - setFailed + setFailed, }); } export function pushStatusHandler(config, existingObjectId) { - let pushStatus; const database = config.database; const handler = restStatusHandler(PUSH_STATUS_COLLECTION, config); let objectId = existingObjectId; - const setInitial = function(body = {}, where, options = {source: 'rest'}) { + const setInitial = function(body = {}, where, options = { source: 'rest' }) { const now = new Date(); let pushTime = now.toISOString(); let status = 'pending'; - if (body.hasOwnProperty('push_time')) { + if (Object.prototype.hasOwnProperty.call(body, 'push_time')) { if (config.hasPushScheduledSupport) { pushTime = body.push_time; status = 'scheduled'; } else { - logger.warn('Trying to schedule a push while server is not configured.'); + logger.warn( + 'Trying to schedule a push while server is not configured.' + ); logger.warn('Push will be sent immediately'); } } - const data = body.data || {}; + const data = body.data || {}; const payloadString = JSON.stringify(data); let pushHash; if (typeof data.alert === 'string') { @@ -179,27 +182,43 @@ export function pushStatusHandler(config, existingObjectId) { numSent: 0, pushHash, // lockdown! - ACL: {} - } - return handler.create(object).then((result) => { + ACL: {}, + }; + return handler.create(object).then(result => { objectId = result.objectId; pushStatus = { - objectId + objectId, }; return Promise.resolve(pushStatus); }); - } + }; - const setRunning = function(count) { - logger.verbose(`_PushStatus ${objectId}: sending push to %d installations`, count); - return handler.update({status:"pending", objectId: objectId}, - {status: "running", count }); - } + const setRunning = function(batches) { + logger.verbose( + `_PushStatus ${objectId}: sending push to installations with %d batches`, + batches + ); + return handler.update( + { + status: 'pending', + objectId: objectId, + }, + { + status: 'running', + count: batches, + } + ); + }; - const trackSent = function(results, UTCOffset, cleanupInstallations = process.env.PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS) { + const trackSent = function( + results, + UTCOffset, + cleanupInstallations = process.env + .PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS + ) { const update = { numSent: 0, - numFailed: 0 + numFailed: 0, }; const devicesToRemove = []; if (Array.isArray(results)) { @@ -210,16 +229,26 @@ export function pushStatusHandler(config, existingObjectId) { return memo; } const deviceType = result.device.deviceType; - const key = result.transmitted ? `sentPerType.${deviceType}` : `failedPerType.${deviceType}`; + const key = result.transmitted + ? `sentPerType.${deviceType}` + : `failedPerType.${deviceType}`; memo[key] = incrementOp(memo, key); if (typeof UTCOffset !== 'undefined') { - const offsetKey = result.transmitted ? `sentPerUTCOffset.${UTCOffset}` : `failedPerUTCOffset.${UTCOffset}`; + const offsetKey = result.transmitted + ? `sentPerUTCOffset.${UTCOffset}` + : `failedPerUTCOffset.${UTCOffset}`; memo[offsetKey] = incrementOp(memo, offsetKey); } if (result.transmitted) { memo.numSent++; } else { - if (result && result.response && result.response.error && result.device && result.device.deviceToken) { + if ( + result && + result.response && + result.response.error && + result.device && + result.device.deviceToken + ) { const token = result.device.deviceToken; const error = result.response.error; // GCM errors @@ -235,16 +264,21 @@ export function pushStatusHandler(config, existingObjectId) { } return memo; }, update); - incrementOp(update, 'count', -results.length); } - logger.verbose(`_PushStatus ${objectId}: sent push! %d success, %d failures`, update.numSent, update.numFailed); - logger.verbose(`_PushStatus ${objectId}: needs cleanup`, { devicesToRemove }); - ['numSent', 'numFailed'].forEach((key) => { + logger.verbose( + `_PushStatus ${objectId}: sent push! %d success, %d failures`, + update.numSent, + update.numFailed + ); + logger.verbose(`_PushStatus ${objectId}: needs cleanup`, { + devicesToRemove, + }); + ['numSent', 'numFailed'].forEach(key => { if (update[key] > 0) { update[key] = { __op: 'Increment', - amount: update[key] + amount: update[key], }; } else { delete update[key]; @@ -252,26 +286,39 @@ export function pushStatusHandler(config, existingObjectId) { }); if (devicesToRemove.length > 0 && cleanupInstallations) { - logger.info(`Removing device tokens on ${devicesToRemove.length} _Installations`); - database.update('_Installation', { deviceToken: { '$in': devicesToRemove }}, { deviceToken: {"__op": "Delete"} }, { - acl: undefined, - many: true - }); + logger.info( + `Removing device tokens on ${devicesToRemove.length} _Installations` + ); + database.update( + '_Installation', + { deviceToken: { $in: devicesToRemove } }, + { deviceToken: { __op: 'Delete' } }, + { + acl: undefined, + many: true, + } + ); } - return handler.update({ objectId }, update).then((res) => { + // indicate this batch is complete + incrementOp(update, 'count', -1); + + return handler.update({ objectId }, update).then(res => { if (res && res.count === 0) { return this.complete(); } - }) - } + }); + }; const complete = function() { - return handler.update({ objectId }, { - status: 'succeeded', - count: {__op: 'Delete'} - }); - } + return handler.update( + { objectId }, + { + status: 'succeeded', + count: { __op: 'Delete' }, + } + ); + }; const fail = function(err) { if (typeof err === 'string') { @@ -279,22 +326,22 @@ export function pushStatusHandler(config, existingObjectId) { } const update = { errorMessage: err, - status: 'failed' - } + status: 'failed', + }; return handler.update({ objectId }, update); - } + }; const rval = { setInitial, setRunning, trackSent, complete, - fail + fail, }; // define objectId to be dynamic - Object.defineProperty(rval, "objectId", { - get: () => objectId + Object.defineProperty(rval, 'objectId', { + get: () => objectId, }); return Object.freeze(rval); diff --git a/src/TestUtils.js b/src/TestUtils.js index b0ebf4cf5b..7772bcd65b 100644 --- a/src/TestUtils.js +++ b/src/TestUtils.js @@ -1,16 +1,21 @@ import AppCache from './cache'; -//Used by tests -export function destroyAllDataPermanently() { +/** + * Destroys all data in the database + * @param {boolean} fast set to true if it's ok to just drop objects and not indexes. + */ +export function destroyAllDataPermanently(fast) { if (!process.env.TESTING) { throw 'Only supported in test environment'; } - return Promise.all(Object.keys(AppCache.cache).map(appId => { - const app = AppCache.get(appId); - if (app.databaseController) { - return app.databaseController.deleteEverything(); - } else { - return Promise.resolve(); - } - })); + return Promise.all( + Object.keys(AppCache.cache).map(appId => { + const app = AppCache.get(appId); + if (app.databaseController) { + return app.databaseController.deleteEverything(fast); + } else { + return Promise.resolve(); + } + }) + ); } diff --git a/src/batch.js b/src/batch.js index 9f16a93917..1a9205208b 100644 --- a/src/batch.js +++ b/src/batch.js @@ -6,14 +6,14 @@ const batchPath = '/batch'; // Mounts a batch-handler onto a PromiseRouter. function mountOnto(router) { - router.route('POST', batchPath, (req) => { + router.route('POST', batchPath, req => { return handleBatch(router, req); }); } function parseURL(URL) { if (typeof URL === 'string') { - return url.parse(URL) + return url.parse(URL); } return undefined; } @@ -30,13 +30,13 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { if (requestPath.slice(0, apiPrefix.length) != apiPrefix) { throw new Parse.Error( Parse.Error.INVALID_JSON, - 'cannot route batch path ' + requestPath); + 'cannot route batch path ' + requestPath + ); } return path.posix.join('/', requestPath.slice(apiPrefix.length)); - } + }; - if (serverURL && publicServerURL - && (serverURL.path != publicServerURL.path)) { + if (serverURL && publicServerURL && serverURL.path != publicServerURL.path) { const localPath = serverURL.path; const publicPath = publicServerURL.path; // Override the api prefix @@ -44,10 +44,15 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { return function(requestPath) { // Build the new path by removing the public path // and joining with the local path - const newPath = path.posix.join('/', localPath, '/' , requestPath.slice(publicPath.length)); + const newPath = path.posix.join( + '/', + localPath, + '/', + requestPath.slice(publicPath.length) + ); // Use the method for local routing return makeRoutablePath(newPath); - } + }; } return makeRoutablePath; @@ -57,8 +62,10 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { // TODO: pass along auth correctly function handleBatch(router, req) { if (!Array.isArray(req.body.requests)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'requests must be an array'); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'requests must be an array' + ); } // The batch paths are all from the root of our domain. @@ -70,31 +77,60 @@ function handleBatch(router, req) { throw 'internal routing problem - expected url to end with batch'; } - const makeRoutablePath = makeBatchRoutingPathFunction(req.originalUrl, req.config.serverURL, req.config.publicServerURL); + const makeRoutablePath = makeBatchRoutingPathFunction( + req.originalUrl, + req.config.serverURL, + req.config.publicServerURL + ); - const promises = req.body.requests.map((restRequest) => { - const routablePath = makeRoutablePath(restRequest.path); - // Construct a request that we can send to a handler - const request = { - body: restRequest.body, - config: req.config, - auth: req.auth, - info: req.info - }; + let initialPromise = Promise.resolve(); + if (req.body.transaction === true) { + initialPromise = req.config.database.createTransactionalSession(); + } - return router.tryRouteRequest(restRequest.method, routablePath, request).then((response) => { - return {success: response.response}; - }, (error) => { - return {error: {code: error.code, error: error.message}}; + return initialPromise.then(() => { + const promises = req.body.requests.map(restRequest => { + const routablePath = makeRoutablePath(restRequest.path); + // Construct a request that we can send to a handler + + const request = { + body: restRequest.body, + config: req.config, + auth: req.auth, + info: req.info, + }; + + return router + .tryRouteRequest(restRequest.method, routablePath, request) + .then( + response => { + return { success: response.response }; + }, + error => { + return { error: { code: error.code, error: error.message } }; + } + ); }); - }); - return Promise.all(promises).then((results) => { - return {response: results}; + return Promise.all(promises).then(results => { + if (req.body.transaction === true) { + if (results.find(result => typeof result.error === 'object')) { + return req.config.database.abortTransactionalSession().then(() => { + return Promise.reject({ response: results }); + }); + } else { + return req.config.database.commitTransactionalSession().then(() => { + return { response: results }; + }); + } + } else { + return { response: results }; + } + }); }); } module.exports = { mountOnto, - makeBatchRoutingPathFunction + makeBatchRoutingPathFunction, }; diff --git a/src/cache.js b/src/cache.js index 96b00b4534..71c01f4a14 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,4 +1,4 @@ -import {InMemoryCache} from './Adapters/Cache/InMemoryCache'; +import { InMemoryCache } from './Adapters/Cache/InMemoryCache'; -export var AppCache = new InMemoryCache({ttl: NaN}); +export var AppCache = new InMemoryCache({ ttl: NaN }); export default AppCache; diff --git a/src/cli/definitions/parse-live-query-server.js b/src/cli/definitions/parse-live-query-server.js index 0fd2fca6c9..3b4ef432dd 100644 --- a/src/cli/definitions/parse-live-query-server.js +++ b/src/cli/definitions/parse-live-query-server.js @@ -1,2 +1,3 @@ -const LiveQueryServerOptions = require('../../Options/Definitions').LiveQueryServerOptions; +const LiveQueryServerOptions = require('../../Options/Definitions') + .LiveQueryServerOptions; export default LiveQueryServerOptions; diff --git a/src/cli/definitions/parse-server.js b/src/cli/definitions/parse-server.js index d19dcc5d8a..33ddc62c17 100644 --- a/src/cli/definitions/parse-server.js +++ b/src/cli/definitions/parse-server.js @@ -1,2 +1,3 @@ -const ParseServerDefinitions = require('../../Options/Definitions').ParseServerOptions; +const ParseServerDefinitions = require('../../Options/Definitions') + .ParseServerOptions; export default ParseServerDefinitions; diff --git a/src/cli/parse-live-query-server.js b/src/cli/parse-live-query-server.js index c9b00a0c77..edcdeec77f 100644 --- a/src/cli/parse-live-query-server.js +++ b/src/cli/parse-live-query-server.js @@ -7,5 +7,5 @@ runner({ start: function(program, options, logOptions) { logOptions(); ParseServer.createLiveQueryServer(undefined, options); - } -}) + }, +}); diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index 9bb5f6ce15..69611bd905 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -5,7 +5,7 @@ import cluster from 'cluster'; import os from 'os'; import runner from './utils/runner'; -const help = function(){ +const help = function() { console.log(' Get Started guide:'); console.log(''); console.log(' Please have a look at the get started guide!'); @@ -15,15 +15,23 @@ const help = function(){ console.log(' Usage with npm start'); console.log(''); console.log(' $ npm start -- path/to/config.json'); - console.log(' $ npm start -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); - console.log(' $ npm start -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); + console.log( + ' $ npm start -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL' + ); + console.log( + ' $ npm start -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL' + ); console.log(''); console.log(''); console.log(' Usage:'); console.log(''); console.log(' $ parse-server path/to/config.json'); - console.log(' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); - console.log(' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); + console.log( + ' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL' + ); + console.log( + ' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL' + ); console.log(''); }; @@ -34,47 +42,83 @@ runner({ start: function(program, options, logOptions) { if (!options.appId || !options.masterKey) { program.outputHelp(); - console.error(""); - console.error('\u001b[31mERROR: appId and masterKey are required\u001b[0m'); - console.error(""); + console.error(''); + console.error( + '\u001b[31mERROR: appId and masterKey are required\u001b[0m' + ); + console.error(''); process.exit(1); } - if (options["liveQuery.classNames"]) { + if (options['liveQuery.classNames']) { options.liveQuery = options.liveQuery || {}; - options.liveQuery.classNames = options["liveQuery.classNames"]; - delete options["liveQuery.classNames"]; + options.liveQuery.classNames = options['liveQuery.classNames']; + delete options['liveQuery.classNames']; } - if (options["liveQuery.redisURL"]) { + if (options['liveQuery.redisURL']) { options.liveQuery = options.liveQuery || {}; - options.liveQuery.redisURL = options["liveQuery.redisURL"]; - delete options["liveQuery.redisURL"]; + options.liveQuery.redisURL = options['liveQuery.redisURL']; + delete options['liveQuery.redisURL']; + } + if (options['liveQuery.redisOptions']) { + options.liveQuery = options.liveQuery || {}; + options.liveQuery.redisOptions = options['liveQuery.redisOptions']; + delete options['liveQuery.redisOptions']; } if (options.cluster) { - const numCPUs = typeof options.cluster === 'number' ? options.cluster : os.cpus().length; + const numCPUs = + typeof options.cluster === 'number' + ? options.cluster + : os.cpus().length; if (cluster.isMaster) { logOptions(); - for(let i = 0; i < numCPUs; i++) { + for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code) => { - console.log(`worker ${worker.process.pid} died (${code})... Restarting`); + console.log( + `worker ${worker.process.pid} died (${code})... Restarting` + ); cluster.fork(); }); } else { ParseServer.start(options, () => { - console.log('[' + process.pid + '] parse-server running on ' + options.serverURL); + printSuccessMessage(); }); } } else { ParseServer.start(options, () => { logOptions(); console.log(''); - console.log('[' + process.pid + '] parse-server running on ' + options.serverURL); + printSuccessMessage(); }); } - } + + function printSuccessMessage() { + console.log( + '[' + process.pid + '] parse-server running on ' + options.serverURL + ); + if (options.mountGraphQL) { + console.log( + '[' + + process.pid + + '] GraphQL running on http://localhost:' + + options.port + + options.graphQLPath + ); + } + if (options.mountPlayground) { + console.log( + '[' + + process.pid + + '] Playground running on http://localhost:' + + options.port + + options.playgroundPath + ); + } + } + }, }); /* eslint-enable no-console */ diff --git a/src/cli/utils/commander.js b/src/cli/utils/commander.js index 8ac47804e8..a4e9683074 100644 --- a/src/cli/utils/commander.js +++ b/src/cli/utils/commander.js @@ -9,12 +9,20 @@ Command.prototype.loadDefinitions = function(definitions) { _definitions = definitions; Object.keys(definitions).reduce((program, opt) => { - if (typeof definitions[opt] == "object") { + if (typeof definitions[opt] == 'object') { const additionalOptions = definitions[opt]; if (additionalOptions.required === true) { - return program.option(`--${opt} <${opt}>`, additionalOptions.help, additionalOptions.action); + return program.option( + `--${opt} <${opt}>`, + additionalOptions.help, + additionalOptions.action + ); } else { - return program.option(`--${opt} [${opt}]`, additionalOptions.help, additionalOptions.action); + return program.option( + `--${opt} [${opt}]`, + additionalOptions.help, + additionalOptions.action + ); } } return program.option(`--${opt} [${opt}]`); @@ -22,7 +30,7 @@ Command.prototype.loadDefinitions = function(definitions) { _reverseDefinitions = Object.keys(definitions).reduce((object, key) => { let value = definitions[key]; - if (typeof value == "object") { + if (typeof value == 'object') { value = value.env; } if (value) { @@ -32,17 +40,17 @@ Command.prototype.loadDefinitions = function(definitions) { }, {}); _defaults = Object.keys(definitions).reduce((defs, opt) => { - if(_definitions[opt].default) { + if (_definitions[opt].default) { defs[opt] = _definitions[opt].default; } return defs; }, {}); /* istanbul ignore next */ - this.on('--help', function(){ + this.on('--help', function() { console.log(' Configure From Environment:'); console.log(''); - Object.keys(_reverseDefinitions).forEach((key) => { + Object.keys(_reverseDefinitions).forEach(key => { console.log(` $ ${key}='${_reverseDefinitions[key]}'`); }); console.log(''); @@ -53,8 +61,8 @@ function parseEnvironment(env = {}) { return Object.keys(_reverseDefinitions).reduce((options, key) => { if (env[key]) { const originalKey = _reverseDefinitions[key]; - let action = (option) => (option); - if (typeof _definitions[originalKey] === "object") { + let action = option => option; + if (typeof _definitions[originalKey] === 'object') { action = _definitions[originalKey].action || action; } options[_reverseDefinitions[key]] = action(env[key]); @@ -77,7 +85,7 @@ function parseConfigFile(program) { } else { options = jsonConfig; } - Object.keys(options).forEach((key) => { + Object.keys(options).forEach(key => { const value = options[key]; if (!_definitions[key]) { throw `error: unknown option ${key}`; @@ -87,14 +95,14 @@ function parseConfigFile(program) { options[key] = action(value); } }); - console.log(`Configuration loaded from ${jsonPath}`) + console.log(`Configuration loaded from ${jsonPath}`); } return options; } Command.prototype.setValuesIfNeeded = function(options) { - Object.keys(options).forEach((key) => { - if (!this.hasOwnProperty(key)) { + Object.keys(options).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(this, key)) { this[key] = options[key]; } }); diff --git a/src/cli/utils/runner.js b/src/cli/utils/runner.js index 1c0c0af302..cdc8e13a8b 100644 --- a/src/cli/utils/runner.js +++ b/src/cli/utils/runner.js @@ -1,16 +1,18 @@ - import program from './commander'; function logStartupOptions(options) { for (const key in options) { let value = options[key]; - if (key == "masterKey") { - value = "***REDACTED***"; + if (key == 'masterKey') { + value = '***REDACTED***'; + } + if (key == 'push' && options.verbose != true) { + value = '***REDACTED***'; } if (typeof value === 'object') { try { - value = JSON.stringify(value) - } catch(e) { + value = JSON.stringify(value); + } catch (e) { if (value && value.constructor && value.constructor.name) { value = value.constructor.name; } @@ -22,12 +24,7 @@ function logStartupOptions(options) { } } -export default function({ - definitions, - help, - usage, - start -}) { +export default function({ definitions, help, usage, start }) { program.loadDefinitions(definitions); if (usage) { program.usage(usage); diff --git a/src/cloud-code/HTTPResponse.js b/src/cloud-code/HTTPResponse.js index 85613c8747..1c1de93ae2 100644 --- a/src/cloud-code/HTTPResponse.js +++ b/src/cloud-code/HTTPResponse.js @@ -1,10 +1,18 @@ - +/** + * @typedef Parse.Cloud.HTTPResponse + * @property {Buffer} buffer The raw byte representation of the response body. Use this to receive binary data. See Buffer for more details. + * @property {Object} cookies The cookies sent by the server. The keys in this object are the names of the cookies. The values are Parse.Cloud.Cookie objects. + * @property {Object} data The parsed response body as a JavaScript object. This is only available when the response Content-Type is application/x-www-form-urlencoded or application/json. + * @property {Object} headers The headers sent by the server. The keys in this object are the names of the headers. We do not support multiple response headers with the same name. In the common case of Set-Cookie headers, please use the cookies field instead. + * @property {Number} status The status code. + * @property {String} text The raw text representation of the response body. + */ export default class HTTPResponse { constructor(response, body) { let _text, _data; this.status = response.statusCode; this.headers = response.headers || {}; - this.cookies = this.headers["set-cookie"]; + this.cookies = this.headers['set-cookie']; if (typeof body == 'string') { _text = body; @@ -21,29 +29,33 @@ export default class HTTPResponse { _text = JSON.stringify(_data); } return _text; - } + }; const getData = () => { if (!_data) { try { _data = JSON.parse(getText()); - } catch (e) { /* */ } + } catch (e) { + /* */ + } } return _data; - } + }; Object.defineProperty(this, 'body', { - get: () => { return body } + get: () => { + return body; + }, }); Object.defineProperty(this, 'text', { enumerable: true, - get: getText + get: getText, }); Object.defineProperty(this, 'data', { enumerable: true, - get: getData + get: getData, }); } } diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 7344f1d593..8b5e0d1cfb 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -1,6 +1,13 @@ -import { Parse } from 'parse/node'; +import { Parse } from 'parse/node'; import * as triggers from '../triggers'; +function isParseObjectConstructor(object) { + return ( + typeof object === 'function' && + Object.prototype.hasOwnProperty.call(object, 'className') + ); +} + function getClassName(parseClass) { if (parseClass && parseClass.className) { return parseClass.className; @@ -8,43 +15,350 @@ function getClassName(parseClass) { return parseClass; } +/** @namespace + * @name Parse + * @description The Parse SDK. + * see [api docs](https://docs.parseplatform.org/js/api) and [guide](https://docs.parseplatform.org/js/guide) + */ + +/** @namespace + * @name Parse.Cloud + * @memberof Parse + * @description The Parse Cloud Code SDK. + */ + var ParseCloud = {}; +/** + * Defines a Cloud Function. + * + * **Available in Cloud Code only.** + + * @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}. + */ ParseCloud.define = function(functionName, handler, validationHandler) { - triggers.addFunction(functionName, handler, validationHandler, Parse.applicationId); + triggers.addFunction( + functionName, + handler, + validationHandler, + Parse.applicationId + ); }; +/** + * Defines a Background Job. + * + * **Available in Cloud Code only.** + * + * @method job + * @name Parse.Cloud.job + * @param {String} name The name of the Background Job + * @param {Function} func The Background Job to register. This function can be async should take a single parameters a {@link Parse.Cloud.JobRequest} + * + */ ParseCloud.job = function(functionName, handler) { triggers.addJob(functionName, handler, Parse.applicationId); }; +/** + * + * Registers a before save function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeSave for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * + * ``` + * Parse.Cloud.beforeSave('MyCustomClass', (request) => { + * // code here + * }) + * + * Parse.Cloud.beforeSave(Parse.User, (request) => { + * // code here + * }) + * ``` + * + * @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}; + */ ParseCloud.beforeSave = function(parseClass, handler) { var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.beforeSave, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.beforeSave, + className, + handler, + Parse.applicationId + ); }; +/** + * Registers a before delete function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeDelete for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeDelete('MyCustomClass', (request) => { + * // code here + * }) + * + * Parse.Cloud.beforeDelete(Parse.User, (request) => { + * // code here + * }) + *``` + * + * @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}. + */ ParseCloud.beforeDelete = function(parseClass, handler) { var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.beforeDelete, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.beforeDelete, + className, + handler, + Parse.applicationId + ); +}; + +/** + * + * Registers the before login function. + * + * **Available in Cloud Code only.** + * + * This function provides further control + * in validating a login attempt. Specifically, + * it is triggered after a user enters + * correct credentials (or other valid authData), + * but prior to a session being generated. + * + * ``` + * Parse.Cloud.beforeLogin((request) => { + * // code here + * }) + * + * ``` + * + * @method beforeLogin + * @name Parse.Cloud.beforeLogin + * @param {Function} func The function to run before a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.beforeLogin = function(handler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger( + triggers.Types.beforeLogin, + className, + handler, + Parse.applicationId + ); +}; + +/** + * + * Registers the after login function. + * + * **Available in Cloud Code only.** + * + * This function is triggered after a user logs in successfully, + * and after a _Session object has been created. + * + * ``` + * Parse.Cloud.afterLogin((request) => { + * // code here + * }) + * + * ``` + * + * @method afterLogin + * @name Parse.Cloud.afterLogin + * @param {Function} func The function to run after a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.afterLogin = function(handler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger( + triggers.Types.afterLogin, + className, + handler, + Parse.applicationId + ); }; +/** + * + * Registers the after logout function. + * + * **Available in Cloud Code only.** + * + * This function is triggered after a user logs out. + * + * ``` + * Parse.Cloud.afterLogout((request) => { + * // code here + * }) + * + * ``` + * + * @method afterLogout + * @name Parse.Cloud.afterLogout + * @param {Function} func The function to run after a logout. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.afterLogout = function(handler) { + let className = '_Session'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger( + triggers.Types.afterLogout, + className, + handler, + Parse.applicationId + ); +}; + +/** + * Registers an after save function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterSave for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * + * ``` + * Parse.Cloud.afterSave('MyCustomClass', async function(request) { + * // code here + * }) + * + * Parse.Cloud.afterSave(Parse.User, async function(request) { + * // code here + * }) + * ``` + * + * @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}. + */ ParseCloud.afterSave = function(parseClass, handler) { var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.afterSave, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.afterSave, + className, + handler, + Parse.applicationId + ); }; +/** + * Registers an after delete function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterDelete for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.afterDelete('MyCustomClass', async (request) => { + * // code here + * }) + * + * Parse.Cloud.afterDelete(Parse.User, async (request) => { + * // code here + * }) + *``` + * + * @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}. + */ ParseCloud.afterDelete = function(parseClass, handler) { var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.afterDelete, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.afterDelete, + className, + handler, + Parse.applicationId + ); }; +/** + * Registers a before find function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeFind for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeFind('MyCustomClass', async (request) => { + * // code here + * }) + * + * Parse.Cloud.beforeFind(Parse.User, async (request) => { + * // code here + * }) + *``` + * + * @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}. + */ ParseCloud.beforeFind = function(parseClass, handler) { var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.beforeFind, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.beforeFind, + className, + handler, + Parse.applicationId + ); }; +/** + * Registers an after find function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterFind for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.afterFind('MyCustomClass', async (request) => { + * // code here + * }) + * + * Parse.Cloud.afterFind(Parse.User, async (request) => { + * // code here + * }) + *``` + * + * @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}. + */ ParseCloud.afterFind = function(parseClass, handler) { const className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.afterFind, className, handler, Parse.applicationId); + triggers.addTrigger( + triggers.Types.afterFind, + className, + handler, + Parse.applicationId + ); }; ParseCloud.onLiveQueryEvent = function(handler) { @@ -53,13 +367,68 @@ ParseCloud.onLiveQueryEvent = function(handler) { ParseCloud._removeAllHooks = () => { triggers._unregisterAll(); -} +}; ParseCloud.useMasterKey = () => { // eslint-disable-next-line - console.warn("Parse.Cloud.useMasterKey is deprecated (and has no effect anymore) on parse-server, please refer to the cloud code migration notes: http://docs.parseplatform.org/parse-server/guide/#master-key-must-be-passed-explicitly") -} + console.warn( + 'Parse.Cloud.useMasterKey is deprecated (and has no effect anymore) on parse-server, please refer to the cloud code migration notes: http://docs.parseplatform.org/parse-server/guide/#master-key-must-be-passed-explicitly' + ); +}; -ParseCloud.httpRequest = require("./httpRequest"); +ParseCloud.httpRequest = require('./httpRequest'); module.exports = ParseCloud; + +/** + * @interface Parse.Cloud.TriggerRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Object} object The object triggering the hook. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + * @property {Parse.Object} original If set, the object, as currently stored. + */ + +/** + * @interface Parse.Cloud.BeforeFindRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Query} query The query triggering the hook. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + * @property {Boolean} isGet wether the query a `get` or a `find` + */ + +/** + * @interface Parse.Cloud.AfterFindRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Query} query The query triggering the hook. + * @property {Array} results The results the query yielded. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + */ + +/** + * @interface Parse.Cloud.FunctionRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {Object} params The params passed to the cloud function. + */ + +/** + * @interface Parse.Cloud.JobRequest + * @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. + */ diff --git a/src/cloud-code/httpRequest.js b/src/cloud-code/httpRequest.js index 32102341ef..0dbf083478 100644 --- a/src/cloud-code/httpRequest.js +++ b/src/cloud-code/httpRequest.js @@ -1,14 +1,40 @@ -import request from 'request'; -import Parse from 'parse/node'; import HTTPResponse from './HTTPResponse'; import querystring from 'querystring'; import log from '../logger'; +import { http, https } from 'follow-redirects'; +import { parse } from 'url'; -var encodeBody = function({body, headers = {}}) { +const clients = { + 'http:': http, + 'https:': https, +}; + +function makeCallback(resolve, reject) { + return function(response) { + const chunks = []; + response.on('data', chunk => { + chunks.push(chunk); + }); + response.on('end', () => { + const body = Buffer.concat(chunks); + const httpResponse = new HTTPResponse(response, body); + + // Consider <200 && >= 400 as errors + if (httpResponse.status < 200 || httpResponse.status >= 400) { + return reject(httpResponse); + } else { + return resolve(httpResponse); + } + }); + response.on('error', reject); + }; +} + +const encodeBody = function({ body, headers = {} }) { if (typeof body !== 'object') { - return {body, headers}; + return { body, headers }; } - var contentTypeKeys = Object.keys(headers).filter((key) => { + var contentTypeKeys = Object.keys(headers).filter(key => { return key.match(/content-type/i) != null; }); @@ -21,63 +47,120 @@ var encodeBody = function({body, headers = {}}) { } else { /* istanbul ignore next */ if (contentTypeKeys.length > 1) { - log.error('Parse.Cloud.httpRequest', 'multiple content-type headers are set.'); + log.error( + 'Parse.Cloud.httpRequest', + 'multiple content-type headers are set.' + ); } // There maybe many, we'll just take the 1st one var contentType = contentTypeKeys[0]; if (headers[contentType].match(/application\/json/i)) { body = JSON.stringify(body); - } else if(headers[contentType].match(/application\/x-www-form-urlencoded/i)) { + } else if ( + headers[contentType].match(/application\/x-www-form-urlencoded/i) + ) { body = querystring.stringify(body); } } - return {body, headers}; -} + return { body, headers }; +}; -module.exports = function(options) { - var promise = new Parse.Promise(); - var callbacks = { - success: options.success, - error: options.error - }; - delete options.success; - delete options.error; - delete options.uri; // not supported - options = Object.assign(options, encodeBody(options)); - // set follow redirects to false by default - options.followRedirect = options.followRedirects == true; +/** + * Makes an HTTP Request. + * + * **Available in Cloud Code only.** + * + * By default, Parse.Cloud.httpRequest does not follow redirects caused by HTTP 3xx response codes. You can use the followRedirects option in the {@link Parse.Cloud.HTTPOptions} object to change this behavior. + * + * Sample request: + * ``` + * Parse.Cloud.httpRequest({ + * url: 'http://www.parse.com/' + * }).then(function(httpResponse) { + * // success + * console.log(httpResponse.text); + * },function(httpResponse) { + * // error + * console.error('Request failed with response code ' + httpResponse.status); + * }); + * ``` + * + * @method httpRequest + * @name Parse.Cloud.httpRequest + * @param {Parse.Cloud.HTTPOptions} options The Parse.Cloud.HTTPOptions object that makes the request. + * @return {Promise} A promise that will be resolved with a {@link Parse.Cloud.HTTPResponse} object when the request completes. + */ +module.exports = function httpRequest(options) { + let url; + try { + url = parse(options.url); + } catch (e) { + return Promise.reject(e); + } + options = Object.assign(options, encodeBody(options)); // support params options if (typeof options.params === 'object') { options.qs = options.params; } else if (typeof options.params === 'string') { options.qs = querystring.parse(options.params); } - // force the response as a buffer - options.encoding = null; - - request(options, (error, response, body) => { - if (error) { - if (callbacks.error) { - callbacks.error(error); - } - return promise.reject(error); - } - const httpResponse = new HTTPResponse(response, body); - - // Consider <200 && >= 400 as errors - if (httpResponse.status < 200 || httpResponse.status >= 400) { - if (callbacks.error) { - callbacks.error(httpResponse); - } - return promise.reject(httpResponse); - } else { - if (callbacks.success) { - callbacks.success(httpResponse); + const client = clients[url.protocol]; + if (!client) { + return Promise.reject(`Unsupported protocol ${url.protocol}`); + } + const requestOptions = { + method: options.method, + port: Number(url.port), + path: url.pathname, + hostname: url.hostname, + headers: options.headers, + encoding: null, + followRedirects: options.followRedirects === true, + }; + if (requestOptions.headers) { + Object.keys(requestOptions.headers).forEach(key => { + if (typeof requestOptions.headers[key] === 'undefined') { + delete requestOptions.headers[key]; } - return promise.resolve(httpResponse); + }); + } + if (url.search) { + options.qs = Object.assign({}, options.qs, querystring.parse(url.query)); + } + if (url.auth) { + requestOptions.auth = url.auth; + } + if (options.qs) { + requestOptions.path += `?${querystring.stringify(options.qs)}`; + } + if (options.agent) { + requestOptions.agent = options.agent; + } + return new Promise((resolve, reject) => { + const req = client.request( + requestOptions, + makeCallback(resolve, reject, options) + ); + if (options.body) { + req.write(options.body); } + req.on('error', error => { + reject(error); + }); + req.end(); }); - return promise; }; +/** + * @typedef Parse.Cloud.HTTPOptions + * @property {String|Object} body The body of the request. If it is a JSON object, then the Content-Type set in the headers must be application/x-www-form-urlencoded or application/json. You can also set this to a {@link Buffer} object to send raw bytes. If you use a Buffer, you should also set the Content-Type header explicitly to describe what these bytes represent. + * @property {function} error The function that is called when the request fails. It will be passed a Parse.Cloud.HTTPResponse object. + * @property {Boolean} followRedirects Whether to follow redirects caused by HTTP 3xx responses. Defaults to false. + * @property {Object} headers The headers for the request. + * @property {String} method The method of the request. GET, POST, PUT, DELETE, HEAD, and OPTIONS are supported. Will default to GET if not specified. + * @property {String|Object} params The query portion of the url. You can pass a JSON object of key value pairs like params: {q : 'Sean Plott'} or a raw string like params:q=Sean Plott. + * @property {function} success The function that is called when the request successfully completes. It will be passed a Parse.Cloud.HTTPResponse object. + * @property {string} url The url to send the request to. + */ + module.exports.encodeBody = encodeBody; diff --git a/src/cryptoUtils.js b/src/cryptoUtils.js index c90e74cae4..580b843ad6 100644 --- a/src/cryptoUtils.js +++ b/src/cryptoUtils.js @@ -8,7 +8,7 @@ export function randomHexString(size: number): string { throw new Error('Zero-length randomHexString is useless.'); } if (size % 2 !== 0) { - throw new Error('randomHexString size must be divisible by 2.') + throw new Error('randomHexString size must be divisible by 2.'); } return randomBytes(size / 2).toString('hex'); } @@ -23,9 +23,8 @@ export function randomString(size: number): string { if (size === 0) { throw new Error('Zero-length randomString is useless.'); } - const chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - 'abcdefghijklmnopqrstuvwxyz' + - '0123456789'); + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'; let objectId = ''; const bytes = randomBytes(size); for (let i = 0; i < bytes.length; ++i) { @@ -45,5 +44,7 @@ export function newToken(): string { } export function md5Hash(string: string): string { - return createHash('md5').update(string).digest('hex'); + return createHash('md5') + .update(string) + .digest('hex'); } diff --git a/src/defaults.js b/src/defaults.js index be205f4e5b..ba603424a8 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -3,7 +3,7 @@ const { ParseServerOptions } = require('./Options/Definitions'); const logsFolder = (() => { let folder = './logs/'; if (typeof process !== 'undefined' && process.env.TESTING === '1') { - folder = './test_logs/' + folder = './test_logs/'; } if (process.env.PARSE_SERVER_LOGS_FOLDER) { folder = nullParser(process.env.PARSE_SERVER_LOGS_FOLDER); @@ -13,24 +13,26 @@ const logsFolder = (() => { const { verbose, level } = (() => { const verbose = process.env.VERBOSE ? true : false; - return { verbose, level: verbose ? 'verbose' : undefined } + return { verbose, level: verbose ? 'verbose' : undefined }; })(); - -const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) => { - const def = ParseServerOptions[key]; - if (def.hasOwnProperty('default')) { - memo[key] = def.default; - } - return memo; -}, {}); +const DefinitionDefaults = Object.keys(ParseServerOptions).reduce( + (memo, key) => { + const def = ParseServerOptions[key]; + if (Object.prototype.hasOwnProperty.call(def, 'default')) { + memo[key] = def.default; + } + return memo; + }, + {} +); const computedDefaults = { jsonLogs: process.env.JSON_LOGS || false, logsFolder, verbose, level, -} +}; export default Object.assign({}, DefinitionDefaults, computedDefaults); export const DefaultMongoURI = DefinitionDefaults.databaseURI; diff --git a/src/deprecated.js b/src/deprecated.js index 7192336c2c..e92c621207 100644 --- a/src/deprecated.js +++ b/src/deprecated.js @@ -1,5 +1,5 @@ export function useExternal(name, moduleName) { return function() { throw `${name} is not provided by parse-server anymore; please install ${moduleName}`; - } + }; } diff --git a/src/index.js b/src/index.js index 26133472d8..4bfc293473 100644 --- a/src/index.js +++ b/src/index.js @@ -1,29 +1,30 @@ -import ParseServer from './ParseServer'; -import S3Adapter from 'parse-server-s3-adapter' -import FileSystemAdapter from 'parse-server-fs-adapter' -import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter' -import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter' -import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter' -import LRUCacheAdapter from './Adapters/Cache/LRUCache.js' -import * as TestUtils from './TestUtils'; -import { useExternal } from './deprecated'; -import { getLogger } from './logger'; -import { PushWorker } from './Push/PushWorker'; -import { ParseServerOptions } from './Options'; +import ParseServer from './ParseServer'; +import S3Adapter from '@parse/s3-files-adapter'; +import FileSystemAdapter from '@parse/fs-files-adapter'; +import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter'; +import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter'; +import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter'; +import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; +import * as TestUtils from './TestUtils'; +import { useExternal } from './deprecated'; +import { getLogger } from './logger'; +import { PushWorker } from './Push/PushWorker'; +import { ParseServerOptions } from './Options'; +import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; // Factory function const _ParseServer = function(options: ParseServerOptions) { const server = new ParseServer(options); return server.app; -} +}; // Mount the create liveQueryServer _ParseServer.createLiveQueryServer = ParseServer.createLiveQueryServer; _ParseServer.start = ParseServer.start; -const GCSAdapter = useExternal('GCSAdapter', 'parse-server-gcs-adapter'); +const GCSAdapter = useExternal('GCSAdapter', '@parse/gcs-files-adapter'); Object.defineProperty(module.exports, 'logger', { - get: getLogger + get: getLogger, }); export default ParseServer; @@ -37,5 +38,6 @@ export { LRUCacheAdapter, TestUtils, PushWorker, - _ParseServer as ParseServer + ParseGraphQLServer, + _ParseServer as ParseServer, }; diff --git a/src/logger.js b/src/logger.js index ee69c42cf0..97604039c5 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,14 +1,16 @@ 'use strict'; import defaults from './defaults'; import { WinstonLoggerAdapter } from './Adapters/Logger/WinstonLoggerAdapter'; -import { LoggerController } from './Controllers/LoggerController'; +import { LoggerController } from './Controllers/LoggerController'; +// Used for Separate Live Query Server function defaultLogger() { const options = { logsFolder: defaults.logsFolder, jsonLogs: defaults.jsonLogs, verbose: defaults.verbose, - silent: defaults.silent }; + silent: defaults.silent, + }; const adapter = new WinstonLoggerAdapter(options); return new LoggerController(adapter, null, options); } @@ -25,10 +27,10 @@ export function getLogger() { // for: `import logger from './logger'` Object.defineProperty(module.exports, 'default', { - get: getLogger + get: getLogger, }); // for: `import { logger } from './logger'` Object.defineProperty(module.exports, 'logger', { - get: getLogger + get: getLogger, }); diff --git a/src/middlewares.js b/src/middlewares.js index 0606ab8f38..f60cb53df6 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,9 +1,18 @@ import AppCache from './cache'; -import log from './logger'; import Parse from 'parse/node'; import auth from './Auth'; import Config from './Config'; import ClientSDK from './ClientSDK'; +import defaultLogger from './logger'; + +export const DEFAULT_ALLOWED_HEADERS = + 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type, Pragma, Cache-Control'; + +const getMountForRequest = function(req) { + const mountPathLength = req.originalUrl.length - req.url.length; + const mountPath = req.originalUrl.slice(0, mountPathLength); + return req.protocol + '://' + req.get('host') + mountPath; +}; // Checks that the request is authorized for this app and checks user // auth too. @@ -12,9 +21,7 @@ import ClientSDK from './ClientSDK'; // req.config - the Config for this app // req.auth - the Auth for this request export function handleParseHeaders(req, res, next) { - var mountPathLength = req.originalUrl.length - req.url.length; - var mountPath = req.originalUrl.slice(0, mountPathLength); - var mount = req.protocol + '://' + req.get('host') + mountPath; + var mount = getMountForRequest(req); var info = { appId: req.get('X-Parse-Application-Id'), @@ -25,7 +32,7 @@ export function handleParseHeaders(req, res, next) { javascriptKey: req.get('X-Parse-Javascript-Key'), dotNetKey: req.get('X-Parse-Windows-Key'), restAPIKey: req.get('X-Parse-REST-API-Key'), - clientVersion: req.get('X-Parse-Client-Version') + clientVersion: req.get('X-Parse-Client-Version'), }; var basicAuth = httpAuth(req); @@ -60,10 +67,12 @@ export function handleParseHeaders(req, res, next) { delete req.body._RevocableSession; } - if (req.body && + if ( + req.body && req.body._ApplicationId && AppCache.get(req.body._ApplicationId) && - (!info.masterKey || AppCache.get(req.body._ApplicationId).masterKey === info.masterKey) + (!info.masterKey || + AppCache.get(req.body._ApplicationId).masterKey === info.masterKey) ) { info.appId = req.body._ApplicationId; info.javascriptKey = req.body._JavaScriptKey || ''; @@ -96,6 +105,10 @@ export function handleParseHeaders(req, res, next) { } } + if (info.sessionToken && typeof info.sessionToken !== 'string') { + info.sessionToken = info.sessionToken.toString(); + } + if (info.clientVersion) { info.clientSDK = ClientSDK.fromString(info.clientVersion); } @@ -103,7 +116,7 @@ export function handleParseHeaders(req, res, next) { if (fileViaJSON) { // We need to repopulate req.body with a buffer var base64 = req.body.base64; - req.body = new Buffer(base64, 'base64'); + req.body = Buffer.from(base64, 'base64'); } const clientIp = getClientIp(req); @@ -114,32 +127,50 @@ export function handleParseHeaders(req, res, next) { req.config.ip = clientIp; req.info = info; - if (info.masterKey && req.config.masterKeyIps && req.config.masterKeyIps.length !== 0 && req.config.masterKeyIps.indexOf(clientIp) === -1) { + if ( + info.masterKey && + req.config.masterKeyIps && + req.config.masterKeyIps.length !== 0 && + req.config.masterKeyIps.indexOf(clientIp) === -1 + ) { return invalidRequest(req, res); } - var isMaster = (info.masterKey === req.config.masterKey); + var isMaster = info.masterKey === req.config.masterKey; if (isMaster) { - req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true }); + req.auth = new auth.Auth({ + config: req.config, + installationId: info.installationId, + isMaster: true, + }); next(); return; } - var isReadOnlyMaster = (info.masterKey === req.config.readOnlyMasterKey); - if (typeof req.config.readOnlyMasterKey != 'undefined' && req.config.readOnlyMasterKey && isReadOnlyMaster) { - req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true, isReadOnly: true }); + var isReadOnlyMaster = info.masterKey === req.config.readOnlyMasterKey; + if ( + typeof req.config.readOnlyMasterKey != 'undefined' && + req.config.readOnlyMasterKey && + isReadOnlyMaster + ) { + req.auth = new auth.Auth({ + config: req.config, + installationId: info.installationId, + isMaster: true, + isReadOnly: true, + }); next(); return; } // Client keys are not required in parse-server, but if any have been configured in the server, validate them // to preserve original behavior. - const keys = ["clientKey", "javascriptKey", "dotNetKey", "restAPIKey"]; + const keys = ['clientKey', 'javascriptKey', 'dotNetKey', 'restAPIKey']; const oneKeyConfigured = keys.some(function(key) { return req.config[key] !== undefined; }); - const oneKeyMatches = keys.some(function(key){ + const oneKeyMatches = keys.some(function(key) { return req.config[key] !== undefined && info[key] === req.config[key]; }); @@ -147,45 +178,63 @@ export function handleParseHeaders(req, res, next) { return invalidRequest(req, res); } - if (req.url == "/login") { + if (req.url == '/login') { delete info.sessionToken; } if (!info.sessionToken) { - req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: false }); + req.auth = new auth.Auth({ + config: req.config, + installationId: info.installationId, + isMaster: false, + }); next(); return; } - return Promise.resolve().then(() => { - // handle the upgradeToRevocableSession path on it's own - if (info.sessionToken && + return Promise.resolve() + .then(() => { + // handle the upgradeToRevocableSession path on it's own + if ( + info.sessionToken && req.url === '/upgradeToRevocableSession' && - info.sessionToken.indexOf('r:') != 0) { - return auth.getAuthForLegacySessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken }) - } else { - return auth.getAuthForSessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken }) - } - }).then((auth) => { - if (auth) { - req.auth = auth; - next(); - } - }) - .catch((error) => { - if(error instanceof Parse.Error) { + info.sessionToken.indexOf('r:') != 0 + ) { + return auth.getAuthForLegacySessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } else { + return auth.getAuthForSessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } + }) + .then(auth => { + if (auth) { + req.auth = auth; + next(); + } + }) + .catch(error => { + if (error instanceof Parse.Error) { next(error); return; - } - else { + } else { // TODO: Determine the correct error scenario. - log.error('error getting auth for sessionToken', error); + req.config.loggerController.error( + 'error getting auth for sessionToken', + error + ); throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); } }); } -function getClientIp(req){ +function getClientIp(req) { if (req.headers['x-forwarded-for']) { // try to get from x-forwared-for if it set (behind reverse proxy) return req.headers['x-forwarded-for'].split(',')[0]; @@ -205,8 +254,7 @@ function getClientIp(req){ } function httpAuth(req) { - if (!(req.req || req).headers.authorization) - return ; + if (!(req.req || req).headers.authorization) return; var header = (req.req || req).headers.authorization; var appId, masterKey, javascriptKey; @@ -226,35 +274,43 @@ function httpAuth(req) { var jsKeyPrefix = 'javascript-key='; - var matchKey = key.indexOf(jsKeyPrefix) + var matchKey = key.indexOf(jsKeyPrefix); if (matchKey == 0) { javascriptKey = key.substring(jsKeyPrefix.length, key.length); - } - else { + } else { masterKey = key; } } } - return {appId: appId, masterKey: masterKey, javascriptKey: javascriptKey}; + return { appId: appId, masterKey: masterKey, javascriptKey: javascriptKey }; } function decodeBase64(str) { - return new Buffer(str, 'base64').toString() + return Buffer.from(str, 'base64').toString(); } -export function allowCrossDomain(req, res, next) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); - res.header('Access-Control-Allow-Headers', 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type'); - - // intercept OPTIONS method - if ('OPTIONS' == req.method) { - res.sendStatus(200); - } - else { - next(); - } +export function allowCrossDomain(appId) { + return (req, res, next) => { + const config = Config.get(appId, getMountForRequest(req)); + let allowHeaders = DEFAULT_ALLOWED_HEADERS; + if (config && config.allowHeaders) { + allowHeaders += `, ${config.allowHeaders.join(', ')}`; + } + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', allowHeaders); + res.header( + 'Access-Control-Expose-Headers', + 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' + ); + // intercept OPTIONS method + if ('OPTIONS' == req.method) { + res.sendStatus(200); + } else { + next(); + } + }; } export function allowMethodOverride(req, res, next) { @@ -267,37 +323,44 @@ export function allowMethodOverride(req, res, next) { } export function handleParseErrors(err, req, res, next) { + const log = (req.config && req.config.loggerController) || defaultLogger; if (err instanceof Parse.Error) { let httpStatus; // TODO: fill out this mapping switch (err.code) { - case Parse.Error.INTERNAL_SERVER_ERROR: - httpStatus = 500; - break; - case Parse.Error.OBJECT_NOT_FOUND: - httpStatus = 404; - break; - default: - httpStatus = 400; + case Parse.Error.INTERNAL_SERVER_ERROR: + httpStatus = 500; + break; + case Parse.Error.OBJECT_NOT_FOUND: + httpStatus = 404; + break; + default: + httpStatus = 400; } res.status(httpStatus); res.json({ code: err.code, error: err.message }); - log.error(err.message, err); + log.error('Parse error: ', err); + if (req.config && req.config.enableExpressErrorHandler) { + next(err); + } } else if (err.status && err.message) { res.status(err.status); res.json({ error: err.message }); - next(err); + if (!(process && process.env.TESTING)) { + next(err); + } } else { log.error('Uncaught internal server error.', err, err.stack); res.status(500); res.json({ code: Parse.Error.INTERNAL_SERVER_ERROR, - message: 'Internal server error.' + message: 'Internal server error.', }); - next(err); + if (!(process && process.env.TESTING)) { + next(err); + } } - } export function enforceMasterKeyAccess(req, res, next) { @@ -313,7 +376,7 @@ export function promiseEnforceMasterKeyAccess(request) { if (!request.auth.isMaster) { const error = new Error(); error.status = 403; - error.message = "unauthorized: master key is required"; + error.message = 'unauthorized: master key is required'; throw error; } return Promise.resolve(); diff --git a/src/password.js b/src/password.js index f73a8a64c9..1e5a555eeb 100644 --- a/src/password.js +++ b/src/password.js @@ -4,7 +4,9 @@ var bcrypt = require('bcryptjs'); try { bcrypt = require('bcrypt'); -} catch(e) { /* */ } +} catch (e) { + /* */ +} // Returns a promise for a hashed password string. function hash(password) { @@ -23,5 +25,5 @@ function compare(password, hashedPassword) { module.exports = { hash: hash, - compare: compare + compare: compare, }; diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000000..2f85c3c9cf --- /dev/null +++ b/src/request.js @@ -0,0 +1 @@ +module.exports = require('./cloud-code/httpRequest'); diff --git a/src/requiredParameter.js b/src/requiredParameter.js index f6d5dd4278..eba860dd82 100644 --- a/src/requiredParameter.js +++ b/src/requiredParameter.js @@ -1,2 +1,4 @@ /** @flow */ -export default (errorMessage: string): any => { throw errorMessage } +export default (errorMessage: string): any => { + throw errorMessage; +}; diff --git a/src/rest.js b/src/rest.js index b428f43cb8..201cd89bbc 100644 --- a/src/rest.js +++ b/src/rest.js @@ -8,115 +8,205 @@ // things. var Parse = require('parse/node').Parse; -import Auth from './Auth'; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); var triggers = require('./triggers'); function checkTriggers(className, config, types) { - return types.some((triggerType) => { - return triggers.getTrigger(className, triggers.Types[triggerType], config.applicationId); + return types.some(triggerType => { + return triggers.getTrigger( + className, + triggers.Types[triggerType], + config.applicationId + ); }); } function checkLiveQuery(className, config) { - return config.liveQueryController && config.liveQueryController.hasLiveQuery(className) + return ( + config.liveQueryController && + config.liveQueryController.hasLiveQuery(className) + ); } // Returns a promise for an object with optional keys 'results' and 'count'. function find(config, auth, className, restWhere, restOptions, clientSDK) { enforceRoleSecurity('find', className, auth); - return triggers.maybeRunQueryTrigger(triggers.Types.beforeFind, className, restWhere, restOptions, config, auth).then((result) => { - restWhere = result.restWhere || restWhere; - restOptions = result.restOptions || restOptions; - const query = new RestQuery(config, auth, className, restWhere, restOptions, clientSDK); - return query.execute(); - }); + return triggers + .maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth + ) + .then(result => { + restWhere = result.restWhere || restWhere; + restOptions = result.restOptions || restOptions; + const query = new RestQuery( + config, + auth, + className, + restWhere, + restOptions, + clientSDK + ); + return query.execute(); + }); } // get is just like find but only queries an objectId. const get = (config, auth, className, objectId, restOptions, clientSDK) => { var restWhere = { objectId }; enforceRoleSecurity('get', className, auth); - return triggers.maybeRunQueryTrigger(triggers.Types.beforeFind, className, restWhere, restOptions, config, auth, true).then((result) => { - restWhere = result.restWhere || restWhere; - restOptions = result.restOptions || restOptions; - const query = new RestQuery(config, auth, className, restWhere, restOptions, clientSDK); - return query.execute(); - }); -} + return triggers + .maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth, + true + ) + .then(result => { + restWhere = result.restWhere || restWhere; + restOptions = result.restOptions || restOptions; + const query = new RestQuery( + config, + auth, + className, + restWhere, + restOptions, + clientSDK + ); + return query.execute(); + }); +}; // Returns a promise that doesn't resolve to any useful value. function del(config, auth, className, objectId) { if (typeof objectId !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad objectId'); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad objectId'); } - if (className === '_User' && !auth.couldUpdateUserId(objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'insufficient auth to delete user'); + if (className === '_User' && auth.isUnauthenticated()) { + throw new Parse.Error( + Parse.Error.SESSION_MISSING, + 'Insufficient auth to delete user' + ); } enforceRoleSecurity('delete', className, auth); - var inflatedObject; - - return Promise.resolve().then(() => { - const hasTriggers = checkTriggers(className, config, ['beforeDelete', 'afterDelete']); - const hasLiveQuery = checkLiveQuery(className, config); - if (hasTriggers || hasLiveQuery || className == '_Session') { - return find(config, Auth.master(config), className, {objectId: objectId}) - .then((response) => { - if (response && response.results && response.results.length) { - const firstResult = response.results[0]; - firstResult.className = className; - if (className === '_Session' && !auth.isMaster) { - if (!auth.user || firstResult.user.objectId !== auth.user.id) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); + let inflatedObject; + let schemaController; + + return Promise.resolve() + .then(() => { + const hasTriggers = checkTriggers(className, config, [ + 'beforeDelete', + 'afterDelete', + ]); + const hasLiveQuery = checkLiveQuery(className, config); + if (hasTriggers || hasLiveQuery || className == '_Session') { + return new RestQuery(config, auth, className, { objectId }) + .execute({ op: 'delete' }) + .then(response => { + if (response && response.results && response.results.length) { + const firstResult = response.results[0]; + firstResult.className = className; + if (className === '_Session' && !auth.isMaster) { + if (!auth.user || firstResult.user.objectId !== auth.user.id) { + throw new Parse.Error( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token' + ); + } } + var cacheAdapter = config.cacheController; + cacheAdapter.user.del(firstResult.sessionToken); + inflatedObject = Parse.Object.fromJSON(firstResult); + return triggers.maybeRunTrigger( + triggers.Types.beforeDelete, + auth, + inflatedObject, + null, + config + ); } - var cacheAdapter = config.cacheController; - cacheAdapter.user.del(firstResult.sessionToken); - inflatedObject = Parse.Object.fromJSON(firstResult); - // Notify LiveQuery server if possible - config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject); - return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config); - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found for delete.'); - }); - } - return Promise.resolve({}); - }).then(() => { - if (!auth.isMaster) { - return auth.getUserRoles(); - } else { - return; - } - }).then(() => { - var options = {}; - if (!auth.isMaster) { - options.acl = ['*']; - if (auth.user) { - options.acl.push(auth.user.id); - options.acl = options.acl.concat(auth.userRoles); + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Object not found for delete.' + ); + }); + } + return Promise.resolve({}); + }) + .then(() => { + if (!auth.isMaster) { + return auth.getUserRoles(); + } else { + return; + } + }) + .then(() => config.database.loadSchema()) + .then(s => { + schemaController = s; + const options = {}; + if (!auth.isMaster) { + options.acl = ['*']; + if (auth.user) { + options.acl.push(auth.user.id); + options.acl = options.acl.concat(auth.userRoles); + } } - } - return config.database.destroy(className, { - objectId: objectId - }, options); - }).then(() => { - return triggers.maybeRunTrigger(triggers.Types.afterDelete, auth, inflatedObject, null, config); - }); + return config.database.destroy( + className, + { + objectId: objectId, + }, + options, + schemaController + ); + }) + .then(() => { + // Notify LiveQuery server if possible + const perms = schemaController.getClassLevelPermissions(className); + config.liveQueryController.onAfterDelete( + className, + inflatedObject, + null, + perms + ); + return triggers.maybeRunTrigger( + triggers.Types.afterDelete, + auth, + inflatedObject, + null, + config + ); + }) + .catch(error => { + handleSessionMissingError(error, className, auth); + }); } // Returns a promise for a {response, status, location} object. function create(config, auth, className, restObject, clientSDK) { enforceRoleSecurity('create', className, auth); - var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK); + var write = new RestWrite( + config, + auth, + className, + null, + restObject, + null, + clientSDK + ); return write.execute(); } @@ -126,43 +216,90 @@ function create(config, auth, className, restObject, clientSDK) { function update(config, auth, className, restWhere, restObject, clientSDK) { enforceRoleSecurity('update', className, auth); - return Promise.resolve().then(() => { - const hasTriggers = checkTriggers(className, config, ['beforeSave', 'afterSave']); - const hasLiveQuery = checkLiveQuery(className, config); - if (hasTriggers || hasLiveQuery) { - return find(config, Auth.master(config), className, restWhere); - } - return Promise.resolve({}); - }).then((response) => { - var originalRestObject; - if (response && response.results && response.results.length) { - originalRestObject = response.results[0]; - } + return Promise.resolve() + .then(() => { + const hasTriggers = checkTriggers(className, config, [ + 'beforeSave', + 'afterSave', + ]); + const hasLiveQuery = checkLiveQuery(className, config); + if (hasTriggers || hasLiveQuery) { + // Do not use find, as it runs the before finds + return new RestQuery( + config, + auth, + className, + restWhere, + undefined, + undefined, + false + ).execute({ + op: 'update', + }); + } + return Promise.resolve({}); + }) + .then(({ results }) => { + var originalRestObject; + if (results && results.length) { + originalRestObject = results[0]; + } + return new RestWrite( + config, + auth, + className, + restWhere, + restObject, + originalRestObject, + clientSDK, + 'update' + ).execute(); + }) + .catch(error => { + handleSessionMissingError(error, className, auth); + }); +} - var write = new RestWrite(config, auth, className, restWhere, restObject, originalRestObject, clientSDK); - return write.execute(); - }); +function handleSessionMissingError(error, className, auth) { + // If we're trying to update a user without / with bad session token + if ( + className === '_User' && + error.code === Parse.Error.OBJECT_NOT_FOUND && + !auth.isMaster + ) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, 'Insufficient auth.'); + } + throw error; } -const classesWithMasterOnlyAccess = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule']; +const classesWithMasterOnlyAccess = [ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_JobSchedule', +]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) { if (className === '_Installation' && !auth.isMaster) { if (method === 'delete' || method === 'find') { - const error = `Clients aren't allowed to perform the ${method} operation on the installation collection.` + const error = `Clients aren't allowed to perform the ${method} operation on the installation collection.`; throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } } //all volatileClasses are masterKey only - if(classesWithMasterOnlyAccess.indexOf(className) >= 0 && !auth.isMaster){ - const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.` + if (classesWithMasterOnlyAccess.indexOf(className) >= 0 && !auth.isMaster) { + const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`; throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } // readOnly masterKey is not allowed - if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { - const error = `read-only masterKey isn't allowed to perform the ${method} operation.` + if ( + auth.isReadOnly && + (method === 'delete' || method === 'create' || method === 'update') + ) { + const error = `read-only masterKey isn't allowed to perform the ${method} operation.`; throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } } @@ -172,5 +309,5 @@ module.exports = { del, find, get, - update + update, }; diff --git a/src/triggers.js b/src/triggers.js index a11a71f7f4..41c33ee94b 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,14 +1,17 @@ // triggers.js -import Parse from 'parse/node'; +import Parse from 'parse/node'; import { logger } from './logger'; export const Types = { + beforeLogin: 'beforeLogin', + afterLogin: 'afterLogin', + afterLogout: 'afterLogout', beforeSave: 'beforeSave', afterSave: 'afterSave', beforeDelete: 'beforeDelete', afterDelete: 'afterDelete', beforeFind: 'beforeFind', - afterFind: 'afterFind' + afterFind: 'afterFind', }; const baseStore = function() { @@ -16,7 +19,7 @@ const baseStore = function() { const Functions = {}; const Jobs = {}; const LiveQuery = []; - const Triggers = Object.keys(Types).reduce(function(base, key){ + const Triggers = Object.keys(Types).reduce(function(base, key) { base[key] = {}; return base; }, {}); @@ -31,55 +34,106 @@ const baseStore = function() { }; function validateClassNameForTriggers(className, type) { - const restrictedClassNames = [ '_Session' ]; - if (restrictedClassNames.indexOf(className) != -1) { - throw `Triggers are not supported for ${className} class.`; - } if (type == Types.beforeSave && className === '_PushStatus') { // _PushStatus uses undocumented nested key increment ops // allowing beforeSave would mess up the objects big time // TODO: Allow proper documented way of using nested increment ops throw 'Only afterSave is allowed on _PushStatus'; } + if ( + (type === Types.beforeLogin || type === Types.afterLogin) && + className !== '_User' + ) { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the _User class is allowed for the beforeLogin and afterLogin triggers'; + } + if (type === Types.afterLogout && className !== '_Session') { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the _Session class is allowed for the afterLogout trigger.'; + } + if (className === '_Session' && type !== Types.afterLogout) { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the afterLogout trigger is allowed for the _Session class.'; + } return className; } const _triggerStore = {}; -export function addFunction(functionName, handler, validationHandler, applicationId) { +const Category = { + Functions: 'Functions', + Validators: 'Validators', + Jobs: 'Jobs', + Triggers: 'Triggers', +}; + +function getStore(category, name, applicationId) { + const path = name.split('.'); + path.splice(-1); // remove last component applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); - _triggerStore[applicationId].Functions[functionName] = handler; - _triggerStore[applicationId].Validators[functionName] = validationHandler; + _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); + let store = _triggerStore[applicationId][category]; + for (const component of path) { + store = store[component]; + if (!store) { + return undefined; + } + } + return store; +} + +function add(category, name, handler, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + store[lastComponent] = handler; +} + +function remove(category, name, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + delete store[lastComponent]; +} + +function get(category, name, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + return store[lastComponent]; +} + +export function addFunction( + functionName, + handler, + validationHandler, + applicationId +) { + add(Category.Functions, functionName, handler, applicationId); + add(Category.Validators, functionName, validationHandler, applicationId); } export function addJob(jobName, handler, applicationId) { - applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); - _triggerStore[applicationId].Jobs[jobName] = handler; + add(Category.Jobs, jobName, handler, applicationId); } export function addTrigger(type, className, handler, applicationId) { validateClassNameForTriggers(className, type); - applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); - _triggerStore[applicationId].Triggers[type][className] = handler; + add(Category.Triggers, `${type}.${className}`, handler, applicationId); } export function addLiveQueryEventHandler(handler, applicationId) { applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); + _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); _triggerStore[applicationId].LiveQuery.push(handler); } export function removeFunction(functionName, applicationId) { - applicationId = applicationId || Parse.applicationId; - delete _triggerStore[applicationId].Functions[functionName] + remove(Category.Functions, functionName, applicationId); } export function removeTrigger(type, className, applicationId) { - applicationId = applicationId || Parse.applicationId; - delete _triggerStore[applicationId].Triggers[type][className] + remove(Category.Triggers, `${type}.${className}`, applicationId); } export function _unregisterAll() { @@ -88,36 +142,48 @@ export function _unregisterAll() { export function getTrigger(className, triggerType, applicationId) { if (!applicationId) { - throw "Missing ApplicationID"; - } - var manager = _triggerStore[applicationId] - if (manager - && manager.Triggers - && manager.Triggers[triggerType] - && manager.Triggers[triggerType][className]) { - return manager.Triggers[triggerType][className]; + throw 'Missing ApplicationID'; } - return undefined; + return get(Category.Triggers, `${triggerType}.${className}`, applicationId); } -export function triggerExists(className: string, type: string, applicationId: string): boolean { - return (getTrigger(className, type, applicationId) != undefined); +export function triggerExists( + className: string, + type: string, + applicationId: string +): boolean { + return getTrigger(className, type, applicationId) != undefined; } export function getFunction(functionName, applicationId) { - var manager = _triggerStore[applicationId]; - if (manager && manager.Functions) { - return manager.Functions[functionName]; - } - return undefined; + return get(Category.Functions, functionName, applicationId); +} + +export function getFunctionNames(applicationId) { + const store = + (_triggerStore[applicationId] && + _triggerStore[applicationId][Category.Functions]) || + {}; + const functionNames = []; + const extractFunctionNames = (namespace, store) => { + Object.keys(store).forEach(name => { + const value = store[name]; + if (namespace) { + name = `${namespace}.${name}`; + } + if (typeof value === 'function') { + functionNames.push(name); + } else { + extractFunctionNames(name, value); + } + }); + }; + extractFunctionNames(null, store); + return functionNames; } export function getJob(jobName, applicationId) { - var manager = _triggerStore[applicationId]; - if (manager && manager.Jobs) { - return manager.Jobs[jobName]; - } - return undefined; + return get(Category.Jobs, jobName, applicationId); } export function getJobs(applicationId) { @@ -128,17 +194,19 @@ export function getJobs(applicationId) { return undefined; } - export function getValidator(functionName, applicationId) { - var manager = _triggerStore[applicationId]; - if (manager && manager.Validators) { - return manager.Validators[functionName]; - } - return undefined; + return get(Category.Validators, functionName, applicationId); } -export function getRequestObject(triggerType, auth, parseObject, originalParseObject, config) { - var request = { +export function getRequestObject( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context +) { + const request = { triggerName: triggerType, object: parseObject, master: false, @@ -151,6 +219,11 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb request.original = originalParseObject; } + if (triggerType === Types.beforeSave || triggerType === Types.afterSave) { + // Set a copy of the context on the request object. + request.context = Object.assign({}, context); + } + if (!auth) { return request; } @@ -166,7 +239,14 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb return request; } -export function getRequestQueryObject(triggerType, auth, query, count, config, isGet) { +export function getRequestQueryObject( + triggerType, + auth, + query, + count, + config, + isGet +) { isGet = !!isGet; var request = { @@ -203,7 +283,7 @@ export function getResponseObject(request, resolve, reject) { return { success: function(response) { if (request.triggerName === Types.afterFind) { - if(!response){ + if (!response) { response = request.objects; } response = response.map(object => { @@ -212,173 +292,260 @@ export function getResponseObject(request, resolve, reject) { return resolve(response); } // Use the JSON response - if (response && !request.object.equals(response) - && request.triggerName === Types.beforeSave) { + if ( + response && + typeof response === 'object' && + !request.object.equals(response) && + request.triggerName === Types.beforeSave + ) { return resolve(response); } + if ( + response && + typeof response === 'object' && + request.triggerName === Types.afterSave + ) { + return resolve(response); + } + if (request.triggerName === Types.afterSave) { + return resolve(); + } response = {}; if (request.triggerName === Types.beforeSave) { response['object'] = request.object._getSaveJSON(); } return resolve(response); }, - error: function(code, message) { - if (!message) { - message = code; - code = Parse.Error.SCRIPT_FAILED; + error: function(error) { + if (error instanceof Parse.Error) { + reject(error); + } else if (error instanceof Error) { + reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error.message)); + } else { + reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error)); } - var scriptError = new Parse.Error(code, message); - return reject(scriptError); - } - } + }, + }; } function userIdForLog(auth) { - return (auth && auth.user) ? auth.user.id : undefined; + return auth && auth.user ? auth.user.id : undefined; } function logTriggerAfterHook(triggerType, className, input, auth) { const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); - logger.info(`${triggerType} triggered for ${className} for user ${userIdForLog(auth)}:\n Input: ${cleanInput}`, { - className, - triggerType, - user: userIdForLog(auth) - }); + logger.info( + `${triggerType} triggered for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}`, + { + className, + triggerType, + user: userIdForLog(auth), + } + ); } -function logTriggerSuccessBeforeHook(triggerType, className, input, result, auth) { +function logTriggerSuccessBeforeHook( + triggerType, + className, + input, + result, + auth +) { const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); const cleanResult = logger.truncateLogMessage(JSON.stringify(result)); - logger.info(`${triggerType} triggered for ${className} for user ${userIdForLog(auth)}:\n Input: ${cleanInput}\n Result: ${cleanResult}`, { - className, - triggerType, - user: userIdForLog(auth) - }); + logger.info( + `${triggerType} triggered for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}\n Result: ${cleanResult}`, + { + className, + triggerType, + user: userIdForLog(auth), + } + ); } function logTriggerErrorBeforeHook(triggerType, className, input, auth, error) { const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); - logger.error(`${triggerType} failed for ${className} for user ${userIdForLog(auth)}:\n Input: ${cleanInput}\n Error: ${JSON.stringify(error)}`, { - className, - triggerType, - error, - user: userIdForLog(auth) - }); + logger.error( + `${triggerType} failed for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}\n Error: ${JSON.stringify(error)}`, + { + className, + triggerType, + error, + user: userIdForLog(auth), + } + ); } -export function maybeRunAfterFindTrigger(triggerType, auth, className, objects, config) { +export function maybeRunAfterFindTrigger( + triggerType, + auth, + className, + objects, + config +) { return new Promise((resolve, reject) => { const trigger = getTrigger(className, triggerType, config.applicationId); if (!trigger) { return resolve(); } const request = getRequestObject(triggerType, auth, null, null, config); - const response = getResponseObject(request, + const { success, error } = getResponseObject( + request, object => { resolve(object); }, error => { reject(error); - }); - logTriggerSuccessBeforeHook(triggerType, className, 'AfterFind', JSON.stringify(objects), auth); + } + ); + logTriggerSuccessBeforeHook( + triggerType, + className, + 'AfterFind', + JSON.stringify(objects), + auth + ); request.objects = objects.map(object => { //setting the class name to transform into parse object object.className = className; return Parse.Object.fromJSON(object); }); - const triggerPromise = trigger(request, response); - if (triggerPromise && typeof triggerPromise.then === "function") { - return triggerPromise.then(promiseResults => { - if(promiseResults) { - resolve(promiseResults); - }else{ - return reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, "AfterFind expect results to be returned in the promise")); + return Promise.resolve() + .then(() => { + const response = trigger(request); + if (response && typeof response.then === 'function') { + return response.then(results => { + if (!results) { + throw new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'AfterFind expect results to be returned in the promise' + ); + } + return results; + }); } - }); - } - }).then((results) => { + return response; + }) + .then(success, error); + }).then(results => { logTriggerAfterHook(triggerType, className, JSON.stringify(results), auth); return results; }); } -export function maybeRunQueryTrigger(triggerType, className, restWhere, restOptions, config, auth, isGet) { +export function maybeRunQueryTrigger( + triggerType, + className, + restWhere, + restOptions, + config, + auth, + isGet +) { const trigger = getTrigger(className, triggerType, config.applicationId); if (!trigger) { return Promise.resolve({ restWhere, - restOptions + restOptions, }); } + const json = Object.assign({}, restOptions); + json.where = restWhere; const parseQuery = new Parse.Query(className); - if (restWhere) { - parseQuery._where = restWhere; - } + parseQuery.withJSON(json); + let count = false; if (restOptions) { - if (restOptions.include && restOptions.include.length > 0) { - parseQuery._include = restOptions.include.split(','); - } - if (restOptions.skip) { - parseQuery._skip = restOptions.skip; - } - if (restOptions.limit) { - parseQuery._limit = restOptions.limit; - } count = !!restOptions.count; } - const requestObject = getRequestQueryObject(triggerType, auth, parseQuery, count, config, isGet); - return Promise.resolve().then(() => { - return trigger(requestObject); - }).then((result) => { - let queryResult = parseQuery; - if (result && result instanceof Parse.Query) { - queryResult = result; - } - const jsonQuery = queryResult.toJSON(); - if (jsonQuery.where) { - restWhere = jsonQuery.where; - } - if (jsonQuery.limit) { - restOptions = restOptions || {}; - restOptions.limit = jsonQuery.limit; - } - if (jsonQuery.skip) { - restOptions = restOptions || {}; - restOptions.skip = jsonQuery.skip; - } - if (jsonQuery.include) { - restOptions = restOptions || {}; - restOptions.include = jsonQuery.include; - } - if (jsonQuery.keys) { - restOptions = restOptions || {}; - restOptions.keys = jsonQuery.keys; - } - if (requestObject.readPreference) { - restOptions = restOptions || {}; - restOptions.readPreference = requestObject.readPreference; - } - if (requestObject.includeReadPreference) { - restOptions = restOptions || {}; - restOptions.includeReadPreference = requestObject.includeReadPreference; - } - if (requestObject.subqueryReadPreference) { - restOptions = restOptions || {}; - restOptions.subqueryReadPreference = requestObject.subqueryReadPreference; - } - return { - restWhere, - restOptions - }; - }, (err) => { - if (typeof err === 'string') { - throw new Parse.Error(1, err); - } else { - throw err; - } - }); + const requestObject = getRequestQueryObject( + triggerType, + auth, + parseQuery, + count, + config, + isGet + ); + return Promise.resolve() + .then(() => { + return trigger(requestObject); + }) + .then( + result => { + let queryResult = parseQuery; + if (result && result instanceof Parse.Query) { + queryResult = result; + } + const jsonQuery = queryResult.toJSON(); + if (jsonQuery.where) { + restWhere = jsonQuery.where; + } + if (jsonQuery.limit) { + restOptions = restOptions || {}; + restOptions.limit = jsonQuery.limit; + } + if (jsonQuery.skip) { + restOptions = restOptions || {}; + restOptions.skip = jsonQuery.skip; + } + if (jsonQuery.include) { + restOptions = restOptions || {}; + restOptions.include = jsonQuery.include; + } + if (jsonQuery.excludeKeys) { + restOptions = restOptions || {}; + restOptions.excludeKeys = jsonQuery.excludeKeys; + } + if (jsonQuery.explain) { + restOptions = restOptions || {}; + restOptions.explain = jsonQuery.explain; + } + if (jsonQuery.keys) { + restOptions = restOptions || {}; + restOptions.keys = jsonQuery.keys; + } + if (jsonQuery.order) { + restOptions = restOptions || {}; + restOptions.order = jsonQuery.order; + } + if (jsonQuery.hint) { + restOptions = restOptions || {}; + restOptions.hint = jsonQuery.hint; + } + if (requestObject.readPreference) { + restOptions = restOptions || {}; + restOptions.readPreference = requestObject.readPreference; + } + if (requestObject.includeReadPreference) { + restOptions = restOptions || {}; + restOptions.includeReadPreference = + requestObject.includeReadPreference; + } + if (requestObject.subqueryReadPreference) { + restOptions = restOptions || {}; + restOptions.subqueryReadPreference = + requestObject.subqueryReadPreference; + } + return { + restWhere, + restOptions, + }; + }, + err => { + if (typeof err === 'string') { + throw new Parse.Error(1, err); + } else { + throw err; + } + } + ); } // To be used as part of the promise chain when saving/deleting an object @@ -386,58 +553,122 @@ export function maybeRunQueryTrigger(triggerType, className, restWhere, restOpti // Resolves to an object, empty or containing an object key. A beforeSave // trigger will set the object key to the rest format object to save. // originalParseObject is optional, we only need that for before/afterSave functions -export function maybeRunTrigger(triggerType, auth, parseObject, originalParseObject, config) { +export function maybeRunTrigger( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context +) { if (!parseObject) { return Promise.resolve({}); } - return new Promise(function (resolve, reject) { - var trigger = getTrigger(parseObject.className, triggerType, config.applicationId); + return new Promise(function(resolve, reject) { + var trigger = getTrigger( + parseObject.className, + triggerType, + config.applicationId + ); if (!trigger) return resolve(); - var request = getRequestObject(triggerType, auth, parseObject, originalParseObject, config); - var response = getResponseObject(request, (object) => { - logTriggerSuccessBeforeHook( - triggerType, parseObject.className, parseObject.toJSON(), object, auth); - resolve(object); - }, (error) => { - logTriggerErrorBeforeHook( - triggerType, parseObject.className, parseObject.toJSON(), auth, error); - reject(error); - }); - // Force the current Parse app before the trigger - Parse.applicationId = config.applicationId; - Parse.javascriptKey = config.javascriptKey || ''; - Parse.masterKey = config.masterKey; + var request = getRequestObject( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context + ); + var { success, error } = getResponseObject( + request, + object => { + logTriggerSuccessBeforeHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + object, + auth + ); + if ( + triggerType === Types.beforeSave || + triggerType === Types.afterSave + ) { + Object.assign(context, request.context); + } + resolve(object); + }, + error => { + logTriggerErrorBeforeHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + auth, + error + ); + reject(error); + } + ); // AfterSave and afterDelete triggers can return a promise, which if they // do, needs to be resolved before this promise is resolved, // so trigger execution is synced with RestWrite.execute() call. // If triggers do not return a promise, they can run async code parallel // to the RestWrite.execute() call. - var triggerPromise = trigger(request, response); - if(triggerType === Types.afterSave || triggerType === Types.afterDelete) - { - logTriggerAfterHook(triggerType, parseObject.className, parseObject.toJSON(), auth); - if(triggerPromise && typeof triggerPromise.then === "function") { - return triggerPromise.then(resolve, resolve); - } - else { - return resolve(); - } - } + return Promise.resolve() + .then(() => { + const promise = trigger(request); + if ( + triggerType === Types.afterSave || + triggerType === Types.afterDelete || + triggerType === Types.afterLogin + ) { + logTriggerAfterHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + auth + ); + } + // beforeSave is expected to return null (nothing) + if (triggerType === Types.beforeSave) { + if (promise && typeof promise.then === 'function') { + return promise.then(response => { + // response.object may come from express routing before hook + if (response && response.object) { + return response; + } + return null; + }); + } + return null; + } + + return promise; + }) + .then(success, error); }); } // Converts a REST-format object to a Parse.Object // data is either className or an object export function inflate(data, restObject) { - var copy = typeof data == 'object' ? data : {className: data}; + var copy = typeof data == 'object' ? data : { className: data }; for (var key in restObject) { copy[key] = restObject[key]; } return Parse.Object.fromJSON(copy); } -export function runLiveQueryEventHandlers(data, applicationId = Parse.applicationId) { - if (!_triggerStore || !_triggerStore[applicationId] || !_triggerStore[applicationId].LiveQuery) { return; } - _triggerStore[applicationId].LiveQuery.forEach((handler) => handler(data)); +export function runLiveQueryEventHandlers( + data, + applicationId = Parse.applicationId +) { + if ( + !_triggerStore || + !_triggerStore[applicationId] || + !_triggerStore[applicationId].LiveQuery + ) { + return; + } + _triggerStore[applicationId].LiveQuery.forEach(handler => handler(data)); } diff --git a/src/vendor/README.md b/src/vendor/README.md index 6bcf5df262..04e3256f72 100644 --- a/src/vendor/README.md +++ b/src/vendor/README.md @@ -1,8 +1,8 @@ # mongoUrl -A fork of node's `url` module, with the modification that commas and colons are -allowed in hostnames. While this results in a slightly incorrect parsed result, -as the hostname field for a mongodb should be an array of replica sets, it's +A fork of node's `url` module, with the modification that commas and colons are +allowed in hostnames. While this results in a slightly incorrect parsed result, +as the hostname field for a mongodb should be an array of replica sets, it's good enough to let us pull out and escape the auth portion of the URL. https://github.com/parse-community/parse-server/pull/986 diff --git a/src/vendor/mongodbUrl.js b/src/vendor/mongodbUrl.js index 4e3689f0c3..a96fd35f92 100644 --- a/src/vendor/mongodbUrl.js +++ b/src/vendor/mongodbUrl.js @@ -43,26 +43,26 @@ const simplePathPattern = /^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/; const hostnameMaxLen = 255; // protocols that can allow "unsafe" and "unwise" chars. const unsafeProtocol = { - 'javascript': true, - 'javascript:': true + javascript: true, + 'javascript:': true, }; // protocols that never have a hostname. const hostlessProtocol = { - 'javascript': true, - 'javascript:': true + javascript: true, + 'javascript:': true, }; // protocols that always contain a // bit. const slashedProtocol = { - 'http': true, + http: true, 'http:': true, - 'https': true, + https: true, 'https:': true, - 'ftp': true, + ftp: true, 'ftp:': true, - 'gopher': true, + gopher: true, 'gopher:': true, - 'file': true, - 'file:': true + file: true, + 'file:': true, }; const querystring = require('querystring'); @@ -94,16 +94,16 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { const code = url.charCodeAt(i); // Find first and last non-whitespace characters for trimming - const isWs = code === 32/* */ || - code === 9/*\t*/ || - code === 13/*\r*/ || - code === 10/*\n*/ || - code === 12/*\f*/ || - code === 160/*\u00A0*/ || - code === 65279/*\uFEFF*/; + const isWs = + code === 32 /* */ || + code === 9 /*\t*/ || + code === 13 /*\r*/ || + code === 10 /*\n*/ || + code === 12 /*\f*/ || + code === 160 /*\u00A0*/ || + code === 65279; /*\uFEFF*/ if (start === -1) { - if (isWs) - continue; + if (isWs) continue; lastPos = start = i; } else { if (inWs) { @@ -120,20 +120,19 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // Only convert backslashes while we haven't seen a split character if (!split) { switch (code) { - case 35: // '#' - hasHash = true; + case 35: // '#' + hasHash = true; // Fall through - case 63: // '?' - split = true; - break; - case 92: // '\\' - if (i - lastPos > 0) - rest += url.slice(lastPos, i); - rest += '/'; - lastPos = i + 1; - break; + case 63: // '?' + split = true; + break; + case 92: // '\\' + if (i - lastPos > 0) rest += url.slice(lastPos, i); + rest += '/'; + lastPos = i + 1; + break; } - } else if (!hasHash && code === 35/*#*/) { + } else if (!hasHash && code === 35 /*#*/) { hasHash = true; } } @@ -144,10 +143,8 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // We didn't convert any backslashes if (end === -1) { - if (start === 0) - rest = url; - else - rest = url.slice(start); + if (start === 0) rest = url; + else rest = url.slice(start); } else { rest = url.slice(start, end); } @@ -195,17 +192,18 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // resolution will treat //foo/bar as host=foo,path=bar because that's // how the browser resolves relative URLs. if (slashesDenoteHost || proto || /^\/\/[^@\/]+@[^@\/]+/.test(rest)) { - var slashes = rest.charCodeAt(0) === 47/*/*/ && - rest.charCodeAt(1) === 47/*/*/; + var slashes = + rest.charCodeAt(0) === 47 /*/*/ && rest.charCodeAt(1) === 47; /*/*/ if (slashes && !(proto && hostlessProtocol[proto])) { rest = rest.slice(2); this.slashes = true; } } - if (!hostlessProtocol[proto] && - (slashes || (proto && !slashedProtocol[proto]))) { - + if ( + !hostlessProtocol[proto] && + (slashes || (proto && !slashedProtocol[proto])) + ) { // there's a hostname. // the first instance of /, ?, ;, or # ends the host. // @@ -226,43 +224,40 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { var nonHost = -1; for (i = 0; i < rest.length; ++i) { switch (rest.charCodeAt(i)) { - case 9: // '\t' - case 10: // '\n' - case 13: // '\r' - case 32: // ' ' - case 34: // '"' - case 37: // '%' - case 39: // '\'' - case 59: // ';' - case 60: // '<' - case 62: // '>' - case 92: // '\\' - case 94: // '^' - case 96: // '`' - case 123: // '{' - case 124: // '|' - case 125: // '}' - // Characters that are never ever allowed in a hostname from RFC 2396 - if (nonHost === -1) - nonHost = i; - break; - case 35: // '#' - case 47: // '/' - case 63: // '?' - // Find the first instance of any host-ending characters - if (nonHost === -1) - nonHost = i; - hostEnd = i; - break; - case 64: // '@' - // At this point, either we have an explicit point where the - // auth portion cannot go past, or the last @ char is the decider. - atSign = i; - nonHost = -1; - break; + case 9: // '\t' + case 10: // '\n' + case 13: // '\r' + case 32: // ' ' + case 34: // '"' + case 37: // '%' + case 39: // '\'' + case 59: // ';' + case 60: // '<' + case 62: // '>' + case 92: // '\\' + case 94: // '^' + case 96: // '`' + case 123: // '{' + case 124: // '|' + case 125: // '}' + // Characters that are never ever allowed in a hostname from RFC 2396 + if (nonHost === -1) nonHost = i; + break; + case 35: // '#' + case 47: // '/' + case 63: // '?' + // Find the first instance of any host-ending characters + if (nonHost === -1) nonHost = i; + hostEnd = i; + break; + case 64: // '@' + // At this point, either we have an explicit point where the + // auth portion cannot go past, or the last @ char is the decider. + atSign = i; + nonHost = -1; + break; } - if (hostEnd !== -1) - break; + if (hostEnd !== -1) break; } start = 0; if (atSign !== -1) { @@ -282,21 +277,20 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // we've indicated that there is a hostname, // so even if it's empty, it has to be present. - if (typeof this.hostname !== 'string') - this.hostname = ''; + if (typeof this.hostname !== 'string') this.hostname = ''; var hostname = this.hostname; // if hostname begins with [ and ends with ] // assume that it's an IPv6 address. - var ipv6Hostname = hostname.charCodeAt(0) === 91/*[*/ && - hostname.charCodeAt(hostname.length - 1) === 93/*]*/; + var ipv6Hostname = + hostname.charCodeAt(0) === 91 /*[*/ && + hostname.charCodeAt(hostname.length - 1) === 93; /*]*/ // validate a little. if (!ipv6Hostname) { const result = validateHostname(this, rest, hostname); - if (result !== undefined) - rest = result; + if (result !== undefined) rest = result; } if (this.hostname.length > hostnameMaxLen) { @@ -335,19 +329,18 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // escaped, even if encodeURIComponent doesn't think they // need to be. const result = autoEscapeStr(rest); - if (result !== undefined) - rest = result; + if (result !== undefined) rest = result; } var questionIdx = -1; var hashIdx = -1; for (i = 0; i < rest.length; ++i) { const code = rest.charCodeAt(i); - if (code === 35/*#*/) { + if (code === 35 /*#*/) { this.hash = rest.slice(i); hashIdx = i; break; - } else if (code === 63/*?*/ && questionIdx === -1) { + } else if (code === 63 /*?*/ && questionIdx === -1) { questionIdx = i; } } @@ -369,18 +362,16 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { this.query = {}; } - var firstIdx = (questionIdx !== -1 && - (hashIdx === -1 || questionIdx < hashIdx) - ? questionIdx - : hashIdx); + var firstIdx = + questionIdx !== -1 && (hashIdx === -1 || questionIdx < hashIdx) + ? questionIdx + : hashIdx; if (firstIdx === -1) { - if (rest.length > 0) - this.pathname = rest; + if (rest.length > 0) this.pathname = rest; } else if (firstIdx > 0) { this.pathname = rest.slice(0, firstIdx); } - if (slashedProtocol[lowerProto] && - this.hostname && !this.pathname) { + if (slashedProtocol[lowerProto] && this.hostname && !this.pathname) { this.pathname = '/'; } @@ -400,9 +391,8 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { function validateHostname(self, rest, hostname) { for (var i = 0, lastPos; i <= hostname.length; ++i) { var code; - if (i < hostname.length) - code = hostname.charCodeAt(i); - if (code === 46/*.*/ || i === hostname.length) { + if (i < hostname.length) code = hostname.charCodeAt(i); + if (code === 46 /*.*/ || i === hostname.length) { if (i - lastPos > 0) { if (i - lastPos > 63) { self.hostname = hostname.slice(0, lastPos + 63); @@ -411,23 +401,24 @@ function validateHostname(self, rest, hostname) { } lastPos = i + 1; continue; - } else if ((code >= 48/*0*/ && code <= 57/*9*/) || - (code >= 97/*a*/ && code <= 122/*z*/) || - code === 45/*-*/ || - (code >= 65/*A*/ && code <= 90/*Z*/) || - code === 43/*+*/ || - code === 95/*_*/ || - /* BEGIN MONGO URI PATCH */ - code === 44/*,*/ || - code === 58/*:*/ || - /* END MONGO URI PATCH */ - code > 127) { + } else if ( + (code >= 48 /*0*/ && code <= 57) /*9*/ || + (code >= 97 /*a*/ && code <= 122) /*z*/ || + code === 45 /*-*/ || + (code >= 65 /*A*/ && code <= 90) /*Z*/ || + code === 43 /*+*/ || + code === 95 /*_*/ || + /* BEGIN MONGO URI PATCH */ + code === 44 /*,*/ || + code === 58 /*:*/ || + /* END MONGO URI PATCH */ + code > 127 + ) { continue; } // Invalid host character self.hostname = hostname.slice(0, i); - if (i < hostname.length) - return '/' + hostname.slice(i) + rest; + if (i < hostname.length) return '/' + hostname.slice(i) + rest; break; } } @@ -440,98 +431,81 @@ function autoEscapeStr(rest) { // Automatically escape all delimiters and unwise characters from RFC 2396 // Also escape single quotes in case of an XSS attack switch (rest.charCodeAt(i)) { - case 9: // '\t' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%09'; - lastPos = i + 1; - break; - case 10: // '\n' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%0A'; - lastPos = i + 1; - break; - case 13: // '\r' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%0D'; - lastPos = i + 1; - break; - case 32: // ' ' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%20'; - lastPos = i + 1; - break; - case 34: // '"' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%22'; - lastPos = i + 1; - break; - case 39: // '\'' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%27'; - lastPos = i + 1; - break; - case 60: // '<' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%3C'; - lastPos = i + 1; - break; - case 62: // '>' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%3E'; - lastPos = i + 1; - break; - case 92: // '\\' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%5C'; - lastPos = i + 1; - break; - case 94: // '^' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%5E'; - lastPos = i + 1; - break; - case 96: // '`' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%60'; - lastPos = i + 1; - break; - case 123: // '{' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%7B'; - lastPos = i + 1; - break; - case 124: // '|' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%7C'; - lastPos = i + 1; - break; - case 125: // '}' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); - newRest += '%7D'; - lastPos = i + 1; - break; + case 9: // '\t' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%09'; + lastPos = i + 1; + break; + case 10: // '\n' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%0A'; + lastPos = i + 1; + break; + case 13: // '\r' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%0D'; + lastPos = i + 1; + break; + case 32: // ' ' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%20'; + lastPos = i + 1; + break; + case 34: // '"' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%22'; + lastPos = i + 1; + break; + case 39: // '\'' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%27'; + lastPos = i + 1; + break; + case 60: // '<' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%3C'; + lastPos = i + 1; + break; + case 62: // '>' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%3E'; + lastPos = i + 1; + break; + case 92: // '\\' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%5C'; + lastPos = i + 1; + break; + case 94: // '^' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%5E'; + lastPos = i + 1; + break; + case 96: // '`' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%60'; + lastPos = i + 1; + break; + case 123: // '{' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%7B'; + lastPos = i + 1; + break; + case 124: // '|' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%7C'; + lastPos = i + 1; + break; + case 125: // '}' + if (i - lastPos > 0) newRest += rest.slice(lastPos, i); + newRest += '%7D'; + lastPos = i + 1; + break; } } - if (lastPos === 0) - return; - if (lastPos < rest.length) - return newRest + rest.slice(lastPos); - else - return newRest; + if (lastPos === 0) return; + if (lastPos < rest.length) return newRest + rest.slice(lastPos); + else return newRest; } // format a parsed object into a url string @@ -542,11 +516,12 @@ function urlFormat(obj) { // this way, you can call url_format() on strings // to clean up potentially wonky urls. if (typeof obj === 'string') obj = urlParse(obj); - else if (typeof obj !== 'object' || obj === null) - throw new TypeError('Parameter "urlObj" must be an object, not ' + - obj === null ? 'null' : typeof obj); - + throw new TypeError( + 'Parameter "urlObj" must be an object, not ' + obj === null + ? 'null' + : typeof obj + ); else if (!(obj instanceof Url)) return Url.prototype.format.call(obj); return obj.format(); @@ -569,9 +544,11 @@ Url.prototype.format = function() { if (this.host) { host = auth + this.host; } else if (this.hostname) { - host = auth + (this.hostname.indexOf(':') === -1 ? - this.hostname : - '[' + this.hostname + ']'); + host = + auth + + (this.hostname.indexOf(':') === -1 + ? this.hostname + : '[' + this.hostname + ']'); if (this.port) { host += ':' + this.port; } @@ -580,42 +557,41 @@ Url.prototype.format = function() { if (this.query !== null && typeof this.query === 'object') query = querystring.stringify(this.query); - var search = this.search || (query && ('?' + query)) || ''; + var search = this.search || (query && '?' + query) || ''; - if (protocol && protocol.charCodeAt(protocol.length - 1) !== 58/*:*/) + if (protocol && protocol.charCodeAt(protocol.length - 1) !== 58 /*:*/) protocol += ':'; var newPathname = ''; var lastPos = 0; for (var i = 0; i < pathname.length; ++i) { switch (pathname.charCodeAt(i)) { - case 35: // '#' - if (i - lastPos > 0) - newPathname += pathname.slice(lastPos, i); - newPathname += '%23'; - lastPos = i + 1; - break; - case 63: // '?' - if (i - lastPos > 0) - newPathname += pathname.slice(lastPos, i); - newPathname += '%3F'; - lastPos = i + 1; - break; + case 35: // '#' + if (i - lastPos > 0) newPathname += pathname.slice(lastPos, i); + newPathname += '%23'; + lastPos = i + 1; + break; + case 63: // '?' + if (i - lastPos > 0) newPathname += pathname.slice(lastPos, i); + newPathname += '%3F'; + lastPos = i + 1; + break; } } if (lastPos > 0) { if (lastPos !== pathname.length) pathname = newPathname + pathname.slice(lastPos); - else - pathname = newPathname; + else pathname = newPathname; } // only the slashedProtocols get the //. Not mailto:, xmpp:, etc. // unless they had them to begin with. - if (this.slashes || - (!protocol || slashedProtocol[protocol]) && host !== false) { + if ( + this.slashes || + ((!protocol || slashedProtocol[protocol]) && host !== false) + ) { host = '//' + (host || ''); - if (pathname && pathname.charCodeAt(0) !== 47/*/*/) + if (pathname && pathname.charCodeAt(0) !== 47 /*/*/) pathname = '/' + pathname; } else if (!host) { host = ''; @@ -623,8 +599,8 @@ Url.prototype.format = function() { search = search.replace('#', '%23'); - if (hash && hash.charCodeAt(0) !== 35/*#*/) hash = '#' + hash; - if (search && search.charCodeAt(0) !== 63/*?*/) search = '?' + search; + if (hash && hash.charCodeAt(0) !== 35 /*#*/) hash = '#' + hash; + if (search && search.charCodeAt(0) !== 63 /*?*/) search = '?' + search; return protocol + host + pathname + search + hash; }; @@ -676,13 +652,15 @@ Url.prototype.resolveObject = function(relative) { var rkeys = Object.keys(relative); for (var rk = 0; rk < rkeys.length; rk++) { var rkey = rkeys[rk]; - if (rkey !== 'protocol') - result[rkey] = relative[rkey]; + if (rkey !== 'protocol') result[rkey] = relative[rkey]; } //urlParse appends trailing / to urls like http://www.example.com - if (slashedProtocol[result.protocol] && - result.hostname && !result.pathname) { + if ( + slashedProtocol[result.protocol] && + result.hostname && + !result.pathname + ) { result.path = result.pathname = '/'; } @@ -710,9 +688,11 @@ Url.prototype.resolveObject = function(relative) { } result.protocol = relative.protocol; - if (!relative.host && - !/^file:?$/.test(relative.protocol) && - !hostlessProtocol[relative.protocol]) { + if ( + !relative.host && + !/^file:?$/.test(relative.protocol) && + !hostlessProtocol[relative.protocol] + ) { const relPath = (relative.pathname || '').split('/'); while (relPath.length && !(relative.host = relPath.shift())); if (!relative.host) relative.host = ''; @@ -740,16 +720,14 @@ Url.prototype.resolveObject = function(relative) { return result; } - var isSourceAbs = (result.pathname && result.pathname.charAt(0) === '/'); - var isRelAbs = ( - relative.host || - relative.pathname && relative.pathname.charAt(0) === '/' - ); - var mustEndAbs = (isRelAbs || isSourceAbs || - (result.host && relative.pathname)); + var isSourceAbs = result.pathname && result.pathname.charAt(0) === '/'; + var isRelAbs = + relative.host || (relative.pathname && relative.pathname.charAt(0) === '/'); + var mustEndAbs = + isRelAbs || isSourceAbs || (result.host && relative.pathname); var removeAllDots = mustEndAbs; - var srcPath = result.pathname && result.pathname.split('/') || []; - var relPath = relative.pathname && relative.pathname.split('/') || []; + var srcPath = (result.pathname && result.pathname.split('/')) || []; + var relPath = (relative.pathname && relative.pathname.split('/')) || []; var psychotic = result.protocol && !slashedProtocol[result.protocol]; // if the url is a non-slashed url, then relative @@ -779,10 +757,12 @@ Url.prototype.resolveObject = function(relative) { if (isRelAbs) { // it's absolute. - result.host = (relative.host || relative.host === '') ? - relative.host : result.host; - result.hostname = (relative.hostname || relative.hostname === '') ? - relative.hostname : result.hostname; + result.host = + relative.host || relative.host === '' ? relative.host : result.host; + result.hostname = + relative.hostname || relative.hostname === '' + ? relative.hostname + : result.hostname; result.search = relative.search; result.query = relative.query; srcPath = relPath; @@ -804,8 +784,10 @@ Url.prototype.resolveObject = function(relative) { //occasionally the auth can get stuck only in host //this especially happens in cases like //url.resolveObject('mailto:local1@domain1', 'local2@domain2') - const authInHost = result.host && result.host.indexOf('@') > 0 ? - result.host.split('@') : false; + const authInHost = + result.host && result.host.indexOf('@') > 0 + ? result.host.split('@') + : false; if (authInHost) { result.auth = authInHost.shift(); result.host = result.hostname = authInHost.shift(); @@ -815,8 +797,9 @@ Url.prototype.resolveObject = function(relative) { result.query = relative.query; //to support http.request if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : '') + - (result.search ? result.search : ''); + result.path = + (result.pathname ? result.pathname : '') + + (result.search ? result.search : ''); } result.href = result.format(); return result; @@ -840,9 +823,10 @@ Url.prototype.resolveObject = function(relative) { // however, if it ends in anything else non-slashy, // then it must NOT get a trailing slash. var last = srcPath.slice(-1)[0]; - var hasTrailingSlash = ( - (result.host || relative.host || srcPath.length > 1) && - (last === '.' || last === '..') || last === ''); + var hasTrailingSlash = + ((result.host || relative.host || srcPath.length > 1) && + (last === '.' || last === '..')) || + last === ''; // strip single dots, resolve double dots to parent dir // if the path tries to go above the root, `up` ends up > 0 @@ -867,27 +851,35 @@ Url.prototype.resolveObject = function(relative) { } } - if (mustEndAbs && srcPath[0] !== '' && - (!srcPath[0] || srcPath[0].charAt(0) !== '/')) { + if ( + mustEndAbs && + srcPath[0] !== '' && + (!srcPath[0] || srcPath[0].charAt(0) !== '/') + ) { srcPath.unshift(''); } - if (hasTrailingSlash && (srcPath.join('/').substr(-1) !== '/')) { + if (hasTrailingSlash && srcPath.join('/').substr(-1) !== '/') { srcPath.push(''); } - var isAbsolute = srcPath[0] === '' || - (srcPath[0] && srcPath[0].charAt(0) === '/'); + var isAbsolute = + srcPath[0] === '' || (srcPath[0] && srcPath[0].charAt(0) === '/'); // put the host back if (psychotic) { - result.hostname = result.host = isAbsolute ? '' : - srcPath.length ? srcPath.shift() : ''; + if (isAbsolute) { + result.hostname = result.host = ''; + } else { + result.hostname = result.host = srcPath.length ? srcPath.shift() : ''; + } //occasionally the auth can get stuck only in host //this especially happens in cases like //url.resolveObject('mailto:local1@domain1', 'local2@domain2') - const authInHost = result.host && result.host.indexOf('@') > 0 ? - result.host.split('@') : false; + const authInHost = + result.host && result.host.indexOf('@') > 0 + ? result.host.split('@') + : false; if (authInHost) { result.auth = authInHost.shift(); result.host = result.hostname = authInHost.shift(); @@ -909,8 +901,9 @@ Url.prototype.resolveObject = function(relative) { //to support request.http if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : '') + - (result.search ? result.search : ''); + result.path = + (result.pathname ? result.pathname : '') + + (result.search ? result.search : ''); } result.auth = relative.auth || result.auth; result.slashes = result.slashes || relative.slashes; @@ -957,16 +950,21 @@ function encodeAuth(str) { // digits // alpha (uppercase) // alpha (lowercase) - if (c === 0x21 || c === 0x2D || c === 0x2E || c === 0x5F || c === 0x7E || - (c >= 0x27 && c <= 0x2A) || - (c >= 0x30 && c <= 0x3A) || - (c >= 0x41 && c <= 0x5A) || - (c >= 0x61 && c <= 0x7A)) { + if ( + c === 0x21 || + c === 0x2d || + c === 0x2e || + c === 0x5f || + c === 0x7e || + (c >= 0x27 && c <= 0x2a) || + (c >= 0x30 && c <= 0x3a) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) + ) { continue; } - if (i - lastPos > 0) - out += str.slice(lastPos, i); + if (i - lastPos > 0) out += str.slice(lastPos, i); lastPos = i + 1; @@ -978,31 +976,29 @@ function encodeAuth(str) { // Multi-byte characters ... if (c < 0x800) { - out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)]; + out += hexTable[0xc0 | (c >> 6)] + hexTable[0x80 | (c & 0x3f)]; continue; } - if (c < 0xD800 || c >= 0xE000) { - out += hexTable[0xE0 | (c >> 12)] + - hexTable[0x80 | ((c >> 6) & 0x3F)] + - hexTable[0x80 | (c & 0x3F)]; + if (c < 0xd800 || c >= 0xe000) { + out += + hexTable[0xe0 | (c >> 12)] + + hexTable[0x80 | ((c >> 6) & 0x3f)] + + hexTable[0x80 | (c & 0x3f)]; continue; } // Surrogate pair ++i; var c2; - if (i < str.length) - c2 = str.charCodeAt(i) & 0x3FF; - else - c2 = 0; - c = 0x10000 + (((c & 0x3FF) << 10) | c2); - out += hexTable[0xF0 | (c >> 18)] + - hexTable[0x80 | ((c >> 12) & 0x3F)] + - hexTable[0x80 | ((c >> 6) & 0x3F)] + - hexTable[0x80 | (c & 0x3F)]; - } - if (lastPos === 0) - return str; - if (lastPos < str.length) - return out + str.slice(lastPos); + if (i < str.length) c2 = str.charCodeAt(i) & 0x3ff; + else c2 = 0; + c = 0x10000 + (((c & 0x3ff) << 10) | c2); + out += + hexTable[0xf0 | (c >> 18)] + + hexTable[0x80 | ((c >> 12) & 0x3f)] + + hexTable[0x80 | ((c >> 6) & 0x3f)] + + hexTable[0x80 | (c & 0x3f)]; + } + if (lastPos === 0) return str; + if (lastPos < str.length) return out + str.slice(lastPos); return out; } diff --git a/views/choose_password b/views/choose_password index 097cbd2077..8919818cf3 100644 --- a/views/choose_password +++ b/views/choose_password @@ -108,7 +108,12 @@ background-image: -ms-linear-gradient(#00395E,#005891); background-image: linear-gradient(#00395E,#005891); } - + + button:disabled, + button[disabled] { + opacity: 0.5; + } + input { color: black; cursor: auto; @@ -126,6 +131,12 @@ word-spacing: 0px; } + #password_match_info { + margin-top: 0px; + font-size: 13px; + color: red; + } + @@ -134,11 +145,20 @@
- + + New Password +
+ + Confirm New Password + + + - + + +