diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..8a15aa307e --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# Emacs +*~ diff --git a/Auth.js b/Auth.js new file mode 100644 index 0000000000..faa1ffd641 --- /dev/null +++ b/Auth.js @@ -0,0 +1,170 @@ +var deepcopy = require('deepcopy'); +var Parse = require('parse/node').Parse; +var RestQuery = require('./RestQuery'); + +var cache = require('./cache'); + +// 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, userObject) { + this.config = config; + this.isMaster = isMaster; + this.user = userObject; + + // Assuming a users roles won't change during a single request, we'll + // only load them once. + this.userRoles = []; + this.fetchedRoles = false; + this.rolePromise = null; +} + +// 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) { + if (this.isMaster) { + return true; + } + if (this.user && this.user.id === userId) { + return true; + } + return false; +}; + +// A helper to get a master-level Auth object +function master(config) { + return new Auth(config, true, null); +} + +// A helper to get a nobody-level Auth object +function nobody(config) { + return new Auth(config, false, null); +} + +// Returns a promise that resolves to an Auth object +var getAuthForSessionToken = function(config, sessionToken) { + var cachedUser = cache.getUser(sessionToken); + if (cachedUser) { + return Promise.resolve(new Auth(config, false, cachedUser)); + } + var restOptions = { + limit: 1, + include: 'user' + }; + var restWhere = { + _session_token: sessionToken + }; + var query = new RestQuery(config, master(config), '_Session', + restWhere, restOptions); + return query.execute().then((response) => { + var results = response.results; + if (results.length !== 1 || !results[0]['user']) { + return nobody(config); + } + var obj = results[0]['user']; + delete obj.password; + obj['className'] = '_User'; + var userObject = Parse.Object.fromJSON(obj); + cache.setUser(sessionToken, userObject); + return new Auth(config, false, userObject); + }); +}; + +// Returns a promise that resolves to an array of role names +Auth.prototype.getUserRoles = function() { + if (this.isMaster || !this.user) { + return Promise.resolve([]); + } + if (this.fetchedRoles) { + return Promise.resolve(this.userRoles); + } + if (this.rolePromise) { + return rolePromise; + } + this.rolePromise = this._loadRoles(); + return this.rolePromise; +}; + +// Iterates through the role tree and compiles a users roles +Auth.prototype._loadRoles = function() { + 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; + return Promise.resolve(this.userRoles); + } + + var roleIDs = results.map(r => r.objectId); + var promises = [Promise.resolve(roleIDs)]; + for (var role of roleIDs) { + promises.push(this._getAllRoleNamesForId(role)); + } + return Promise.all(promises).then((results) => { + var allIDs = []; + for (var x of results) { + Array.prototype.push.apply(allIDs, x); + } + var restWhere = { + objectId: { + '$in': allIDs + } + }; + var query = new RestQuery(this.config, master(this.config), + '_Role', restWhere, {}); + return query.execute(); + }).then((response) => { + var results = response.results; + this.userRoles = results.map((r) => { + return 'role:' + r.name; + }); + this.fetchedRoles = true; + this.rolePromise = null; + return Promise.resolve(this.userRoles); + }); + }); +}; + +// Given a role object id, get any other roles it is part of +// TODO: Make recursive to support role nesting beyond 1 level deep +Auth.prototype._getAllRoleNamesForId = function(roleID) { + var rolePointer = { + __type: 'Pointer', + className: '_Role', + objectId: roleID + }; + var restWhere = { + '$relatedTo': { + key: 'roles', + object: rolePointer + } + }; + var query = new RestQuery(this.config, master(this.config), '_Role', + restWhere, {}); + return query.execute().then((response) => { + var results = response.results; + if (!results.length) { + return Promise.resolve([]); + } + var roleIDs = results.map(r => r.objectId); + return Promise.resolve(roleIDs); + }); +}; + +module.exports = { + Auth: Auth, + master: master, + nobody: nobody, + getAuthForSessionToken: getAuthForSessionToken +}; diff --git a/Config.js b/Config.js new file mode 100644 index 0000000000..b9ede8db78 --- /dev/null +++ b/Config.js @@ -0,0 +1,27 @@ +// A Config object provides information about how a specific app is +// configured. +// mount is the URL for the root of the API; includes http, domain, etc. +function Config(applicationId, mount) { + var cache = require('./cache'); + var DatabaseAdapter = require('./DatabaseAdapter'); + + var cacheInfo = cache.apps[applicationId]; + this.valid = !!cacheInfo; + if (!this.valid) { + return; + } + + this.applicationId = applicationId; + this.collectionPrefix = cacheInfo.collectionPrefix || ''; + this.database = DatabaseAdapter.getDatabaseConnection(applicationId); + this.masterKey = cacheInfo.masterKey; + this.clientKey = cacheInfo.clientKey; + this.javascriptKey = cacheInfo.javascriptKey; + this.dotNetKey = cacheInfo.dotNetKey; + this.restAPIKey = cacheInfo.restAPIKey; + this.fileKey = cacheInfo.fileKey; + this.mount = mount; +} + + +module.exports = Config; diff --git a/DatabaseAdapter.js b/DatabaseAdapter.js new file mode 100644 index 0000000000..82efb8fd73 --- /dev/null +++ b/DatabaseAdapter.js @@ -0,0 +1,48 @@ +// Database Adapter +// +// Allows you to change the underlying database. +// +// Adapter classes must implement the following methods: +// * a constructor with signature (connectionString, optionsObject) +// * connect() +// * loadSchema() +// * create(className, object) +// * find(className, query, options) +// * update(className, query, update, options) +// * destroy(className, query, options) +// * This list is incomplete and the database process is not fully modularized. +// +// Default is ExportAdapter, which uses mongo. + +var ExportAdapter = require('./ExportAdapter'); + +var adapter = ExportAdapter; +var cache = require('./cache'); +var dbConnections = {}; +var databaseURI = 'mongodb://localhost:27017/parse'; + +function setAdapter(databaseAdapter) { + adapter = databaseAdapter; +} + +function setDatabaseURI(uri) { + databaseURI = uri; +} + +function getDatabaseConnection(appId) { + if (dbConnections[appId]) { + return dbConnections[appId]; + } + dbConnections[appId] = new adapter(databaseURI, { + collectionPrefix: cache.apps[appId]['collectionPrefix'] + }); + dbConnections[appId].connect(); + return dbConnections[appId]; +} + +module.exports = { + dbConnections: dbConnections, + getDatabaseConnection: getDatabaseConnection, + setAdapter: setAdapter, + setDatabaseURI: setDatabaseURI +}; diff --git a/ExportAdapter.js b/ExportAdapter.js new file mode 100644 index 0000000000..3ecddf4b4b --- /dev/null +++ b/ExportAdapter.js @@ -0,0 +1,576 @@ +// A database adapter that works with data exported from the hosted +// Parse database. + +var mongodb = require('mongodb'); +var MongoClient = mongodb.MongoClient; +var Parse = require('parse/node').Parse; + +var Schema = require('./Schema'); +var transform = require('./transform'); + +// options can contain: +// collectionPrefix: the string to put in front of every collection name. +function ExportAdapter(mongoURI, options) { + this.mongoURI = mongoURI; + options = options || {}; + + this.collectionPrefix = options.collectionPrefix; + + // 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.connect(); +} + +// Connects to the database. Returns a promise that resolves when the +// connection is successful. +// this.db will be populated with a Mongo "Db" object when the +// promise resolves successfully. +ExportAdapter.prototype.connect = function() { + if (this.connectionPromise) { + // There's already a connection in progress. + return this.connectionPromise; + } + + this.connectionPromise = Promise.resolve().then(() => { + return MongoClient.connect(this.mongoURI); + }).then((db) => { + this.db = db; + }); + return this.connectionPromise; +}; + +// Returns a promise for a Mongo collection. +// Generally just for internal use. +ExportAdapter.prototype.collection = function(className) { + if (className !== '_User' && + className !== '_Installation' && + className !== '_Session' && + className !== '_SCHEMA' && + className !== '_Role' && + !className.match(/^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/) && + !className.match(/^[A-Za-z][A-Za-z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, + 'invalid className: ' + className); + } + return this.connect().then(() => { + return this.db.collection(this.collectionPrefix + className); + }); +}; + +function returnsTrue() { + return true; +} + +// Returns a promise for a schema object. +// If we are provided a acceptor, then we run it on the schema. +// If the schema isn't accepted, we reload it at most once. +ExportAdapter.prototype.loadSchema = function(acceptor) { + acceptor = acceptor || returnsTrue; + + if (!this.schemaPromise) { + this.schemaPromise = this.collection('_SCHEMA').then((coll) => { + delete this.schemaPromise; + return Schema.load(coll); + }); + return this.schemaPromise; + } + + return this.schemaPromise.then((schema) => { + if (acceptor(schema)) { + return schema; + } + this.schemaPromise = this.collection('_SCHEMA').then((coll) => { + delete this.schemaPromise; + return Schema.load(coll); + }); + return this.schemaPromise; + }); +}; + +// Returns a promise for the classname that is related to the given +// classname through the key. +// TODO: make this not in the ExportAdapter interface +ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { + return this.loadSchema().then((schema) => { + var t = schema.getExpectedType(className, key); + var match = t.match(/^relation<(.*)>$/); + if (match) { + return match[1]; + } else { + return className; + } + }); +}; + +// 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. +ExportAdapter.prototype.validateObject = function(className, object) { + return this.loadSchema().then((schema) => { + return schema.validateObject(className, object); + }); +}; + +// Like transform.untransformObject but you need to provide a className. +// Filters out any data that shouldn't be on this REST-formatted object. +ExportAdapter.prototype.untransformObject = function( + schema, isMaster, aclGroup, className, mongoObject) { + var object = transform.untransformObject(schema, className, mongoObject); + + if (className !== '_User') { + return object; + } + + if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) { + return object; + } + + delete object.authData; + delete object.sessionToken; + return object; +}; + +// 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 +// 'increment'. +// 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. +ExportAdapter.prototype.update = function(className, query, update, options) { + var acceptor = function(schema) { + return schema.hasKeys(className, Object.keys(query)); + }; + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + var mongoUpdate, schema; + return this.loadSchema(acceptor).then((s) => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'update'); + } + return Promise.resolve(); + }).then(() => { + + return this.handleRelationUpdates(className, query.objectId, update); + }).then(() => { + return this.collection(className); + }).then((coll) => { + var mongoWhere = transform.transformWhere(schema, className, query); + if (options.acl) { + var writePerms = [ + {_wperm: {'$exists': false}} + ]; + for (var entry of options.acl) { + writePerms.push({_wperm: {'$in': [entry]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; + } + + mongoUpdate = transform.transformUpdate(schema, className, update); + + return coll.findAndModify(mongoWhere, {}, mongoUpdate, {}); + }).then((result) => { + if (!result.value) { + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + } + if (result.lastErrorObject.n != 1) { + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + } + + var response = {}; + var inc = mongoUpdate['$inc']; + if (inc) { + for (var key in inc) { + response[key] = (result.value[key] || 0) + inc[key]; + } + } + return response; + }); +}; + +// Processes relation-updating operations from a REST-format update. +// Returns a promise that resolves successfully when these are +// processed. +// This mutates update. +ExportAdapter.prototype.handleRelationUpdates = function(className, + objectId, + update) { + var pending = []; + var deleteMe = []; + objectId = update.objectId || objectId; + + var process = (op, key) => { + if (!op) { + return; + } + if (op.__op == 'AddRelation') { + for (var object of op.objects) { + pending.push(this.addRelation(key, className, + objectId, + object.objectId)); + } + deleteMe.push(key); + } + + if (op.__op == 'RemoveRelation') { + for (var object of op.objects) { + pending.push(this.removeRelation(key, className, + objectId, + object.objectId)); + } + deleteMe.push(key); + } + + if (op.__op == 'Batch') { + for (x of op.ops) { + process(x, key); + } + } + }; + + for (var key in update) { + process(update[key], key); + } + for (var key of deleteMe) { + delete update[key]; + } + return Promise.all(pending); +}; + +// Adds a relation. +// Returns a promise that resolves successfully iff the add was successful. +ExportAdapter.prototype.addRelation = function(key, fromClassName, + fromId, toId) { + var doc = { + relatedId: toId, + owningId: fromId + }; + var className = '_Join:' + key + ':' + fromClassName; + return this.collection(className).then((coll) => { + return coll.update(doc, doc, {upsert: true}); + }); +}; + +// Removes a relation. +// Returns a promise that resolves successfully iff the remove was +// successful. +ExportAdapter.prototype.removeRelation = function(key, fromClassName, + fromId, toId) { + var doc = { + relatedId: toId, + owningId: fromId + }; + var className = '_Join:' + key + ':' + fromClassName; + return this.collection(className).then((coll) => { + return coll.remove(doc); + }); +}; + +// 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. +ExportAdapter.prototype.destroy = function(className, query, options) { + options = options || {}; + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + + var schema; + return this.loadSchema().then((s) => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'delete'); + } + return Promise.resolve(); + }).then(() => { + + return this.collection(className); + }).then((coll) => { + var mongoWhere = transform.transformWhere(schema, className, query); + + if (options.acl) { + var writePerms = [ + {_wperm: {'$exists': false}} + ]; + for (var entry of options.acl) { + writePerms.push({_wperm: {'$in': [entry]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; + } + + return coll.remove(mongoWhere); + }).then((resp) => { + if (resp.result.n === 0) { + return Promise.reject( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + + } + }, (error) => { + throw error; + }); +}; + +// Inserts an object into the database. +// Returns a promise that resolves successfully iff the object saved. +ExportAdapter.prototype.create = function(className, object, options) { + var schema; + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + + return this.loadSchema().then((s) => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'create'); + } + return Promise.resolve(); + }).then(() => { + + return this.handleRelationUpdates(className, null, object); + }).then(() => { + return this.collection(className); + }).then((coll) => { + var mongoObject = transform.transformCreate(schema, className, object); + return coll.insert([mongoObject]); + }); +}; + +// Runs a mongo query on the database. +// This should only be used for testing - use 'find' for normal code +// to avoid Mongo-format dependencies. +// Returns a promise that resolves to a list of items. +ExportAdapter.prototype.mongoFind = function(className, query, options) { + options = options || {}; + return this.collection(className).then((coll) => { + return coll.find(query, options).toArray(); + }); +}; + +// Deletes everything in the database matching the current collectionPrefix +// Won't delete collections in the system namespace +// Returns a promise. +ExportAdapter.prototype.deleteEverything = function() { + this.schemaPromise = null; + + return this.connect().then(() => { + return this.db.collections(); + }).then((colls) => { + var promises = []; + for (var coll of colls) { + if (!coll.namespace.match(/\.system\./) && + coll.collectionName.indexOf(this.collectionPrefix) === 0) { + promises.push(coll.drop()); + } + } + return Promise.all(promises); + }); +}; + +// Finds the keys in a query. Returns a Set. REST format only +function keysForQuery(query) { + var sublist = query['$and'] || query['$or']; + if (sublist) { + var answer = new Set(); + for (var subquery of sublist) { + for (var key of keysForQuery(subquery)) { + answer.add(key); + } + } + return answer; + } + + return new Set(Object.keys(query)); +} + +// Returns a promise for a list of related ids given an owning id. +// className here is the owning className. +ExportAdapter.prototype.relatedIds = function(className, key, owningId) { + var joinTable = '_Join:' + key + ':' + className; + return this.collection(joinTable).then((coll) => { + return coll.find({owningId: owningId}).toArray(); + }).then((results) => { + return results.map(r => r.relatedId); + }); +}; + +// Returns a promise for a list of owning ids given some related ids. +// className here is the owning className. +ExportAdapter.prototype.owningIds = function(className, key, relatedIds) { + var joinTable = '_Join:' + key + ':' + className; + return this.collection(joinTable).then((coll) => { + return coll.find({relatedId: {'$in': relatedIds}}).toArray(); + }).then((results) => { + return results.map(r => r.owningId); + }); +}; + +// 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 +// TODO: this only handles one of these at a time - make it handle more +ExportAdapter.prototype.reduceInRelation = function(className, query, schema) { + // Search for an in-relation or equal-to-relation + for (var key in query) { + if (query[key] && + (query[key]['$in'] || query[key].__type == 'Pointer')) { + var t = schema.getExpectedType(className, key); + var match = t ? t.match(/^relation<(.*)>$/) : false; + if (!match) { + continue; + } + var relatedClassName = match[1]; + var relatedIds; + if (query[key]['$in']) { + relatedIds = query[key]['$in'].map(r => r.objectId); + } else { + relatedIds = [query[key].objectId]; + } + return this.owningIds(className, key, relatedIds).then((ids) => { + delete query[key]; + query.objectId = {'$in': ids}; + }); + } + } + return Promise.resolve(); +}; + +// Modifies query so that it no longer has $relatedTo +// Returns a promise that resolves when query is mutated +ExportAdapter.prototype.reduceRelationKeys = function(className, query) { + var relatedTo = query['$relatedTo']; + if (relatedTo) { + return this.relatedIds( + relatedTo.object.className, + relatedTo.key, + relatedTo.object.objectId).then((ids) => { + delete query['$relatedTo']; + query['objectId'] = {'$in': ids}; + return this.reduceRelationKeys(className, query); + }); + } +}; + +// Does a find with "smart indexing". +// Currently this just means, if it needs a geoindex and there is +// 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. +ExportAdapter.prototype.smartFind = function(coll, where, options) { + return coll.find(where, options).toArray() + .then((result) => { + return result; + }, (error) => { + // Check for "no geoindex" error + if (!error.message.match(/unable to find index for .geoNear/) || + error.code != 17007) { + throw error; + } + + // Figure out what key needs an index + var key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; + if (!key) { + throw error; + } + + var index = {}; + index[key] = '2d'; + return coll.createIndex(index).then(() => { + // Retry, but just once. + return coll.find(where, options).toArray(); + }); + }); +}; + +// 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. +ExportAdapter.prototype.find = function(className, query, options) { + options = options || {}; + var mongoOptions = {}; + if (options.skip) { + mongoOptions.skip = options.skip; + } + if (options.limit) { + mongoOptions.limit = options.limit; + } + + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + var acceptor = function(schema) { + return schema.hasKeys(className, keysForQuery(query)); + }; + var schema; + return this.loadSchema(acceptor).then((s) => { + schema = s; + if (options.sort) { + mongoOptions.sort = {}; + for (var key in options.sort) { + var mongoKey = transform.transformKey(schema, className, key); + mongoOptions.sort[mongoKey] = options.sort[key]; + } + } + + if (!isMaster) { + var op = 'find'; + var k = Object.keys(query); + if (k.length == 1 && typeof query.objectId == 'string') { + op = 'get'; + } + return schema.validatePermission(className, aclGroup, op); + } + return Promise.resolve(); + }).then(() => { + return this.reduceRelationKeys(className, query); + }).then(() => { + return this.reduceInRelation(className, query, schema); + }).then(() => { + return this.collection(className); + }).then((coll) => { + var mongoWhere = transform.transformWhere(schema, className, query); + if (!isMaster) { + var orParts = [ + {"_rperm" : { "$exists": false }}, + {"_rperm" : { "$in" : ["*"]}} + ]; + for (var acl of aclGroup) { + orParts.push({"_rperm" : { "$in" : [acl]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; + } + if (options.count) { + return coll.count(mongoWhere, mongoOptions); + } else { + return this.smartFind(coll, mongoWhere, mongoOptions) + .then((mongoResults) => { + return mongoResults.map((r) => { + return this.untransformObject( + schema, isMaster, aclGroup, className, r); + }); + }); + } + }); +}; + +module.exports = ExportAdapter; diff --git a/FilesAdapter.js b/FilesAdapter.js new file mode 100644 index 0000000000..7b952ed031 --- /dev/null +++ b/FilesAdapter.js @@ -0,0 +1,28 @@ +// Files Adapter +// +// Allows you to change the file storage mechanism. +// +// Adapter classes must implement the following functions: +// * create(config, filename, data) +// * get(config, filename) +// +// Default is GridStoreAdapter, which requires mongo +// and for the API server to be using the ExportAdapter +// database adapter. + +var GridStoreAdapter = require('./GridStoreAdapter'); + +var adapter = GridStoreAdapter; + +function setAdapter(filesAdapter) { + adapter = filesAdapter; +} + +function getAdapter() { + return adapter; +} + +module.exports = { + getAdapter: getAdapter, + setAdapter: setAdapter +}; diff --git a/GridStoreAdapter.js b/GridStoreAdapter.js new file mode 100644 index 0000000000..3168de066a --- /dev/null +++ b/GridStoreAdapter.js @@ -0,0 +1,38 @@ +// GridStoreAdapter +// +// Stores files in Mongo using GridStore +// Requires the database adapter to be based on mongoclient + +var GridStore = require('mongodb').GridStore; + +// For a given config object, filename, and data, store a file +// Returns a promise +function create(config, filename, data) { + return config.database.connect().then(() => { + var gridStore = new GridStore(config.database.db, filename, 'w'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.write(data); + }).then((gridStore) => { + return gridStore.close(); + }); +} + +// Search for and return a file if found by filename +// Resolves a promise that succeeds with the buffer result +// from GridStore +function get(config, filename) { + return config.database.connect().then(() => { + return GridStore.exist(config.database.db, filename); + }).then(() => { + var gridStore = new GridStore(config.database.db, filename, 'r'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.read(); + }); +} + +module.exports = { + create: create, + get: get +}; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..f2207ea9af --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For Parse Server software + +Copyright (c) 2015-present, Parse, LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Parse nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PATENTS b/PATENTS new file mode 100644 index 0000000000..66bfadd92b --- /dev/null +++ b/PATENTS @@ -0,0 +1,33 @@ +Additional Grant of Patent Rights Version 2 + +"Software" means the Parse Server software distributed by Parse, LLC. + +Parse, LLC. ("Parse") hereby grants to each recipient of the Software +("you") a perpetual, worldwide, royalty-free, non-exclusive, irrevocable +(subject to the termination provision below) license under any Necessary +Claims, to make, have made, use, sell, offer to sell, import, and otherwise +transfer the Software. For avoidance of doubt, no license is granted under +Parse’s rights in any patent claims that are infringed by (i) modifications +to the Software made by you or any third party or (ii) the Software in +combination with any software or other technology. + +The license granted hereunder will terminate, automatically and without notice, +if you (or any of your subsidiaries, corporate affiliates or agents) initiate +directly or indirectly, or take a direct financial interest in, any Patent +Assertion: (i) against Parse or any of its subsidiaries or corporate +affiliates, (ii) against any party if such Patent Assertion arises in whole or +in part from any software, technology, product or service of Parse or any of +its subsidiaries or corporate affiliates, or (iii) against any party relating +to the Software. Notwithstanding the foregoing, if Parse or any of its +subsidiaries or corporate affiliates files a lawsuit alleging patent +infringement against you in the first instance, and you respond by filing a +patent infringement counterclaim in that lawsuit against that party that is +unrelated to the Software, the license granted hereunder will not terminate +under section (i) of this paragraph due to such counterclaim. + +A "Necessary Claim" is a claim of a patent owned by Parse that is +necessarily infringed by the Software standing alone. + +A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, +or contributory infringement or inducement to infringe any patent, including a +cross-claim or counterclaim. diff --git a/PromiseRouter.js b/PromiseRouter.js new file mode 100644 index 0000000000..03514e7818 --- /dev/null +++ b/PromiseRouter.js @@ -0,0 +1,148 @@ +// A router that is based on promises rather than req/res/next. +// This is intended to replace the use of express.Router to handle +// subsections of the API surface. +// This will make it easier to have methods like 'batch' that +// themselves use our routing information, without disturbing express +// components that external developers may be modifying. + +function PromiseRouter() { + // Each entry should be an object with: + // path: the path to route, in express format + // method: the HTTP method that this route handles. + // Must be one of: POST, GET, PUT, DELETE + // handler: a function that takes request, and returns a promise. + // Successful handlers should resolve to an object with fields: + // status: optional. the http status code. defaults to 200 + // response: a json object with the content of the response + // location: optional. a location header + this.routes = []; +} + +// Global flag. Set this to true to log every request and response. +PromiseRouter.verbose = process.env.VERBOSE || false; + +// Merge the routes into this one +PromiseRouter.prototype.merge = function(router) { + for (var route of router.routes) { + this.routes.push(route); + } +}; + +PromiseRouter.prototype.route = function(method, path, handler) { + switch(method) { + case 'POST': + case 'GET': + case 'PUT': + case 'DELETE': + break; + default: + throw 'cannot route method: ' + method; + } + + this.routes.push({ + path: path, + method: method, + handler: handler + }); +}; + +// Returns an object with: +// handler: the handler that should deal with this request +// params: any :-params that got parsed from the path +// Returns undefined if there is no match. +PromiseRouter.prototype.match = function(method, path) { + for (var route of this.routes) { + if (route.method != method) { + continue; + } + + // NOTE: we can only route the specific wildcards :className and + // :objectId, and in that order. + // This is pretty hacky but I don't want to rebuild the entire + // express route matcher. Maybe there's a way to reuse its logic. + var pattern = '^' + route.path + '$'; + + pattern = pattern.replace(':className', + '(_?[A-Za-z][A-Za-z_0-9]*)'); + pattern = pattern.replace(':objectId', + '([A-Za-z0-9]+)'); + var re = new RegExp(pattern); + var m = path.match(re); + if (!m) { + continue; + } + var params = {}; + if (m[1]) { + params.className = m[1]; + } + if (m[2]) { + params.objectId = m[2]; + } + + return {params: params, handler: route.handler}; + } +}; + +// A helper function to make an express handler out of a a promise +// handler. +// Express handlers should never throw; if a promise handler throws we +// just treat it like it resolved to an error. +function makeExpressHandler(promiseHandler) { + return function(req, res, next) { + try { + if (PromiseRouter.verbose) { + console.log(req.method, req.originalUrl, req.headers, + JSON.stringify(req.body, null, 2)); + } + promiseHandler(req).then((result) => { + if (!result.response) { + console.log('BUG: the handler did not include a "response" field'); + throw 'control should not get here'; + } + if (PromiseRouter.verbose) { + console.log('response:', JSON.stringify(result.response, null, 2)); + } + var status = result.status || 200; + res.status(status); + if (result.location) { + res.set('Location', result.location); + } + res.json(result.response); + }, (e) => { + if (PromiseRouter.verbose) { + console.log('error:', e); + } + next(e); + }); + } catch (e) { + if (PromiseRouter.verbose) { + console.log('error:', e); + } + next(e); + } + } +} + +// Mount the routes on this router onto an express app (or express router) +PromiseRouter.prototype.mountOnto = function(expressApp) { + for (var route of this.routes) { + switch(route.method) { + case 'POST': + expressApp.post(route.path, makeExpressHandler(route.handler)); + break; + case 'GET': + expressApp.get(route.path, makeExpressHandler(route.handler)); + break; + case 'PUT': + expressApp.put(route.path, makeExpressHandler(route.handler)); + break; + case 'DELETE': + expressApp.delete(route.path, makeExpressHandler(route.handler)); + break; + default: + throw 'unexpected code branch'; + } + } +}; + +module.exports = PromiseRouter; diff --git a/README.md b/README.md new file mode 100644 index 0000000000..9ccef178d6 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +## parse-server + +A Parse.com API compatible router package for Express + +--- + +#### Basic options: + +* databaseURI (required) - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname` +* appId (required) - The application id to host with this server instance +* masterKey (required) - The master key to use for overriding ACL security +* cloud - The absolute path to your cloud code main.js file +* fileKey - For migrated apps, this is necessary to provide access to files already hosted on Parse. + +#### 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 intialization time. Setting any of these keys will require all requests to provide one of the configured keys. + +* clientKey +* javascriptKey +* restAPIKey +* dotNetKey + +#### Advanced options: + +* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see `FilesAdapter.js`) +* databaseAdapter (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`) + +--- + +### Usage + +You can create an instance of ParseServer, and mount it on a new or existing Express website: + +``` +var express = require('express'); +var ParseServer = require('parse-server').ParseServer; + +var app = express(); + +// Specify the connection string for your mongodb database +// and the location to your Parse cloud code +var api = new ParseServer({ + databaseURI: 'mongodb://localhost:27017/dev', + cloud: '/home/myApp/cloud/main.js', // Provide an absolute path + appId: 'myAppId', + masterKey: 'mySecretMasterKey', + fileKey: 'optionalFileKey' +}); + +// Serve the Parse API on the /parse URL prefix +app.use('/parse', api); + +// Hello world +app.get('/', function(req, res) { + res.status(200).send('Express is running here.'); +}); + +var port = process.env.PORT || 1337; +app.listen(port, function() { + console.log('parse-server-example running on port ' + port + '.'); +}); + +``` + +### Supported + +* CRUD operations +* Schema validation +* Pointers +* Users, including Facebook login and anonymous users +* Files +* Installations +* Sessions +* Geopoints +* Roles +* Class-level Permissions (see below) + +Parse server does not include a web-based dashboard, which is where class-level permissions have always been configured. If you migrate an app from Parse, you'll see the format for CLPs in the SCHEMA collection. There is also a `setPermissions` method on the `Schema` class, which you can see used in the unit-tests in `Schema.spec.js` +You can also set up an app on Parse, providing the connection string for your mongo database, and continue to use the dashboard on Parse.com. + +### Not supported + +* Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community. \ No newline at end of file diff --git a/RestQuery.js b/RestQuery.js new file mode 100644 index 0000000000..f136206af4 --- /dev/null +++ b/RestQuery.js @@ -0,0 +1,555 @@ +// An object that encapsulates everything we need to run a 'find' +// operation, encoded in the REST API format. + +var Parse = require('parse/node').Parse; + +// restOptions can include: +// skip +// limit +// order +// count +// include +// keys +// redirectClassNameForKey +function RestQuery(config, auth, className, restWhere, restOptions) { + restOptions = restOptions || {}; + + this.config = config; + this.auth = auth; + this.className = className; + this.restWhere = restWhere || {}; + 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.'); + } + this.restWhere = { + '$and': [this.restWhere, { + 'user': { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id + } + }] + }; + } + } + + this.doCount = 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, + // stored as arrays, taking into account that we need to include foo + // before including foo.bar. Also it should dedupe. + // For example, passing an arg of include=foo.bar,foo.baz could lead to + // this.include = [['foo'], ['foo', 'baz'], ['foo', 'bar']] + this.include = []; + + for (var option in restOptions) { + switch(option) { + case 'keys': + this.keys = new Set(restOptions.keys.split(',')); + this.keys.add('objectId'); + this.keys.add('createdAt'); + this.keys.add('updatedAt'); + break; + case 'count': + this.doCount = true; + break; + case 'skip': + case 'limit': + this.findOptions[option] = restOptions[option]; + break; + case 'order': + var fields = restOptions.order.split(','); + var sortMap = {}; + for (var field of fields) { + if (field[0] == '-') { + sortMap[field.slice(1)] = -1; + } else { + sortMap[field] = 1; + } + } + this.findOptions.sort = sortMap; + break; + case 'include': + var paths = restOptions.include.split(','); + var pathSet = {}; + for (var path of paths) { + // Add all prefixes with a .-split to pathSet + var parts = path.split('.'); + for (var len = 1; len <= parts.length; len++) { + pathSet[parts.slice(0, len).join('.')] = true; + } + } + this.include = Object.keys(pathSet).sort((a, b) => { + return a.length - b.length; + }).map((s) => { + return s.split('.'); + }); + break; + case 'redirectClassNameForKey': + this.redirectKey = restOptions.redirectClassNameForKey; + this.redirectClassName = null; + break; + default: + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad option: ' + option); + } + } +} + +// A convenient method to perform all the steps of processing a query +// in order. +// Returns a promise for the response - an object with optional keys +// 'results' and 'count'. +// TODO: consolidate the replaceX functions +RestQuery.prototype.execute = function() { + return Promise.resolve().then(() => { + return this.getUserAndRoleACL(); + }).then(() => { + return this.redirectClassNameForKey(); + }).then(() => { + return this.replaceSelect(); + }).then(() => { + return this.replaceDontSelect(); + }).then(() => { + return this.replaceInQuery(); + }).then(() => { + return this.replaceNotInQuery(); + }).then(() => { + return this.runFind(); + }).then(() => { + return this.runCount(); + }).then(() => { + return this.handleInclude(); + }).then(() => { + return this.response; + }); +}; + +// 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) { + return Promise.resolve(); + } + return this.auth.getUserRoles().then((roles) => { + roles.push(this.auth.user.id); + this.findOptions.acl = roles; + return Promise.resolve(); + }); +}; + +// Changes the className if redirectClassNameForKey is set. +// Returns a promise. +RestQuery.prototype.redirectClassNameForKey = function() { + if (!this.redirectKey) { + return Promise.resolve(); + } + + // We need to change the class name based on the schema + return this.config.database.redirectClassNameForKey( + this.className, this.redirectKey).then((newClassName) => { + this.className = newClassName; + this.redirectClassName = newClassName; + }); +}; + +// Replaces a $inQuery clause by running the subquery, if there is an +// $inQuery clause. +// The $inQuery clause turns into an $in with values that are just +// pointers to the objects returned in the subquery. +RestQuery.prototype.replaceInQuery = function() { + var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); + if (!inQueryObject) { + return; + } + + // 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'); + } + + var subquery = new RestQuery( + this.config, this.auth, inQueryValue.className, + inQueryValue.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push({ + __type: 'Pointer', + className: inQueryValue.className, + objectId: result.objectId + }); + } + delete inQueryObject['$inQuery']; + inQueryObject['$in'] = values; + + // Recurse to repeat + return this.replaceInQuery(); + }); +}; + +// Replaces a $notInQuery clause by running the subquery, if there is an +// $notInQuery clause. +// The $notInQuery clause turns into a $nin with values that are just +// pointers to the objects returned in the subquery. +RestQuery.prototype.replaceNotInQuery = function() { + var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); + if (!notInQueryObject) { + return; + } + + // 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'); + } + + var subquery = new RestQuery( + this.config, this.auth, notInQueryValue.className, + notInQueryValue.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push({ + __type: 'Pointer', + className: notInQueryValue.className, + objectId: result.objectId + }); + } + delete notInQueryObject['$notInQuery']; + notInQueryObject['$nin'] = values; + + // Recurse to repeat + return this.replaceNotInQuery(); + }); +}; + +// Replaces a $select clause by running the subquery, if there is a +// $select clause. +// The $select clause turns into an $in with values selected out of +// the subquery. +// Returns a possible-promise. +RestQuery.prototype.replaceSelect = function() { + var selectObject = findObjectWithKey(this.restWhere, '$select'); + if (!selectObject) { + return; + } + + // The select value must have precisely two keys - query and key + var selectValue = selectObject['$select']; + if (!selectValue.query || + !selectValue.key || + typeof selectValue.query !== 'object' || + !selectValue.query.className || + !selectValue.query.where || + Object.keys(selectValue).length !== 2) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'improper usage of $select'); + } + + var subquery = new RestQuery( + this.config, this.auth, selectValue.query.className, + selectValue.query.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push(result[selectValue.key]); + } + delete selectObject['$select']; + selectObject['$in'] = values; + + // Keep replacing $select clauses + return this.replaceSelect(); + }) +}; + +// Replaces a $dontSelect clause by running the subquery, if there is a +// $dontSelect clause. +// The $dontSelect clause turns into an $nin with values selected out of +// the subquery. +// Returns a possible-promise. +RestQuery.prototype.replaceDontSelect = function() { + var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); + if (!dontSelectObject) { + return; + } + + // 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 || + !dontSelectValue.query.where || + Object.keys(dontSelectValue).length !== 2) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'improper usage of $dontSelect'); + } + + var subquery = new RestQuery( + this.config, this.auth, dontSelectValue.query.className, + dontSelectValue.query.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push(result[dontSelectValue.key]); + } + delete dontSelectObject['$dontSelect']; + dontSelectObject['$nin'] = values; + + // Keep replacing $dontSelect clauses + return this.replaceDontSelect(); + }) +}; + +// Returns a promise for whether it was successful. +// Populates this.response with an object that only has 'results'. +RestQuery.prototype.runFind = function() { + return this.config.database.find( + this.className, this.restWhere, this.findOptions).then((results) => { + if (this.className == '_User') { + for (var result of results) { + delete result.password; + } + } + + updateParseFiles(this.config, results); + + if (this.keys) { + var keySet = this.keys; + results = results.map((object) => { + var newObject = {}; + for (var key in object) { + if (keySet.has(key)) { + newObject[key] = object[key]; + } + } + return newObject; + }); + } + + if (this.redirectClassName) { + for (var r of results) { + r.className = this.redirectClassName; + } + } + + this.response = {results: results}; + }); +}; + +// Returns a promise for whether it was successful. +// Populates this.response.count with the count +RestQuery.prototype.runCount = function() { + if (!this.doCount) { + return; + } + this.findOptions.count = true; + delete this.findOptions.skip; + return this.config.database.find( + this.className, this.restWhere, this.findOptions).then((c) => { + this.response.count = c; + }); +}; + +// Augments this.response with data at the paths provided in this.include. +RestQuery.prototype.handleInclude = function() { + if (this.include.length == 0) { + return; + } + + var pathResponse = includePath(this.config, this.auth, + this.response, this.include[0]); + if (pathResponse.then) { + return pathResponse.then((newResponse) => { + this.response = newResponse; + this.include = this.include.slice(1); + return this.handleInclude(); + }); + } + return pathResponse; +}; + +// Adds included values to the response. +// Path is a list of field names. +// Returns a promise for an augmented response. +function includePath(config, auth, response, path) { + var pointers = findPointers(response.results, path); + if (pointers.length == 0) { + return response; + } + var className = null; + var objectIds = {}; + for (var pointer of pointers) { + if (className === null) { + className = pointer.className; + } else { + if (className != pointer.className) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'inconsistent type data for include'); + } + } + objectIds[pointer.objectId] = true; + } + if (!className) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad pointers'); + } + + // Get the objects for all these object ids + var where = {'objectId': {'$in': Object.keys(objectIds)}}; + var query = new RestQuery(config, auth, className, where); + return query.execute().then((includeResponse) => { + var replace = {}; + for (var obj of includeResponse.results) { + obj.__type = 'Object'; + obj.className = className; + replace[obj.objectId] = obj; + } + var resp = { + results: replacePointers(response.results, path, replace) + }; + if (response.count) { + resp.count = response.count; + } + return resp; + }); +} + +// Object may be a list of REST-format object to find pointers in, or +// it may be a single object. +// If the path yields things that aren't pointers, this throws an error. +// Path is a list of fields to search into. +// Returns a list of pointers in REST format. +function findPointers(object, path) { + if (object instanceof Array) { + var answer = []; + for (x of object) { + answer = answer.concat(findPointers(x, path)); + } + return answer; + } + + if (typeof object !== 'object') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'can only include pointer fields'); + } + + if (path.length == 0) { + if (object.__type == 'Pointer') { + return [object]; + } + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'can only include pointer fields'); + } + + var subobject = object[path[0]]; + if (!subobject) { + return []; + } + return findPointers(subobject, path.slice(1)); +} + +// Object may be a list of REST-format objects to replace pointers +// in, or it may be a single object. +// Path is a list of fields to search into. +// replace is a map from object id -> object. +// Returns something analogous to object, but with the appropriate +// pointers inflated. +function replacePointers(object, path, replace) { + if (object instanceof Array) { + return object.map((obj) => replacePointers(obj, path, replace)); + } + + if (typeof object !== 'object') { + return object; + } + + if (path.length == 0) { + if (object.__type == 'Pointer' && replace[object.objectId]) { + return replace[object.objectId]; + } + return object; + } + + var subobject = object[path[0]]; + if (!subobject) { + return object; + } + var newsub = replacePointers(subobject, path.slice(1), replace); + var answer = {}; + for (var key in object) { + if (key == path[0]) { + answer[key] = newsub; + } else { + answer[key] = object[key]; + } + } + return answer; +} + +// Find file references in REST-format object and adds the url key +// with the current mount point and app id +// Object may be a single object or list of REST-format objects +function updateParseFiles(config, object) { + if (object instanceof Array) { + object.map((obj) => updateParseFiles(config, obj)); + return; + } + if (typeof object !== 'object') { + return; + } + for (var key in object) { + if (object[key] && object[key]['__type'] && + object[key]['__type'] == 'File') { + var filename = object[key]['name']; + var encoded = encodeURIComponent(filename); + encoded = encoded.replace('%40', '@'); + if (filename.indexOf('tfss-') === 0) { + object[key]['url'] = 'http://files.parsetfss.com/' + + config.fileKey + '/' + encoded; + } else { + object[key]['url'] = config.mount + '/files/' + + config.applicationId + '/' + + encoded; + } + } + } +} + +// Finds a subobject that has the given key, if there is one. +// Returns undefined otherwise. +function findObjectWithKey(root, key) { + if (typeof root !== 'object') { + return; + } + if (root instanceof Array) { + for (var item of root) { + var answer = findObjectWithKey(item, key); + if (answer) { + return answer; + } + } + } + if (root && root[key]) { + return root; + } + for (var subkey in root) { + var answer = findObjectWithKey(root[subkey], key); + if (answer) { + return answer; + } + } +} + +module.exports = RestQuery; diff --git a/RestWrite.js b/RestWrite.js new file mode 100644 index 0000000000..6e63a324a3 --- /dev/null +++ b/RestWrite.js @@ -0,0 +1,718 @@ +// A RestWrite encapsulates everything we need to run an operation +// that writes to the database. +// This could be either a "create" or an "update". + +var deepcopy = require('deepcopy'); +var rack = require('hat').rack(); + +var Auth = require('./Auth'); +var cache = require('./cache'); +var Config = require('./Config'); +var crypto = require('./crypto'); +var facebook = require('./facebook'); +var Parse = require('parse/node'); +var triggers = require('./triggers'); + +// query and data are both provided in REST API format. So data +// types are encoded by plain old objects. +// If query is null, this is a "create" and the data in data should be +// created. +// Otherwise this is an "update" - the object matching the query +// should get updated with data. +// 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) { + this.config = config; + this.auth = auth; + this.className = className; + this.storage = {}; + + if (!query && data.objectId) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + + 'is an invalid field name.'); + } + + // When the operation is complete, this.response may have several + // fields. + // response: the actual data to be returned + // status: the http status code. if not present, treated like a 200 + // location: the location header. if not present, no location header + this.response = null; + + // Processing this operation may mutate our data, so we operate on a + // copy + this.query = deepcopy(query); + this.data = deepcopy(data); + // We never change originalData, so we do not need a deep copy + this.originalData = originalData; + + // The timestamp we'll use for this whole operation + this.updatedAt = Parse._encode(new Date()).iso; + + if (this.data) { + // Add default fields + this.data.updatedAt = this.updatedAt; + if (!this.query) { + this.data.createdAt = this.updatedAt; + this.data.objectId = newObjectId(); + } + } +} + +// A convenient method to perform all the steps of processing the +// write, in order. +// Returns a promise for a {response, status, location} object. +// status and location are optional. +RestWrite.prototype.execute = function() { + return Promise.resolve().then(() => { + return this.validateSchema(); + }).then(() => { + return this.handleInstallation(); + }).then(() => { + return this.handleSession(); + }).then(() => { + return this.runBeforeTrigger(); + }).then(() => { + return this.validateAuthData(); + }).then(() => { + return this.transformUser(); + }).then(() => { + return this.runDatabaseOperation(); + }).then(() => { + return this.handleFollowup(); + }).then(() => { + return this.runAfterTrigger(); + }).then(() => { + return this.response; + }); +}; + +// Validates this operation against the schema. +RestWrite.prototype.validateSchema = function() { + return this.config.database.validateObject(this.className, this.data); +}; + +// Runs any beforeSave triggers against this operation. +// Any change leads to our data being mutated. +RestWrite.prototype.runBeforeTrigger = function() { + // Cloud code gets a bit of extra data for its objects + var extraData = {className: this.className}; + if (this.query && this.query.objectId) { + extraData.objectId = this.query.objectId; + } + // Build the inflated object, for a create write, originalData is empty + var inflatedObject = triggers.inflate(extraData, this.originalData);; + inflatedObject._finishFetch(this.data); + // Build the original object, we only do this for a update write + var originalObject; + if (this.query && this.query.objectId) { + originalObject = triggers.inflate(extraData, this.originalData); + } + + return Promise.resolve().then(() => { + return triggers.maybeRunTrigger( + 'beforeSave', this.auth, inflatedObject, originalObject); + }).then((response) => { + if (response && response.object) { + this.data = response.object; + // We should delete the objectId for an update write + if (this.query && this.query.objectId) { + delete this.data.objectId + } + } + }); +}; + +// Transforms auth data for a user object. +// Does nothing if this isn't a user object. +// Returns a promise for when we're done if it can't finish this tick. +RestWrite.prototype.validateAuthData = function() { + if (this.className !== '_User') { + return; + } + + if (!this.query && !this.data.authData) { + if (typeof this.data.username !== 'string') { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, + 'bad or missing username'); + } + if (typeof this.data.password !== 'string') { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, + 'password is required'); + } + } + + if (!this.data.authData) { + return; + } + + var facebookData = this.data.authData.facebook; + var anonData = this.data.authData.anonymous; + + if (anonData === null || + (anonData && anonData.id)) { + return this.handleAnonymousAuthData(); + } else if (facebookData === null || + (facebookData && facebookData.id && facebookData.access_token)) { + return this.handleFacebookAuthData(); + } else { + throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.'); + } +}; + +RestWrite.prototype.handleAnonymousAuthData = function() { + var anonData = this.data.authData.anonymous; + if (anonData === null && this.query) { + // We are unlinking the user from the anonymous provider + this.data._auth_data_anonymous = null; + return; + } + + // Check if this user already exists + return this.config.database.find( + this.className, + {'authData.anonymous.id': anonData.id}, {}) + .then((results) => { + if (results.length > 0) { + if (!this.query) { + // We're signing up, but this user already exists. Short-circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + return; + } + + // If this is a PUT for the same user, allow the linking + if (results[0].objectId === this.query.objectId) { + // Delete the rest format key before saving + delete this.data.authData; + return; + } + + // We're trying to create a duplicate account. Forbid it + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); + } + + // This anonymous user does not already exist, so transform it + // to a saveable format + this.data._auth_data_anonymous = anonData; + + // Delete the rest format key before saving + delete this.data.authData; + }) + +}; + +RestWrite.prototype.handleFacebookAuthData = function() { + var facebookData = this.data.authData.facebook; + if (facebookData === null && this.query) { + // We are unlinking from Facebook. + this.data._auth_data_facebook = null; + return; + } + + return facebook.validateUserId(facebookData.id, + facebookData.access_token) + .then(() => { + return facebook.validateAppId(process.env.FACEBOOK_APP_ID, + facebookData.access_token); + }).then(() => { + // Check if this user already exists + // TODO: does this handle re-linking correctly? + return this.config.database.find( + this.className, + {'authData.facebook.id': facebookData.id}, {}); + }).then((results) => { + if (results.length > 0) { + if (!this.query) { + // We're signing up, but this user already exists. Short-circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + return; + } + + // If this is a PUT for the same user, allow the linking + if (results[0].objectId === this.query.objectId) { + // Delete the rest format key before saving + delete this.data.authData; + return; + } + // We're trying to create a duplicate FB auth. Forbid it + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); + } + + // This FB auth does not already exist, so transform it to a + // saveable format + this.data._auth_data_facebook = facebookData; + + // Delete the rest format key before saving + delete this.data.authData; + }); +}; + +// The non-third-party parts of User transformation +RestWrite.prototype.transformUser = function() { + if (this.response || this.className !== '_User') { + return; + } + + var promise = Promise.resolve(); + + if (!this.query) { + var token = 'r:' + rack(); + this.storage['token'] = token; + promise = promise.then(() => { + // TODO: Proper createdWith options, pass installationId + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId() + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false, + expiresAt: 0 + }; + var create = new RestWrite(this.config, Auth.master(this.config), + '_Session', null, sessionData); + return create.execute(); + }); + } + + return promise.then(() => { + // Transform the password + if (!this.data.password) { + return; + } + if (this.query) { + this.storage['clearSessions'] = true; + } + return crypto.hash(this.data.password).then((hashedPassword) => { + this.data._hashed_password = hashedPassword; + delete this.data.password; + }); + + }).then(() => { + // Check for username uniqueness + if (!this.data.username) { + if (!this.query) { + // TODO: what's correct behavior here + this.data.username = ''; + } + return; + } + 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 Promise.resolve(); + }); + }).then(() => { + if (!this.data.email) { + return; + } + // Validate basic email address format + if (!this.data.email.match(/^.+@.+$/)) { + throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, + 'Email address format is invalid.'); + } + // Check for email uniqueness + 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'); + } + return Promise.resolve(); + }); + }); +}; + +// Handles any followup logic +RestWrite.prototype.handleFollowup = function() { + if (this.storage && this.storage['clearSessions']) { + var sessionQuery = { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId() + } + }; + delete this.storage['clearSessions']; + return this.config.database.destroy('_Session', sessionQuery) + .then(this.handleFollowup); + } +}; + +// Handles the _Role class specialness. +// Does nothing if this isn't a role object. +RestWrite.prototype.handleRole = function() { + if (this.response || this.className !== '_Role') { + return; + } + + if (!this.auth.user && !this.auth.isMaster) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + + if (!this.data.name) { + throw new Parse.Error(Parse.Error.INVALID_ROLE_NAME, + 'Invalid role name.'); + } +}; + +// Handles the _Session class specialness. +// Does nothing if this isn't an installation object. +RestWrite.prototype.handleSession = function() { + if (this.response || this.className !== '_Session') { + return; + } + + if (!this.auth.user && !this.auth.isMaster) { + 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.'); + } + + if (!this.query && !this.auth.isMaster) { + var token = 'r:' + rack(); + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id + }, + createdWith: { + 'action': 'create' + }, + restricted: true, + expiresAt: 0 + }; + for (var key in this.data) { + if (key == 'objectId') { + continue; + } + sessionData[key] = this.data[key]; + } + var create = new RestWrite(this.config, Auth.master(this.config), + '_Session', null, sessionData); + return create.execute().then((results) => { + if (!results.response) { + 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 + }; + }); + } +}; + +// Handles the _Installation class specialness. +// Does nothing if this isn't an installation object. +// If an installation is found, this can mutate this.query and turn a create +// into an update. +// Returns a promise for when we're done if it can't finish this tick. +RestWrite.prototype.handleInstallation = function() { + if (this.response || this.className !== '_Installation') { + return; + } + + if (!this.query && !this.data.deviceToken && !this.data.installationId) { + throw new Parse.Error(135, + 'at least one ID field (deviceToken, installationId) ' + + 'must be specified in this operation'); + } + + if (!this.query && !this.data.deviceType) { + throw new Parse.Error(135, + 'deviceType must be specified in this operation'); + } + + // If the device token is 64 characters long, we assume it is for iOS + // and lowercase it. + if (this.data.deviceToken && this.data.deviceToken.length == 64) { + this.data.deviceToken = this.data.deviceToken.toLowerCase(); + } + + // TODO: We may need installationId from headers, plumb through Auth? + // per installation_handler.go + + // We lowercase the installationId if present + if (this.data.installationId) { + this.data.installationId = this.data.installationId.toLowerCase(); + } + + if (this.data.deviceToken && this.data.deviceType == 'android') { + throw new Parse.Error(114, + 'deviceToken may not be set for deviceType android'); + } + + var promise = Promise.resolve(); + + if (this.query && this.query.objectId) { + promise = promise.then(() => { + return this.config.database.find('_Installation', { + objectId: this.query.objectId + }, {}).then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found for update.'); + } + var existing = results[0]; + if (this.data.installationId && existing.installationId && + this.data.installationId !== existing.installationId) { + throw new Parse.Error(136, + 'installationId may not be changed in this ' + + 'operation'); + } + if (this.data.deviceToken && existing.deviceToken && + this.data.deviceToken !== existing.deviceToken && + !this.data.installationId && !existing.installationId) { + throw new Parse.Error(136, + 'deviceToken may not be changed in this ' + + 'operation'); + } + if (this.data.deviceType && this.data.deviceType && + this.data.deviceType !== existing.deviceType) { + throw new Parse.Error(136, + 'deviceType may not be changed in this ' + + 'operation'); + } + return Promise.resolve(); + }); + }); + } + + // Check if we already have installations for the installationId/deviceToken + var installationMatch; + var deviceTokenMatches = []; + promise = promise.then(() => { + if (this.data.installationId) { + return this.config.database.find('_Installation', { + 'installationId': this.data.installationId + }); + } + return Promise.resolve([]); + }).then((results) => { + if (results && results.length) { + // We only take the first match by installationId + installationMatch = results[0]; + } + if (this.data.deviceToken) { + return this.config.database.find( + '_Installation', + {'deviceToken': this.data.deviceToken}); + } + return Promise.resolve([]); + }).then((results) => { + if (results) { + deviceTokenMatches = results; + } + if (!installationMatch) { + if (!deviceTokenMatches.length) { + return; + } else if (deviceTokenMatches.length == 1 && + (!deviceTokenMatches[0]['installationId'] || !this.data.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': this.data.installationId + } + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + this.config.database.destroy('_Installation', delQuery); + 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. + var delQuery = {objectId: installationMatch.objectId}; + return this.config.database.destroy('_Installation', delQuery) + .then(() => { + return deviceTokenMatches[0]['objectId']; + }); + } else { + if (this.data.deviceToken && + installationMatch.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. + var delQuery = { + 'deviceToken': this.data.deviceToken, + 'installationId': { + '$ne': this.data.installationId + } + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + this.config.database.destroy('_Installation', delQuery); + } + // In non-merge scenarios, just return the installation match id + return installationMatch.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.) + }); + return promise; +}; + +RestWrite.prototype.runDatabaseOperation = function() { + if (this.response) { + return; + } + + if (this.className === '_User' && + this.query && + !this.auth.couldUpdateUserId(this.query.objectId)) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'cannot modify user ' + this.objectId); + } + + // TODO: Add better detection for ACL, ensuring a user can't be locked from + // their own user record. + if (this.data.ACL && this.data.ACL['*unresolved']) { + throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); + } + + var options = {}; + if (!this.auth.isMaster) { + options.acl = ['*']; + if (this.auth.user) { + options.acl.push(this.auth.user.id); + } + } + + if (this.query) { + // Run an update + return this.config.database.update( + this.className, this.query, this.data, options).then((resp) => { + this.response = resp; + this.response.updatedAt = this.updatedAt; + }); + } else { + // Run a create + return this.config.database.create(this.className, this.data, options) + .then(() => { + var resp = { + objectId: this.data.objectId, + createdAt: this.data.createdAt + }; + if (this.storage['token']) { + resp.sessionToken = this.storage['token']; + } + this.response = { + status: 201, + response: resp, + location: this.location() + }; + }); + } +}; + +// Returns nothing - doesn't wait for the trigger. +RestWrite.prototype.runAfterTrigger = function() { + var extraData = {className: this.className}; + if (this.query && this.query.objectId) { + extraData.objectId = this.query.objectId; + } + + // Build the inflated object, different from beforeSave, originalData is not empty + // since developers can change data in the beforeSave. + var inflatedObject = triggers.inflate(extraData, this.originalData); + inflatedObject._finishFetch(this.data); + // Build the original object, we only do this for a update write. + var originalObject; + if (this.query && this.query.objectId) { + originalObject = triggers.inflate(extraData, this.originalData); + } + + triggers.maybeRunTrigger('afterSave', this.auth, inflatedObject, originalObject); +}; + +// A helper to figure out what location this operation happens at. +RestWrite.prototype.location = function() { + var middle = (this.className === '_User' ? '/users/' : + '/classes/' + this.className + '/'); + return this.config.mount + middle + this.data.objectId; +}; + +// A helper to get the object id for this operation. +// Because it could be either on the query or on the data +RestWrite.prototype.objectId = function() { + return this.data.objectId || this.query.objectId; +}; + +// Returns a string that's usable as an object id. +// Probably unique. Good enough? Probably! +function newObjectId() { + var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + 'abcdefghijklmnopqrstuvwxyz' + + '0123456789'); + var objectId = ''; + for (var i = 0; i < 10; ++i) { + objectId += chars[Math.floor(Math.random() * chars.length)]; + } + return objectId; +} + +module.exports = RestWrite; diff --git a/Schema.js b/Schema.js new file mode 100644 index 0000000000..bf093fb342 --- /dev/null +++ b/Schema.js @@ -0,0 +1,347 @@ +// This class handles schema validation, persistence, and modification. +// +// Each individual Schema object should be immutable. The helpers to +// do things with the Schema just return a new schema when the schema +// is changed. +// +// The canonical place to store this Schema is in the database itself, +// in a _SCHEMA collection. This is not the right way to do it for an +// open source framework, but it's backward compatible, so we're +// keeping it this way for now. +// +// In API-handling code, you should only use the Schema class via the +// ExportAdapter. This will let us replace the schema logic for +// different databases. +// TODO: hide all schema logic inside the database adapter. + +var Parse = require('parse/node').Parse; +var transform = require('./transform'); + + +// Create a schema from a Mongo collection and the exported schema format. +// mongoSchema should be a list of objects, each with: +// '_id' indicates the className +// '_metadata' is ignored for now +// Everything else is expected to be a userspace field. +function Schema(collection, mongoSchema) { + this.collection = collection; + + // this.data[className][fieldName] tells you the type of that field + this.data = {}; + // this.perms[className][operation] tells you the acl-style permissions + this.perms = {}; + + for (var obj of mongoSchema) { + var className = null; + var classData = {}; + var permsData = null; + for (var key in obj) { + var value = obj[key]; + switch(key) { + case '_id': + className = value; + break; + case '_metadata': + if (value && value['class_permissions']) { + permsData = value['class_permissions']; + } + break; + default: + classData[key] = value; + } + } + if (className) { + this.data[className] = classData; + if (permsData) { + this.perms[className] = permsData; + } + } + } +} + +// Returns a promise for a new Schema. +function load(collection) { + return collection.find({}, {}).toArray().then((mongoSchema) => { + return new Schema(collection, mongoSchema); + }); +} + +// Returns a new, reloaded schema. +Schema.prototype.reload = function() { + return load(this.collection); +}; + +// Returns a promise that resolves successfully to the new schema +// object. +// If 'freeze' is true, refuse to update the schema. +Schema.prototype.validateClassName = function(className, freeze) { + if (this.data[className]) { + return Promise.resolve(this); + } + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema is frozen, cannot add: ' + className); + } + // We don't have this class. Update the schema + return this.collection.insert([{_id: className}]).then(() => { + // The schema update succeeded. Reload the schema + return this.reload(); + }, () => { + // The schema update failed. This can be okay - it might + // have failed because there's a race condition and a different + // client is making the exact same schema update that we want. + // So just reload the schema. + return this.reload(); + }).then((schema) => { + // Ensure that the schema now validates + return schema.validateClassName(className, true); + }, (error) => { + // The schema still doesn't validate. Give up + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema class name does not revalidate'); + }); +}; + +// Returns whether the schema knows the type of all these keys. +Schema.prototype.hasKeys = function(className, keys) { + for (var key of keys) { + if (!this.data[className] || !this.data[className][key]) { + return false; + } + } + return true; +}; + +// Sets the Class-level permissions for a given className, which must +// exist. +Schema.prototype.setPermissions = function(className, perms) { + var query = {_id: className}; + var update = { + _metadata: { + class_permissions: perms + } + }; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { + // The update succeeded. Reload the schema + return this.reload(); + }); +}; + +// Returns a promise that resolves successfully to the new schema +// object if the provided className-key-type tuple is valid. +// The className must already be validated. +// If 'freeze' is true, refuse to update the schema for this field. +Schema.prototype.validateField = function(className, key, type, freeze) { + // Just to check that the key is valid + transform.transformKey(this, className, key); + + var expected = this.data[className][key]; + if (expected) { + if (expected === type) { + return Promise.resolve(this); + } else { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'schema mismatch for ' + className + '.' + key + + '; expected ' + expected + ' but got ' + type); + } + } + + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema is frozen, cannot add ' + key + ' field'); + } + + // We don't have this field, but if the value is null or undefined, + // we won't update the schema until we get a value with a type. + if (!type) { + return Promise.resolve(this); + } + + if (type === 'geopoint') { + // Make sure there are not other geopoint fields + for (var otherKey in this.data[className]) { + if (this.data[className][otherKey] === 'geopoint') { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class'); + } + } + } + + // We don't have this field. Update the schema. + // Note that we use the $exists guard and $set to avoid race + // conditions in the database. This is important! + var query = {_id: className}; + query[key] = {'$exists': false}; + var update = {}; + update[key] = type; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { + // The update succeeded. Reload the schema + return this.reload(); + }, () => { + // The update failed. This can be okay - it might have been a race + // condition where another client updated the schema in the same + // way that we wanted to. So, just reload the schema + return this.reload(); + }).then((schema) => { + // Ensure that the schema now validates + return schema.validateField(className, key, type, true); + }, (error) => { + // The schema still doesn't validate. Give up + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'schema key will not revalidate'); + }); +}; + +// Given a schema promise, construct another schema promise that +// validates this field once the schema loads. +function thenValidateField(schemaPromise, className, key, type) { + return schemaPromise.then((schema) => { + return schema.validateField(className, key, type); + }); +} + +// Validates an object provided in REST format. +// Returns a promise that resolves to the new schema if this object is +// valid. +Schema.prototype.validateObject = function(className, object) { + var geocount = 0; + var promise = this.validateClassName(className); + for (var key in object) { + var expected = getType(object[key]); + if (expected === 'geopoint') { + geocount++; + } + if (geocount > 1) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class'); + } + if (!expected) { + continue; + } + promise = thenValidateField(promise, className, key, expected); + } + return promise; +}; + +// Validates an operation passes class-level-permissions set in the schema +Schema.prototype.validatePermission = function(className, aclGroup, operation) { + if (!this.perms[className] || !this.perms[className][operation]) { + return Promise.resolve(); + } + var perms = this.perms[className][operation]; + // Handle the public scenario quickly + if (perms['*']) { + return Promise.resolve(); + } + // Check permissions against the aclGroup provided (array of userId/roles) + var found = false; + for (var i = 0; i < aclGroup.length && !found; i++) { + if (perms[aclGroup[i]]) { + found = true; + } + } + if (!found) { + // TODO: Verify correct error code + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied for this action.'); + } +}; + +// Returns the expected type for a className+key combination +// or undefined if the schema is not set +Schema.prototype.getExpectedType = function(className, key) { + if (this.data && this.data[className]) { + return this.data[className][key]; + } + return undefined; +}; + +// Helper function to check if a field is a pointer, returns true or false. +Schema.prototype.isPointer = function(className, key) { + var expected = this.getExpectedType(className, key); + if (expected && expected.charAt(0) == '*') { + return true; + } + return false; +}; + +// Gets the type from a REST API formatted object, where 'type' is +// extended past javascript types to include the rest of the Parse +// type system. +// The output should be a valid schema value. +// TODO: ensure that this is compatible with the format used in Open DB +function getType(obj) { + var type = typeof obj; + switch(type) { + case 'boolean': + case 'string': + case 'number': + return type; + 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) { + if (obj instanceof Array) { + return 'array'; + } + if (obj.__type === 'Pointer' && obj.className) { + return '*' + obj.className; + } + if (obj.__type === 'File' && obj.url && obj.name) { + return 'file'; + } + if (obj.__type === 'Date' && obj.iso) { + return 'date'; + } + if (obj.__type == 'GeoPoint' && + obj.latitude != null && + obj.longitude != null) { + return 'geopoint'; + } + 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 'relation<' + obj.objects[0].className + '>'; + case 'Batch': + return getObjectType(obj.ops[0]); + default: + throw 'unexpected op: ' + obj.__op; + } + } + return 'object'; +} + + +module.exports = { + load: load +}; diff --git a/analytics.js b/analytics.js new file mode 100644 index 0000000000..7294837f6a --- /dev/null +++ b/analytics.js @@ -0,0 +1,20 @@ +// analytics.js + +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + + +// Returns a promise that resolves to an empty object response +function ignoreAndSucceed(req) { + return Promise.resolve({ + response: {} + }); +} + +router.route('POST','/events/AppOpened', ignoreAndSucceed); +router.route('POST','/events/:eventName', ignoreAndSucceed); + +module.exports = router; \ No newline at end of file diff --git a/batch.js b/batch.js new file mode 100644 index 0000000000..4b710a1721 --- /dev/null +++ b/batch.js @@ -0,0 +1,72 @@ +var Parse = require('parse/node').Parse; + +// These methods handle batch requests. +var batchPath = '/batch'; + +// Mounts a batch-handler onto a PromiseRouter. +function mountOnto(router) { + router.route('POST', batchPath, (req) => { + return handleBatch(router, req); + }); +} + +// Returns a promise for a {response} object. +// TODO: pass along auth correctly +function handleBatch(router, req) { + if (!req.body.requests instanceof 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. + // That means they include the API prefix, that the API is mounted + // to. However, our promise router does not route the api prefix. So + // we need to figure out the API prefix, so that we can strip it + // from all the subrequests. + if (!req.originalUrl.endsWith(batchPath)) { + throw 'internal routing problem - expected url to end with batch'; + } + var apiPrefixLength = req.originalUrl.length - batchPath.length; + var apiPrefix = req.originalUrl.slice(0, apiPrefixLength); + + var promises = []; + for (var restRequest of req.body.requests) { + // The routablePath is the path minus the api prefix + if (restRequest.path.slice(0, apiPrefixLength) != apiPrefix) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'cannot route batch path ' + restRequest.path); + } + var routablePath = restRequest.path.slice(apiPrefixLength); + + // Use the router to figure out what handler to use + var match = router.match(restRequest.method, routablePath); + if (!match) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'cannot route ' + restRequest.method + ' ' + routablePath); + } + + // Construct a request that we can send to a handler + var request = { + body: restRequest.body, + params: match.params, + config: req.config, + auth: req.auth + }; + + promises.push(match.handler(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}; + }); +} + +module.exports = { + mountOnto: mountOnto +}; diff --git a/cache.js b/cache.js new file mode 100644 index 0000000000..aba6ce16ff --- /dev/null +++ b/cache.js @@ -0,0 +1,37 @@ +var apps = {}; +var stats = {}; +var isLoaded = false; +var users = {}; + +function getApp(app, callback) { + if (apps[app]) return callback(true, apps[app]); + return callback(false); +} + +function updateStat(key, value) { + stats[key] = value; +} + +function getUser(sessionToken) { + if (users[sessionToken]) return users[sessionToken]; + return undefined; +} + +function setUser(sessionToken, userObject) { + users[sessionToken] = userObject; +} + +function clearUser(sessionToken) { + delete users[sessionToken]; +} + +module.exports = { + apps: apps, + stats: stats, + isLoaded: isLoaded, + getApp: getApp, + updateStat: updateStat, + clearUser: clearUser, + getUser: getUser, + setUser: setUser +}; diff --git a/classes.js b/classes.js new file mode 100644 index 0000000000..d520250b02 --- /dev/null +++ b/classes.js @@ -0,0 +1,88 @@ +// These methods handle the 'classes' routes. +// Methods of the form 'handleX' return promises and are intended to +// be used with the PromiseRouter. + +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +// Returns a promise that resolves to a {response} object. +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (typeof req.body.keys == 'string') { + options.keys = req.body.keys; + } + if (req.body.include) { + options.include = String(req.body.include); + } + if (req.body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); + } + + return rest.find(req.config, req.auth, + req.params.className, req.body.where, options) + .then((response) => { + return {response: response}; + }); +} + +// Returns a promise for a {status, response, location} object. +function handleCreate(req) { + return rest.create(req.config, req.auth, + req.params.className, req.body); +} + +// Returns a promise for a {response} object. +function handleGet(req) { + return rest.find(req.config, req.auth, + req.params.className, {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +// Returns a promise for a {response} object. +function handleDelete(req) { + return rest.del(req.config, req.auth, + req.params.className, req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +// Returns a promise for a {response} object. +function handleUpdate(req) { + return rest.update(req.config, req.auth, + req.params.className, req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +router.route('GET', '/classes/:className', handleFind); +router.route('POST', '/classes/:className', handleCreate); +router.route('GET', '/classes/:className/:objectId', handleGet); +router.route('DELETE', '/classes/:className/:objectId', handleDelete); +router.route('PUT', '/classes/:className/:objectId', handleUpdate); + +module.exports = router; + diff --git a/cloud/main.js b/cloud/main.js new file mode 100644 index 0000000000..eaf3cf955a --- /dev/null +++ b/cloud/main.js @@ -0,0 +1,80 @@ +var Parse = require('parse/node').Parse; + +Parse.Cloud.define('hello', function(req, res) { + res.success('Hello world!'); +}); + +Parse.Cloud.beforeSave('BeforeSaveFailure', function(req, res) { + res.error('You shall not pass!'); +}); + +Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) { + res.success(); +}); + +Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + req.object.set('foo', 'baz'); + res.success(); +}); + +Parse.Cloud.afterSave('AfterSaveTest', function(req) { + var obj = new Parse.Object('AfterSaveProof'); + obj.set('proof', req.object.id); + obj.save(); +}); + +Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) { + res.error('Nope'); +}); + +Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) { + res.success(); +}); + +Parse.Cloud.afterDelete('AfterDeleteTest', function(req) { + var obj = new Parse.Object('AfterDeleteProof'); + obj.set('proof', req.object.id); + obj.save(); +}); + +Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) { + if (req.user && req.user.id) { + res.success(); + } else { + res.error('No user present on request object for beforeSave.'); + } +}); + +Parse.Cloud.afterSave('SaveTriggerUser', function(req) { + if (!req.user || !req.user.id) { + console.log('No user present on request object for afterSave.'); + } +}); + +Parse.Cloud.define('foo', function(req, res) { + res.success({ + object: { + __type: 'Object', + className: 'Foo', + objectId: '123', + x: 2, + relation: { + __type: 'Object', + className: 'Bar', + objectId: '234', + x: 3 + } + }, + array: [{ + __type: 'Object', + className: 'Bar', + objectId: '345', + x: 2 + }], + a: 2 + }); +}); + +Parse.Cloud.define('bar', function(req, res) { + res.error('baz'); +}); diff --git a/crypto.js b/crypto.js new file mode 100644 index 0000000000..fdbcdf9ab7 --- /dev/null +++ b/crypto.js @@ -0,0 +1,35 @@ +// Tools for encrypting and decrypting passwords. +// Basically promise-friendly wrappers for bcrypt. +var bcrypt = require('bcrypt'); + +// Returns a promise for a hashed password string. +function hash(password) { + return new Promise(function(fulfill, reject) { + bcrypt.hash(password, 8, function(err, hashedPassword) { + if (err) { + reject(err); + } else { + fulfill(hashedPassword); + } + }); + }); +} + +// Returns a promise for whether this password compares to equal this +// hashed password. +function compare(password, hashedPassword) { + return new Promise(function(fulfill, reject) { + bcrypt.compare(password, hashedPassword, function(err, success) { + if (err) { + reject(err); + } else { + fulfill(success); + } + }); + }); +} + +module.exports = { + hash: hash, + compare: compare +}; diff --git a/facebook.js b/facebook.js new file mode 100644 index 0000000000..23dc45b3ae --- /dev/null +++ b/facebook.js @@ -0,0 +1,52 @@ +// Helper functions for accessing the Facebook Graph API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateUserId(userId, access_token) { + return graphRequest('me?fields=id&access_token=' + access_token) + .then((data) => { + if (data && data.id == userId) { + 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(appId, access_token) { + return graphRequest('app?access_token=' + access_token) + .then((data) => { + if (data && data.id == appId) { + 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() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Facebook.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateUserId: validateUserId +}; diff --git a/files.js b/files.js new file mode 100644 index 0000000000..965923234b --- /dev/null +++ b/files.js @@ -0,0 +1,89 @@ +// files.js + +var bodyParser = require('body-parser'), + Config = require('./Config'), + express = require('express'), + FilesAdapter = require('./FilesAdapter'), + middlewares = require('./middlewares.js'), + mime = require('mime'), + Parse = require('parse/node').Parse, + path = require('path'), + rack = require('hat').rack(); + +var router = express.Router(); + +var processCreate = function(req, res, next) { + if (!req.body || !req.body.length) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Invalid file upload.')); + return; + } + + if (req.params.filename.length > 128) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename too long.')); + 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.')); + return; + } + + // If a content-type is included, we'll add an extension so we can + // return the same content-type. + var extension = ''; + var hasExtension = req.params.filename.indexOf('.') > 0; + var contentType = req.get('Content-type'); + if (!hasExtension && contentType && mime.extension(contentType)) { + extension = '.' + mime.extension(contentType); + } + + var filename = rack() + '_' + req.params.filename + extension; + FilesAdapter.getAdapter().create(req.config, filename, req.body) + .then(() => { + res.status(201); + var location = (req.protocol + '://' + req.get('host') + + path.dirname(req.originalUrl) + '/' + + req.config.applicationId + '/' + + encodeURIComponent(filename)); + res.set('Location', location); + res.json({ url: location, name: filename }); + }).catch((error) => { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Could not store file.')); + }); +}; + +var processGet = function(req, res) { + var config = new Config(req.params.appId); + FilesAdapter.getAdapter().get(config, req.params.filename) + .then((data) => { + res.status(200); + var contentType = mime.lookup(req.params.filename); + res.set('Content-type', contentType); + res.end(data); + }).catch((error) => { + res.status(404); + res.set('Content-type', 'text/plain'); + res.end('File not found.'); + }); +}; + +router.get('/files/:appId/:filename', processGet); + +router.post('/files', function(req, res, next) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename not provided.')); +}); + +// TODO: do we need to allow crossdomain and method override? +router.post('/files/:filename', + bodyParser.raw({type: '*/*'}), + middlewares.handleParseHeaders, + processCreate); + +module.exports = { + router: router +}; diff --git a/functions.js b/functions.js new file mode 100644 index 0000000000..cf4aeb28bf --- /dev/null +++ b/functions.js @@ -0,0 +1,43 @@ +// functions.js + +var express = require('express'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +function handleCloudFunction(req) { + // TODO: set user from req.auth + if (Parse.Cloud.Functions[req.params.functionName]) { + return new Promise(function (resolve, reject) { + var response = createResponseObject(resolve, reject); + var request = { + params: req.body || {} + }; + Parse.Cloud.Functions[req.params.functionName](request, response); + }); + } else { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid function.'); + } +} + +function createResponseObject(resolve, reject) { + return { + success: function(result) { + resolve({ + response: { + result: result + } + }); + }, + error: function(error) { + reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error)); + } + } +} + +router.route('POST', '/functions/:functionName', handleCloudFunction); + + +module.exports = router; diff --git a/index.js b/index.js new file mode 100644 index 0000000000..9fe45016b0 --- /dev/null +++ b/index.js @@ -0,0 +1,173 @@ +// ParseServer - open-source compatible API Server for Parse apps + +var batch = require('./batch'), + bodyParser = require('body-parser'), + cache = require('./cache'), + DatabaseAdapter = require('./DatabaseAdapter'), + express = require('express'), + FilesAdapter = require('./FilesAdapter'), + middlewares = require('./middlewares'), + multer = require('multer'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + request = require('request'); + +// 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: +// "databaseAdapter": a class like ExportAdapter providing create, find, +// update, and delete +// "filesAdapter": a class like GridStoreAdapter providing create, get, +// and delete +// "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 +// "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 +// "javascriptKey": optional key from Parse dashboard +function ParseServer(args) { + if (!args.appId || !args.masterKey) { + throw 'You must provide an appId and masterKey!'; + } + + if (args.databaseAdapter) { + DatabaseAdapter.setAdapter(args.databaseAdapter); + } + if (args.filesAdapter) { + FilesAdapter.setAdapter(args.filesAdapter); + } + if (args.databaseURI) { + DatabaseAdapter.setDatabaseURI(args.databaseURI); + } + if (args.cloud) { + addParseCloud(); + require(args.cloud); + } + + cache.apps[args.appId] = { + masterKey: args.masterKey, + collectionPrefix: args.collectionPrefix || '', + clientKey: args.clientKey || '', + javascriptKey: args.javascriptKey || '', + dotNetKey: args.dotNetKey || '', + restAPIKey: args.restAPIKey || '', + fileKey: args.fileKey || 'invalid-file-key' + }; + + // Initialize the node client SDK automatically + Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); + + // 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(); + + // File handling needs to be before default middlewares are applied + api.use('/', require('./files').router); + + // TODO: separate this from the regular ParseServer object + if (process.env.TESTING == 1) { + console.log('enabling integration testing-routes'); + api.use('/', require('./testing-routes').router); + } + + api.use(bodyParser.json({ 'type': '*/*' })); + api.use(middlewares.allowCrossDomain); + api.use(middlewares.allowMethodOverride); + api.use(middlewares.handleParseHeaders); + + var router = new PromiseRouter(); + + router.merge(require('./classes')); + router.merge(require('./users')); + router.merge(require('./sessions')); + router.merge(require('./roles')); + router.merge(require('./analytics')); + router.merge(require('./push')); + router.merge(require('./installations')); + router.merge(require('./functions')); + + batch.mountOnto(router); + + router.mountOnto(api); + + api.use(middlewares.handleParseErrors); + + return api; +} + +function addParseCloud() { + Parse.Cloud.Functions = {}; + Parse.Cloud.Triggers = { + beforeSave: {}, + beforeDelete: {}, + afterSave: {}, + afterDelete: {} + }; + Parse.Cloud.define = function(functionName, handler) { + Parse.Cloud.Functions[functionName] = handler; + }; + Parse.Cloud.beforeSave = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.beforeSave[className] = handler; + }; + Parse.Cloud.beforeDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.beforeDelete[className] = handler; + }; + Parse.Cloud.afterSave = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.afterSave[className] = handler; + }; + Parse.Cloud.afterDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.afterDelete[className] = handler; + }; + Parse.Cloud.httpRequest = function(options) { + var promise = new Parse.Promise(); + var callbacks = { + success: options.success, + error: options.error + }; + delete options.success; + delete options.error; + if (options.uri && !options.url) { + options.uri = options.url; + delete options.url; + } + request(options, (error, response, body) => { + if (error) { + if (callbacks.error) { + return callbacks.error(error); + } + return promise.reject(error); + } else { + if (callbacks.success) { + return callbacks.success(body); + } + return promise.resolve(body); + } + }); + return promise; + }; + global.Parse = Parse; +} + +function getClassName(parseClass) { + if (parseClass && parseClass.className) { + return parseClass.className; + } + return parseClass; +} + +module.exports = { + ParseServer: ParseServer +}; + diff --git a/installations.js b/installations.js new file mode 100644 index 0000000000..517c3b812e --- /dev/null +++ b/installations.js @@ -0,0 +1,80 @@ +// installations.js + +var Parse = require('parse/node').Parse; +var PromiseRouter = require('./PromiseRouter'); +var rest = require('./rest'); + +var router = new PromiseRouter(); + + +// Returns a promise for a {status, response, location} object. +function handleCreate(req) { + return rest.create(req.config, + req.auth, '_Installation', req.body); +} + +// Returns a promise that resolves to a {response} object. +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (req.body.include) { + options.include = String(req.body.include); + } + + return rest.find(req.config, req.auth, + '_Installation', req.body.where, options) + .then((response) => { + return {response: response}; + }); +} + +// Returns a promise for a {response} object. +function handleGet(req) { + return rest.find(req.config, req.auth, '_Installation', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +// Returns a promise for a {response} object. +function handleUpdate(req) { + return rest.update(req.config, req.auth, + '_Installation', req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +// Returns a promise for a {response} object. +function handleDelete(req) { + return rest.del(req.config, req.auth, + '_Installation', req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +router.route('POST','/installations', handleCreate); +router.route('GET','/installations', handleFind); +router.route('GET','/installations/:objectId', handleGet); +router.route('PUT','/installations/:objectId', handleUpdate); +router.route('DELETE','/installations/:objectId', handleDelete); + +module.exports = router; \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..0438b79f69 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs" + } +} \ No newline at end of file diff --git a/middlewares.js b/middlewares.js new file mode 100644 index 0000000000..bb2512391a --- /dev/null +++ b/middlewares.js @@ -0,0 +1,192 @@ +var Parse = require('parse/node').Parse; + +var auth = require('./Auth'); +var cache = require('./cache'); +var Config = require('./Config'); + +// Checks that the request is authorized for this app and checks user +// auth too. +// The bodyparser should run before this middleware. +// Adds info to the request: +// req.config - the Config for this app +// req.auth - the Auth for this request +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 info = { + appId: req.get('X-Parse-Application-Id'), + sessionToken: req.get('X-Parse-Session-Token'), + masterKey: req.get('X-Parse-Master-Key'), + installationId: req.get('X-Parse-Installation-Id'), + clientKey: req.get('X-Parse-Client-Key'), + javascriptKey: req.get('X-Parse-Javascript-Key'), + dotNetKey: req.get('X-Parse-Windows-Key'), + restAPIKey: req.get('X-Parse-REST-API-Key') + }; + + var fileViaJSON = false; + + if (!info.appId || !cache.apps[info.appId]) { + // See if we can find the app id on the body. + if (req.body instanceof Buffer) { + // The only chance to find the app id is if this is a file + // upload that actually is a JSON body. So try to parse it. + req.body = JSON.parse(req.body); + fileViaJSON = true; + } + + if (req.body && req.body._ApplicationId + && cache.apps[req.body._ApplicationId] + && ( + !info.masterKey + || + cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey) + ) { + info.appId = req.body._ApplicationId; + info.javascriptKey = req.body._JavaScriptKey || ''; + delete req.body._ApplicationId; + delete req.body._JavaScriptKey; + // TODO: test that the REST API formats generated by the other + // SDKs are handled ok + if (req.body._ClientVersion) { + info.clientVersion = req.body._ClientVersion; + delete req.body._ClientVersion; + } + if (req.body._InstallationId) { + info.installationId = req.body._InstallationId; + delete req.body._InstallationId; + } + if (req.body._SessionToken) { + info.sessionToken = req.body._SessionToken; + delete req.body._SessionToken; + } + if (req.body._MasterKey) { + info.masterKey = req.body._MasterKey; + delete req.body._MasterKey; + } + } else { + return invalidRequest(req, res); + } + } + + if (fileViaJSON) { + // We need to repopulate req.body with a buffer + var base64 = req.body.base64; + req.body = new Buffer(base64, 'base64'); + } + + info.app = cache.apps[info.appId]; + req.config = new Config(info.appId, mount); + req.database = req.config.database; + req.info = info; + + var isMaster = (info.masterKey === req.config.masterKey); + + if (isMaster) { + req.auth = new auth.Auth(req.config, 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. + var keyRequired = (req.config.clientKey + || req.config.javascriptKey + || req.config.dotNetKey + || req.config.restAPIKey); + var keyHandled = false; + if (keyRequired + && ((info.clientKey && req.config.clientKey && info.clientKey === req.config.clientKey) + || (info.javascriptKey && req.config.javascriptKey && info.javascriptKey === req.config.javascriptKey) + || (info.dotNetKey && req.config.dotNetKey && info.dotNetKey === req.config.dotNetKey) + || (info.restAPIKey && req.config.restAPIKey && info.restAPIKey === req.config.restAPIKey) + )) { + keyHandled = true; + } + if (keyRequired && !keyHandled) { + return invalidRequest(req, res); + } + + if (!info.sessionToken) { + req.auth = new auth.Auth(req.config, false); + next(); + return; + } + + return auth.getAuthForSessionToken( + req.config, info.sessionToken).then((auth) => { + if (auth) { + req.auth = auth; + next(); + } + }).catch((error) => { + // TODO: Determine the correct error scenario. + console.log(error); + throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); + }); + +} + +var allowCrossDomain = function(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', '*'); + + // intercept OPTIONS method + if ('OPTIONS' == req.method) { + res.send(200); + } + else { + next(); + } +}; + +var allowMethodOverride = function(req, res, next) { + if (req.method === 'POST' && req.body._method) { + req.originalMethod = req.method; + req.method = req.body._method; + delete req.body._method; + } + next(); +}; + +var handleParseErrors = function(err, req, res, next) { + if (err instanceof Parse.Error) { + var 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; + } + + res.status(httpStatus); + res.json({code: err.code, error: err.message}); + } else { + console.log('Uncaught internal server error.', err, err.stack); + res.status(500); + res.json({code: Parse.Error.INTERNAL_SERVER_ERROR, + message: 'Internal server error.'}); + } +}; + +function invalidRequest(req, res) { + res.status(403); + res.end('{"error":"unauthorized"}'); +} + + +module.exports = { + allowCrossDomain: allowCrossDomain, + allowMethodOverride: allowMethodOverride, + handleParseErrors: handleParseErrors, + handleParseHeaders: handleParseHeaders +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000000..eebd2f5ac0 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "parse-server", + "version": "2.0.0", + "description": "An express module providing a Parse-compatible API server", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/ParsePlatform/parse-server" + }, + "license": "BSD", + "dependencies": { + "bcrypt": "~0.8", + "body-parser": "~1.12.4", + "deepcopy": "^0.5.0", + "express": "~4.2.x", + "hat": "~0.0.3", + "mime": "^1.3.4", + "mongodb": "~2.0.33", + "multer": "~0.1.8", + "parse": "~1.6.12", + "request": "^2.65.0" + }, + "devDependencies": { + "jasmine": "^2.3.2" + }, + "scripts": { + "test": "TESTING=1 jasmine" + } +} diff --git a/push.js b/push.js new file mode 100644 index 0000000000..08a192c474 --- /dev/null +++ b/push.js @@ -0,0 +1,18 @@ +// push.js + +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + + + +function notImplementedYet(req) { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, + 'This path is not implemented yet.'); +} + +router.route('POST','/push', notImplementedYet); + +module.exports = router; \ No newline at end of file diff --git a/rest.js b/rest.js new file mode 100644 index 0000000000..552fa6be8c --- /dev/null +++ b/rest.js @@ -0,0 +1,129 @@ +// This file contains helpers for running operations in REST format. +// The goal is that handlers that explicitly handle an express route +// should just be shallow wrappers around things in this file, but +// these functions should not explicitly depend on the request +// object. +// This means that one of these handlers can support multiple +// routes. That's useful for the routes that do really similar +// things. + +var Parse = require('parse/node').Parse; + +var cache = require('./cache'); +var RestQuery = require('./RestQuery'); +var RestWrite = require('./RestWrite'); +var triggers = require('./triggers'); + +// Returns a promise for an object with optional keys 'results' and 'count'. +function find(config, auth, className, restWhere, restOptions) { + enforceRoleSecurity('find', className, auth); + var query = new RestQuery(config, auth, className, + restWhere, restOptions); + 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'); + } + + if (className === '_User' && !auth.couldUpdateUserId(objectId)) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'insufficient auth to delete user'); + } + + enforceRoleSecurity('delete', className, auth); + + var inflatedObject; + + return Promise.resolve().then(() => { + if (triggers.getTrigger(className, 'beforeDelete') || + triggers.getTrigger(className, 'afterDelete') || + className == '_Session') { + return find(config, auth, className, {objectId: objectId}) + .then((response) => { + if (response && response.results && response.results.length) { + response.results[0].className = className; + cache.clearUser(response.results[0].sessionToken); + inflatedObject = Parse.Object.fromJSON(response.results[0]); + return triggers.maybeRunTrigger('beforeDelete', + auth, inflatedObject); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found for delete.'); + }); + } + return Promise.resolve({}); + }).then(() => { + var options = {}; + if (!auth.isMaster) { + options.acl = ['*']; + if (auth.user) { + options.acl.push(auth.user.id); + } + } + + return config.database.destroy(className, { + objectId: objectId + }, options); + }).then(() => { + triggers.maybeRunTrigger('afterDelete', auth, inflatedObject); + return Promise.resolve(); + }); +} + +// Returns a promise for a {response, status, location} object. +function create(config, auth, className, restObject) { + enforceRoleSecurity('create', className, auth); + + var write = new RestWrite(config, auth, className, null, restObject); + return write.execute(); +} + +// Returns a promise that contains the fields of the update that the +// REST API is supposed to return. +// Usually, this is just updatedAt. +function update(config, auth, className, objectId, restObject) { + enforceRoleSecurity('update', className, auth); + + return Promise.resolve().then(() => { + if (triggers.getTrigger(className, 'beforeSave') || + triggers.getTrigger(className, 'afterSave')) { + return find(config, auth, className, {objectId: objectId}); + } + return Promise.resolve({}); + }).then((response) => { + var originalRestObject; + if (response && response.results && response.results.length) { + originalRestObject = response.results[0]; + } + + var write = new RestWrite(config, auth, className, + {objectId: objectId}, restObject, originalRestObject); + return write.execute(); + }); +} + +// Disallowing access to the _Role collection except by master key +function enforceRoleSecurity(method, className, auth) { + if (className === '_Role' && !auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + method + ' operation on the role collection.'); + } + if (method === 'delete' && className === '_Installation' && !auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + 'delete operation on the installation collection.'); + + } +} + +module.exports = { + create: create, + del: del, + find: find, + update: update +}; diff --git a/roles.js b/roles.js new file mode 100644 index 0000000000..6aaf806526 --- /dev/null +++ b/roles.js @@ -0,0 +1,48 @@ +// roles.js + +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +function handleCreate(req) { + return rest.create(req.config, req.auth, + '_Role', req.body); +} + +function handleUpdate(req) { + return rest.update(req.config, req.auth, '_Role', + req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +function handleDelete(req) { + return rest.del(req.config, req.auth, + '_Role', req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +function handleGet(req) { + return rest.find(req.config, req.auth, '_Role', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +router.route('POST','/roles', handleCreate); +router.route('GET','/roles/:objectId', handleGet); +router.route('PUT','/roles/:objectId', handleUpdate); +router.route('DELETE','/roles/:objectId', handleDelete); + +module.exports = router; \ No newline at end of file diff --git a/sessions.js b/sessions.js new file mode 100644 index 0000000000..30290a9d52 --- /dev/null +++ b/sessions.js @@ -0,0 +1,122 @@ +// sessions.js + +var Auth = require('./Auth'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +function handleCreate(req) { + return rest.create(req.config, req.auth, + '_Session', req.body); +} + +function handleUpdate(req) { + return rest.update(req.config, req.auth, '_Session', + req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +function handleDelete(req) { + return rest.del(req.config, req.auth, + '_Session', req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +function handleGet(req) { + return rest.find(req.config, req.auth, '_Session', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +function handleLogout(req) { + // TODO: Verify correct behavior for logout without token + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'Session token required for logout.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return rest.del(req.config, Auth.master(req.config), '_Session', + response.results[0].objectId); + }).then(() => { + return { + status: 200, + response: {} + }; + }); +} + +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (typeof req.body.keys == 'string') { + options.keys = req.body.keys; + } + if (req.body.include) { + options.include = String(req.body.include); + } + + return rest.find(req.config, req.auth, + '_Session', req.body.where, options) + .then((response) => { + return {response: response}; + }); +} + +function handleMe(req) { + // TODO: Verify correct behavior + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return { + response: response.results[0] + }; + }); +} + +router.route('POST', '/logout', handleLogout); +router.route('POST','/sessions', handleCreate); +router.route('GET','/sessions/me', handleMe); +router.route('GET','/sessions/:objectId', handleGet); +router.route('PUT','/sessions/:objectId', handleUpdate); +router.route('GET','/sessions', handleFind); +router.route('DELETE','/sessions/:objectId', handleDelete); + +module.exports = router; \ No newline at end of file diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js new file mode 100644 index 0000000000..95fbdd2190 --- /dev/null +++ b/spec/ExportAdapter.spec.js @@ -0,0 +1,15 @@ +var ExportAdapter = require('../ExportAdapter'); + +describe('ExportAdapter', () => { + it('can be constructed', (done) => { + var database = new ExportAdapter('mongodb://localhost:27017/test', + { + collectionPrefix: 'test_' + }); + database.connect().then(done, (error) => { + console.log('error', error.stack); + fail(); + }); + }); + +}); diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js new file mode 100644 index 0000000000..fead537e02 --- /dev/null +++ b/spec/ParseACL.spec.js @@ -0,0 +1,1141 @@ +// This is a port of the test suite: +// hungry/js/test/parse_acl_test.js + +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("refresh object with acl", (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(); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and public get", (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(); + // Get + var query = new Parse.Query(TestObject); + query.get(object.id, { + success: function(model) { + fail('Should not have retrieved the object.'); + done(); + }, + error: function(model, error) { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and public find", (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(); + + // Find + var query = new Parse.Query(TestObject); + query.find({ + success: function(results) { + equal(results.length, 0); + done(); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and public update", (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(); + + // 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(); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and public delete", (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(); + + // Delete + object.destroy().then(() => { + fail('destroy should fail'); + }, error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and logged in get", (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(); + 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(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and logged in find", (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(); + 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); + 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) => { + // 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(); + Parse.User.logIn("alice", "wonderland", { + success: function() { + // Update + object.set("foo", "bar"); + object.save(null, { + success: function() { + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl an object owned by one user and logged in delete", (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(); + Parse.User.logIn("alice", "wonderland", { + success: function() { + // Delete + object.destroy({ + success: function() { + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly readable and public get", (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(); + + // Get + var query = new Parse.Query(TestObject); + query.get(object.id, { + success: function(result) { + ok(result); + equal(result.id, object.id); + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly readable and public find", (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(); + + // 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(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly readable and public update", (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(); + + // Update + 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) => { + // 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(); + + // Delete + 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) => { + // 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(); + + // Get + var query = new Parse.Query(TestObject); + query.get(object.id, { + error: function(model, error) { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly writable and public find", (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(); + + // Find + var query = new Parse.Query(TestObject); + query.find({ + success: function(results) { + equal(results.length, 0); + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly writable and public update", (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(); + + // Update + object.set("foo", "bar"); + object.save(null, { + success: function() { + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl making an object publicly writable and public delete", (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(); + + // Delete + object.destroy({ + success: function() { + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl sharing with another user and get", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl sharing with another user and find", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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); + equal(result.id, object.id); + done(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl sharing with another user and update", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl sharing with another user and delete", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + } + }); + } + }); + } + }); + } + }); + } + }); + }); + + 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(); + // 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(); + + 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 find", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + + var query = new Parse.Query(TestObject); + query.find({ + success: function(results) { + equal(results.length, 0); + done(); + } + }); + } + }); + } + }); + } + }); + }); + + it("acl sharing with another user and public update", (done) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + + 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) => { + // Sign in as Bob. + Parse.User.signUp("bob", "pass", null, { + success: function(bob) { + Parse.User.logOut(); + // 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(); + + 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("empty acl works", (done) => { + Parse.User.signUp("tdurden", "mayhem", { + ACL: new Parse.ACL(), + foo: "bar" + }, { + success: function(user) { + Parse.User.logOut(); + 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(); + } + }); + }); + + 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(); + 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(); + } + })); + } + })); + } + })); + }); + +}); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js new file mode 100644 index 0000000000..810ae46cc9 --- /dev/null +++ b/spec/ParseAPI.spec.js @@ -0,0 +1,615 @@ +// A bunch of different tests are in here - it isn't very thematic. +// It would probably be better to refactor them into different files. + +var DatabaseAdapter = require('../DatabaseAdapter'); +var request = require('request'); + +describe('miscellaneous', function() { + it('create a GameScore object', function(done) { + var 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(); + }, function(err) { console.log(err); }); + }); + + 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: fail + }); + }); + }); + + it('create a valid parse user', function(done) { + createTestUser(function(data) { + expect(data.id).not.toBeUndefined(); + expect(data.getSessionToken()).not.toBeUndefined(); + expect(data.get('password')).toBeUndefined(); + done(); + }, function(err) { + console.log(err); + fail(err); + }); + }); + + it('fail to create a duplicate username', function(done) { + createTestUser(function(data) { + createTestUser(function(data) { + fail('Should not have been able to save duplicate username.'); + }, function(error) { + expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); + done(); + }); + }); + }); + + it('succeed in logging in', function(done) { + createTestUser(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(); + done(); + }, error: function(error) { + fail(error); + } + }); + }, 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(); + return Parse.User.logIn('test', 'moon-y'); + }).then((user) => { + expect(user.get('foo')).toEqual(2); + Parse.User.logOut(); + done(); + }, (error) => { + fail(error); + done(); + }); + }); + + it('save various data types', function(done) { + var 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(); + }); + }); + + 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(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(); + }); + }); + + it('test cloud function', function(done) { + Parse.Cloud.run('hello', {}, function(result) { + expect(result).toEqual('Hello world!'); + done(); + }); + }); + + it('basic beforeSave rejection', function(done) { + var obj = new Parse.Object('BeforeSaveFailure'); + obj.set('foo', 'bar'); + obj.save().then(function() { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, function(error) { + done(); + }) + }); + + it('test beforeSave unchanged success', function(done) { + var obj = new Parse.Object('BeforeSaveUnchanged'); + obj.set('foo', 'bar'); + obj.save().then(function() { + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeSave changed object success', function(done) { + var 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) { + fail(error); + done(); + }); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test afterSave ran and created an object', function(done) { + var obj = new Parse.Object('AfterSaveTest'); + obj.save(); + + setTimeout(function() { + var 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); + }); + + it('test beforeSave happens on update', function(done) { + var 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(); + }); + }); + + it('test beforeDelete failure', function(done) { + var obj = new Parse.Object('BeforeDeleteFail'); + var 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) => { + expect(objAgain.get('foo')).toEqual('bar'); + done(); + }, (error) => { + // We should have been able to fetch the object again + fail(error); + }); + }); + + it('test beforeDelete success', function(done) { + var 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(); + }); + }); + + it('test afterDelete ran and created an object', function(done) { + var obj = new Parse.Object('AfterDeleteTest'); + obj.save().then(function() { + obj.destroy(); + }); + + setTimeout(function() { + var 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); + }); + + it('test save triggers get user', function(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 obj = new Parse.Object('SaveTriggerUser'); + obj.save().then(function() { + done(); + }, function(error) { + fail(error); + done(); + }); + } + }); + }); + + it('test cloud function return types', function(done) { + Parse.Cloud.run('foo').then((result) => { + expect(result.object instanceof Parse.Object).toBeTruthy(); + expect(result.object.className).toEqual('Foo'); + expect(result.object.get('x')).toEqual(2); + var bar = result.object.get('relation'); + expect(bar instanceof Parse.Object).toBeTruthy(); + expect(bar.className).toEqual('Bar'); + expect(bar.get('x')).toEqual(3); + expect(Array.isArray(result.array)).toEqual(true); + expect(result.array[0] instanceof Parse.Object).toBeTruthy(); + expect(result.array[0].get('x')).toEqual(2); + done(); + }); + }); + + 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(() => { + var db = DatabaseAdapter.getDatabaseConnection(appId); + return db.mongoFind('TestObject', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0]['foo']).toEqual('bar'); + done(); + }); + }); + + it('test beforeSave get full object on create and update', function(done) { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.beforeSave('GameScore', function(req, res) { + var 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(); + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var 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); + // Clear mock beforeSave + delete Parse.Cloud.Triggers.beforeSave.GameScore; + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test afterSave get full object on create and update', function(done) { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { + var 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(); + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var 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); + // Clear mock afterSave + delete Parse.Cloud.Triggers.afterSave.GameScore; + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeSave get original object on update', function(done) { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.beforeSave('GameScore', function(req, res) { + var 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; + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + // Check the originalObject is undefined + expect(originalObject).toBeUndefined(); + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + // Check the originalObject + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('fooAgain')).toEqual('barAgain'); + expect(originalObject.id).not.toBeUndefined(); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + expect(originalObject.get('foo')).toEqual('bar'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var 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); + // Clear mock beforeSave + delete Parse.Cloud.Triggers.beforeSave.GameScore; + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test afterSave get original object on update', function(done) { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { + var 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; + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + // Check the originalObject is undefined + expect(originalObject).toBeUndefined(); + } else if (triggerTime == 1) { + // Update + expect(object.get('foo')).toEqual('baz'); + // Check the originalObject + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('fooAgain')).toEqual('barAgain'); + expect(originalObject.id).not.toBeUndefined(); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + expect(originalObject.get('foo')).toEqual('bar'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var 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); + // Clear mock afterSave + delete Parse.Cloud.Triggers.afterSave.GameScore; + done(); + }, function(error) { + fail(error); + 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((s) => { + fail('Should not have succeeded.'); + delete Parse.Cloud.Functions['willFail']; + done(); + }, (e) => { + expect(e.code).toEqual(141); + expect(e.message).toEqual('noway'); + delete Parse.Cloud.Functions['willFail']; + done(); + }); + }); + + it('fails on invalid client key', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Client-Key': 'notclient' + }; + request.get({ + headers: headers, + url: 'http://localhost:8378/1/classes/TestObject' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.error).toEqual('unauthorized'); + done(); + }); + }); + + it('fails on invalid windows key', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Windows-Key': 'notwindows' + }; + request.get({ + headers: headers, + url: 'http://localhost:8378/1/classes/TestObject' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.error).toEqual('unauthorized'); + done(); + }); + }); + + it('fails on invalid javascript key', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'notjavascript' + }; + request.get({ + headers: headers, + url: 'http://localhost:8378/1/classes/TestObject' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.error).toEqual('unauthorized'); + done(); + }); + }); + + it('fails on invalid rest api key', done => { + var headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'notrest' + }; + request.get({ + headers: headers, + url: 'http://localhost:8378/1/classes/TestObject' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.error).toEqual('unauthorized'); + done(); + }); + }); + +}); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js new file mode 100644 index 0000000000..b65d8f3492 --- /dev/null +++ b/spec/ParseFile.spec.js @@ -0,0 +1,375 @@ +// This is a port of the test suite: +// hungry/js/test/parse_file_test.js + +var request = require('request'); + +var str = "Hello World!"; +var data = []; +for (var i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); +} + +describe('Parse.File testing', () => { + it('works with REST API', 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/file.txt', + body: 'argle bargle', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + 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'); + done(); + }); + }); + }); + + it('handles other filetypes', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.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); + 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); + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + + it("save file", done => { + var 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(); + } + })); + }); + + it("save file in object", done => { + var 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(); + } + })); + } + })); + } + })); + }); + + it("save file in object with escaped characters in filename", done => { + var 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(); + } + })); + } + })); + } + })); + }); + + it("autosave file in object", 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(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(); + } + })); + } + })); + }); + + it("autosave file in object in object", done => { + var 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(); + } + })); + } + })); + }); + + it("saving an already saved file", done => { + var 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(); + } + })); + } + })); + }); + + it("two saves at the same time", done => { + var file = new Parse.File("hello.txt", data, "text/plain"); + + var firstName; + var secondName; + + var firstSave = file.save().then(function() { firstName = file.name(); }); + var secondSave = file.save().then(function() { secondName = file.name(); }); + + Parse.Promise.when(firstSave, secondSave).then(function() { + equal(firstName, secondName); + done(); + }, function(error) { + ok(false, error); + done(); + }); + }); + + 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(obj) { + ok(object.toJSON().file.url); + done(); + } + })); + }); + + it("content-type used with no extension", done => { + var headers = { + 'Content-Type': 'text/html', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.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); + expect(b.name).toMatch(/\.html$/); + request.get(b.url, (error, response, body) => { + expect(response.headers['content-type']).toMatch(/^text\/html/); + done(); + }); + }); + }); + + it("filename is url encoded", done => { + var headers = { + 'Content-Type': 'text/html', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.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); + expect(b.url).toMatch(/hello%20world/); + done(); + }) + }); + + it('supports array of files', done => { + var file = { + __type: 'File', + url: 'http://meep.meep', + name: 'meep' + }; + var files = [file, file]; + var 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(); + }); + }); + + it('validates filename characters', done => { + var headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/di$avowed.txt', + body: 'will fail', + }, (error, response, body) => { + var b = JSON.parse(body); + expect(b.code).toEqual(122); + done(); + }); + }); + + it('validates filename length', done => { + var headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + var fileName = 'Onceuponamidnightdrearywhileiponderedweak' + + 'andwearyOveramanyquaintandcuriousvolumeof' + + 'forgottenloreWhileinoddednearlynappingsud' + + 'denlytherecameatapping'; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/' + fileName, + body: 'will fail', + }, (error, response, body) => { + var b = JSON.parse(body); + expect(b.code).toEqual(122); + done(); + }); + }); + + it('supports a dictionary with file', done => { + var file = { + __type: 'File', + url: 'http://meep.meep', + name: 'meep' + }; + var dict = { + file: file + }; + var 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(); + }); + }); + + it('creates correct url for old files hosted on parse', done => { + var file = { + __type: 'File', + url: 'http://irrelevant.elephant/', + name: 'tfss-123.txt' + }; + var 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(); + }); + + }); + +}); diff --git a/spec/ParseGeoPoint.spec.js b/spec/ParseGeoPoint.spec.js new file mode 100644 index 0000000000..f5b54bca55 --- /dev/null +++ b/spec/ParseGeoPoint.spec.js @@ -0,0 +1,290 @@ +// This is a port of the test suite: +// hungry/js/test/parse_geo_point_test.js + +var 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(); + 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(); + } + }); + } + }); + }); + + it('geo point exception two fields', (done) => { + var point = new Parse.GeoPoint(20, 20); + var 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(); + }); + }); + + 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); + 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(); + } + }); + } + }); + }); + + it('geo max distance large', (done) => { + var objects = []; + [0, 1, 2].map(function(i) { + var obj = new TestObject(); + var 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((list) => { + 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) => { + console.log(err); + fail(); + }); + }); + + it('geo max distance medium', (done) => { + var objects = []; + [0, 1, 2].map(function(i) { + var obj = new TestObject(); + var 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(list) { + 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(); + } + }); + }); + }); + + it('geo max distance small', (done) => { + var objects = []; + [0, 1, 2].map(function(i) { + var obj = new TestObject(); + var 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(list) { + 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(); + } + }); + }); + }); + + var makeSomeGeoPoints = function(callback) { + var sacramento = new TestObject(); + sacramento.set('location', new Parse.GeoPoint(38.52, -121.50)); + sacramento.set('name', 'Sacramento'); + + var honolulu = new TestObject(); + honolulu.set('location', new Parse.GeoPoint(21.35, -157.93)); + honolulu.set('name', 'Honolulu'); + + var 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); + }; + + it('geo max distance in km everywhere', (done) => { + makeSomeGeoPoints(function(list) { + var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + var query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 4000.0); + query.find({ + success: function(results) { + equal(results.length, 3); + done(); + } + }); + }); + }); + + it('geo max distance in km california', (done) => { + makeSomeGeoPoints(function(list) { + 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 bay area', (done) => { + makeSomeGeoPoints(function(list) { + 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 mid peninsula', (done) => { + makeSomeGeoPoints(function(list) { + 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 miles everywhere', (done) => { + makeSomeGeoPoints(function(list) { + var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + var query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 2500.0); + query.find({ + success: function(results) { + equal(results.length, 3); + done(); + } + }); + }); + }); + + it('geo max distance in miles california', (done) => { + makeSomeGeoPoints(function(list) { + 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 bay area', (done) => { + makeSomeGeoPoints(function(list) { + var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + var query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 75.0); + query.find({ + success: function(results) { + equal(results.length, 1); + equal(results[0].get('name'), 'San Francisco'); + done(); + } + }); + }); + }); + + it('geo max distance in miles mid peninsula', (done) => { + makeSomeGeoPoints(function(list) { + 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('works with geobox queries', (done) => { + var inSF = new Parse.GeoPoint(37.75, -122.4); + var southwestOfSF = new Parse.GeoPoint(37.708813, -122.526398); + var northeastOfSF = new Parse.GeoPoint(37.822802, -122.373962); + + var object = new TestObject(); + object.set('point', inSF); + object.save().then(() => { + var query = new Parse.Query(TestObject); + query.withinGeoBox('point', southwestOfSF, northeastOfSF); + return query.find(); + }).then((results) => { + equal(results.length, 1); + done(); + }); + }); +}); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js new file mode 100644 index 0000000000..6d8e61625f --- /dev/null +++ b/spec/ParseInstallation.spec.js @@ -0,0 +1,777 @@ +// These tests check the Installations functionality of the REST API. +// Ported from installation_collection_test.go + +var auth = require('../Auth'); +var cache = require('../cache'); +var Config = require('../Config'); +var DatabaseAdapter = require('../DatabaseAdapter'); +var Parse = require('parse/node').Parse; +var rest = require('../rest'); + +var config = new Config('test'); +var database = DatabaseAdapter.getDatabaseConnection('test'); + +describe('Installations', () => { + + it('creates an android installation with ids', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var device = 'android'; + var input = { + 'installationId': installId, + 'deviceType': device + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('creates an ios installation with ids', (done) => { + var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + var device = 'ios'; + var input = { + 'deviceToken': t, + 'deviceType': device + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.deviceToken).toEqual(t); + expect(obj.deviceType).toEqual(device); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('creates an embedded installation with ids', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var device = 'embedded'; + var input = { + 'installationId': installId, + 'deviceType': device + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + done(); + }).catch((error) => { console.log(error); }); + }); + + 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'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + expect(typeof obj.channels).toEqual('object'); + expect(obj.channels.length).toEqual(2); + expect(obj.channels[0]).toEqual('foo'); + expect(obj.channels[1]).toEqual('bar'); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('creates an ios installation with all fields', (done) => { + var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + var device = 'ios'; + var input = { + 'deviceToken': t, + 'deviceType': device, + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.deviceToken).toEqual(t); + expect(obj.deviceType).toEqual(device); + expect(typeof obj.channels).toEqual('object'); + expect(obj.channels.length).toEqual(2); + expect(obj.channels[0]).toEqual('foo'); + expect(obj.channels[1]).toEqual('bar'); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('fails with missing ids', (done) => { + var input = { + 'deviceType': 'android', + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Should not have been able to create an Installation.'); + done(); + }).catch((error) => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('fails for android with device token', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + var device = 'android'; + var input = { + 'installationId': installId, + 'deviceType': device, + 'deviceToken': t, + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Should not have been able to create an Installation.'); + done(); + }).catch((error) => { + expect(error.code).toEqual(114); + done(); + }); + }); + + it('fails for android with missing type', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var input = { + 'installationId': installId, + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Should not have been able to create an Installation.'); + done(); + }).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' + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.custom).toEqual('allowed'); + done(); + }).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 installId2 = '12345678-abcd-abcd-abcd-123456789abd'; + var input = { + 'deviceToken': t, + 'deviceType': 'ios', + 'installationId': installId1, + 'channels': ['foo', 'bar'] + }; + var firstObject; + var secondObject; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + firstObject = results[0]; + delete input.deviceToken; + delete input.channels; + input['foo'] = 'bar'; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + secondObject = results[0]; + expect(firstObject._id).toEqual(secondObject._id); + expect(secondObject.channels.length).toEqual(2); + expect(secondObject.foo).toEqual('bar'); + done(); + }).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' + }; + var input2 = { + 'deviceToken': t, + 'deviceType': 'ios' + }; + var input3 = { + 'deviceToken': t, + 'installationId': installId, + 'deviceType': 'ios' + }; + var firstObject; + var secondObject; + rest.create(config, auth.nobody(config), '_Installation', input1) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + firstObject = results[0]; + return rest.create(config, auth.nobody(config), '_Installation', input2); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(2); + if (results[0]['_id'] == firstObject._id) { + secondObject = results[1]; + } else { + secondObject = results[0]; + } + return rest.create(config, auth.nobody(config), '_Installation', input3); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0]['_id']).toEqual(secondObject._id); + done(); + }).catch((error) => { console.log(error); }); + }); + + notWorking('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 + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input.installationId = installId2; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + input.installationId = installId3; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {installationId: installId1}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + return database.mongoFind('_Installation', + {installationId: installId2}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + return database.mongoFind('_Installation', + {installationId: installId3}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('updating with new channels', (done) => { + var input = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android', + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var id = results[0]['_id']; + var update = { + 'channels': ['baz'] + }; + return rest.update(config, auth.nobody(config), + '_Installation', id, update); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0].channels.length).toEqual(1); + expect(results[0].channels[0]).toEqual('baz'); + done(); + }).catch((error) => { console.log(error); }); + }); + + 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'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'installationId': installId2 + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + fail('Updating the installation should have failed.'); + done(); + }).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'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'deviceToken': b + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + fail('Updating the installation should have failed.'); + }).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'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'installationId': installId, + 'deviceToken': u, + 'deviceType': 'ios' + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toEqual(u); + done(); + }); + }); + + it('update fails to change deviceType', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var input = { + 'installationId': installId, + 'deviceType': 'android', + 'channels': ['foo', 'bar'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'deviceType': 'ios' + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + fail('Should not have been able to update Installation.'); + done(); + }).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'] + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'custom': 'allowed' + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0]['custom']).toEqual('allowed'); + done(); + }); + }); + + 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' + }; + var firstObject; + var secondObject; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + 'installationId': installId2, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {installationId: installId1}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + firstObject = results[0]; + return database.mongoFind('_Installation', + {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 + }; + return rest.update(config, auth.nobody(config), '_Installation', + secondObject._id, input); + }).then(() => { + // The first object should have been deleted + return database.mongoFind('_Installation', {_id: firstObject._id}, {}); + }).then((results) => { + expect(results.length).toEqual(0); + done(); + }).catch((error) => { console.log(error); }); + }); + + notWorking('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' + }; + 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(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + // The first object should have been deleted during merge + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId2); + done(); + }); + }); + + it('update ios token and channels', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + var input = { + 'installationId': installId, + 'deviceType': 'ios' + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'deviceToken': t, + 'channels': [] + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).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(); + }); + }); + + it('update ios linking two existing objects', (done) => { + var installId = '12345678-abcd-abcd-abcd-123456789abc'; + var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + var input = { + 'installationId': installId, + 'deviceType': 'ios' + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {deviceToken: t}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'deviceToken': t, + 'installationId': installId, + 'deviceType': 'ios' + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).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(); + }); + }); + + 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' + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {deviceToken: t}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'deviceToken': t, + 'installationId': installId, + 'deviceType': 'ios', + 'score': { + '__op': 'Increment', + 'amount': 1 + } + }; + return rest.update(config, auth.nobody(config), '_Installation', + results[0]['_id'], input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).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'); + expect(results[0].score).toEqual(1); + 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' + }; + var installObj; + var tokenObj; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + installObj = results[0]; + input = { + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', {deviceToken: t}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + tokenObj = results[0]; + input = { + 'installationId': installId, + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.update(config, auth.nobody(config), '_Installation', + installObj._id, input); + }).then(() => { + return database.mongoFind('_Installation', {_id: tokenObj._id}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + done(); + }).catch((error) => { console.log(error); }); + }); + + 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' + }; + var installObj; + var tokenObj; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + installObj = results[0]; + input = { + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', {deviceToken: t}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + tokenObj = results[0]; + input = { + 'installationId': installId, + 'deviceToken': t, + 'deviceType': 'ios', + 'score': { + '__op': 'Increment', + 'amount': 1 + } + }; + return rest.update(config, auth.nobody(config), '_Installation', + installObj._id, input); + }).then(() => { + return database.mongoFind('_Installation', {_id: tokenObj._id}, {}); + }).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) => { console.log(error); }); + }); + + 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 + // process does not generate installation IDs. When they later integrate + // the Parse SDK, their app is going to save the installation. This save + // op will have a client-generated installation ID as well as a device + // token. At this point, if the device token matches the originally- + // 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' + }; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + input = { + 'installationId': installId, + 'deviceToken': t, + 'deviceType': 'ios' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].installationId).toEqual(installId); + done(); + }); + }); + + // TODO: Look at additional tests from installation_collection_test.go:882 + // TODO: Do we need to support _tombstone disabling of installations? + // TODO: Test deletion, badge increments + +}); diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js new file mode 100644 index 0000000000..907c15ca9e --- /dev/null +++ b/spec/ParseObject.spec.js @@ -0,0 +1,1739 @@ +// This is a port of the test suite: +// hungry/js/test/parse_object_test.js +// +// Things we didn't port: +// Tests that aren't async, because they only test the client. +// Tests that use Relations, because we intentionally do not support +// relations. +// Tests that use 'testDestroy', because they have a complex +// dependency on the terrible qunit start/stop mechanism. +// Tests for unfetching, since that behaves differently in +// single-instance mode and we don't want these tests to run in +// single-instance mode. + +describe('Parse.Object testing', () => { + it("create", function(done) { + create({ "test" : "test" }, function(model, response) { + 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, response) { + var t2 = new TestObject({ objectId: model.id }); + t2.set("test", "changed"); + t2.save(null, { + success: function(model, response) { + 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) { + equal(objectAgain, object); + done(); + }, + error: function(objectAgain, error) { + ok(null, "Error " + error.code + ": " + error.message); + done(); + } + }); + }); + + it("save cycle", function(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, response) { + var t2 = new TestObject({ objectId: model.id }); + t2.fetch({ + success: function(model2, response) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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(); + } + }); + } + }); + } + }); + }); + + 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("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(); + } + }); + } + }); + }); + + 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("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("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); + + var endDiff = Math.abs(endTime.getTime() - + object.createdAt.getTime()); + ok(endDiff < 5000); + + done(); + } + }); + }); + + it("can set null", function(done) { + var obj = new Parse.Object("TestObject"); + obj.set("foo", null); + obj.save(null, { + success: function(obj) { + equal(obj.get("foo"), null); + done(); + }, + error: function(obj, error) { + ok(false, error.message); + 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); + done(); + }, + error: function(obj, error) { + ok(false, error.message); + done(); + } + }); + }); + + it('cannot set invalid date', function(done) { + var obj = new Parse.Object('TestObject'); + obj.set('when', new Date(Date.parse(null))); + try { + obj.save(); + } catch (e) { + ok(true); + done(); + return; + } + ok(false, 'Saving an invalid date should throw'); + done(); + }); + + it("invalid class name", function(done) { + var item = new Parse.Object("Foo^bar"); + item.save(null, { + success: function(item) { + ok(false, "The name should have been invalid."); + done(); + }, + error: function(item, error) { + // 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("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."); + 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(); + } + }); + }); + + 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."); + 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("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); + 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(); + } + }); + }, + 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(); + } + }); + } + }); + } + }); + }); + + 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', 3); + expect(x2.get('stuff')).toEqual([2, 3]); + return x2.save(); + }).then(() => { + var query = new Parse.Query('X'); + return query.get(x1.id); + }).then((x3) => { + expect(x3.get('stuff')).toEqual([1, 2, 3]); + done(); + }, (error) => { + fail(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) { + ok(!object.dirty()); + ok(!object.dirty("cat")); + ok(!object.dirty("dog")); + + object.set("dog", "okay"); + + ok(object.dirty()); + ok(!object.dirty("cat")); + ok(object.dirty("dog")); + + done(); + }, + error: function(object, error) { + 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"); + ok(object.dirty()); + var 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")); + + 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); + 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("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("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("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("new attribute unset then clear", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + it("unknown attribute unset then clear", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + 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("new attribute clear then unset", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + it("unknown attribute clear then unset", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + 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("new attribute clear then clear", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + it("unknown attribute clear then clear", function(done) { + var TestObject = Parse.Object.extend("TestObject"); + var 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(); + } + }); + } + }); + }); + + it("saving children in an array", function(done) { + var Parent = Parse.Object.extend("Parent"); + var Child = Parse.Object.extend("Child"); + + var child1 = new Child(); + var child2 = new Child(); + var parent = new Parent(); + + child1.set('name', 'jamie'); + child2.set('name', 'cersei'); + parent.set('children', [child1, child2]); + + parent.save(null, { + success: function(parent) { + 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); + done(); + } + }); + }); + + it("two saves at the same time", function(done) { + + var object = new Parse.Object("TestObject"); + var firstSave = true; + + var 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(); + } + }); + }; + + var options = { success: success, error: fail }; + + object.save({ cat: "meow" }, options); + object.save({ dog: "bark" }, options); + }); + + // The schema-checking parts of this are working. + // We dropped the part where number can be reset to a correctly + // typed field and saved okay, since that appears to be borked in + // the client. + // 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'); + 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); + + 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("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("toJSON saved object", function(done) { + var _ = Parse._; + create({ "foo" : "bar" }, function(model, response) { + var 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'"); + ok(objJSON.updatedAt, "expected json to contain key 'updatedAt'"); + done(); + }); + }); + + 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("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("fail validation with promise", function(done) { + var PickyEater = Parse.Object.extend("PickyEater", { + validate: function(attrs) { + 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(error) { + 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; + restController.request = function() { + return r.apply(this, arguments).then(function(result) { + 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(); + }); + }); + + it("beforeSave doesn't make object dirty with existing field", function(done) { + var restController = Parse.CoreManager.getRESTController(); + var 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 result; + }); + }; + + var now = new Date(); + + var obj = new Parse.Object("Thing"); + var 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(); + }); + }); + + 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("destroyAll no objects", function(done) { + Parse.Object.destroyAll([], function(success, error) { + ok(success && !error, "Should be able to destroy no objects"); + done(); + }); + }); + + 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("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); + 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); + }).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 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); + 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(error) { + 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 unsaved object", function(done) { + var unsavedObjectArray = [new TestObject()]; + Parse.Object.fetchAll(unsavedObjectArray, + expectError(Parse.Error.MISSING_OBJECT_ID, done)); + }); + + it("fetchAll error on deleted object", function(done) { + var numItems = 11; + var container = new Container(); + var subContainer = new Container(); + var items = []; + for (var i = 0; i < numItems; i++) { + var 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)); + }); + }); + + // TODO: Verify that with Sessions, this test is wrong... A fetch on + // user should not bring down a session token. + notWorking("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(); + }); + }); + + 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); + 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() { + 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); + 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(error) { + 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"); + 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("Objects with className User", function(done) { + equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true); + var User1 = Parse.Object.extend({ + className: "User" + }); + + 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" + }); + + 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(); + }); + }); + + it("remove from new field creates array key", (done) => { + var 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(); + }); + }); + + it("increment with type conflict fails", (done) => { + var obj = new TestObject(); + obj.set('astring', 'foo'); + obj.save().then(() => { + var obj2 = new TestObject(); + obj2.increment('astring'); + return obj2.save(); + }).then((obj2) => { + 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(); + 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(); + }); + }); + + it("increment update with type conflict fails", (done) => { + var 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(); + }); + }); + + 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'); + 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(); + }); + }); + +}); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js new file mode 100644 index 0000000000..88c1f53a89 --- /dev/null +++ b/spec/ParseQuery.spec.js @@ -0,0 +1,2075 @@ +// This is a port of the test suite: +// hungry/js/test/parse_query_test.js +// +// Some new tests are added. + +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); + query.equalTo('foo', 'baz'); + query.find({ + success: function(results) { + equal(results.length, 1); + equal(results[0].get('foo'), 'baz'); + 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); + query.limit(1); + query.find({ + success: 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({}); + if (i > 0) { + message.set('prior', messageList[i - 1]); + } + messageList.push(message); + } + + Parse.Object.saveAll(messageList, function() { + equal(messageList.length, 4); + + var 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) { + fail(e); + done(); + } + }); + }, (e) => { + fail(e); + done(); + }); + }); + + it("containsAll number array queries", function(done) { + var 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] })); + + 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) { + fail(err); + done(); + }, + }); + }); + }); + + it("containsAll string array queries", function(done) { + var 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"] })); + + Parse.Object.saveAll(objectsList, function() { + var query = new Parse.Query(StringSet); + query.containsAll("strings", ["a", "b", "c"]); + query.find({ + success: function(results) { + equal(results.length, 1); + done(); + } + }); + }); + }); + + it("containsAll date array queries", function(done) { + var 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' + + '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})' + + '(.([0-9]+))?' + 'Z$'); + var 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; + + return new Date(Date.UTC(year, month, day, hour, minute, second, milli)); + } + + var makeDates = function(stringArray) { + return stringArray.map(function(dateStr) { + 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) { + equal(results.length, 1); + done(); + }, + error: function(e) { + fail(e); + done(); + }, + }); + }); + }); + + it("containsAll object array queries", function(done) { + + var MessageSet = Parse.Object.extend({ className: "MessageSet" }); + + var messageList = []; + for (var i = 0; i < 4; ++i) { + messageList.push(new TestObject({ 'i' : i })); + } + + Parse.Object.saveAll(messageList, function() { + equal(messageList.length, 4); + + var messageSetList = []; + messageSetList.push(new MessageSet({ 'messages' : messageList })); + + var someList = []; + someList.push(messageList[0]); + someList.push(messageList[1]); + someList.push(messageList[3]); + messageSetList.push(new MessageSet({ 'messages' : someList })); + + Parse.Object.saveAll(messageSetList, function() { + var inList = []; + inList.push(messageList[0]); + inList.push(messageList[2]); + + var query = new Parse.Query(MessageSet); + query.containsAll('messages', inList); + query.find({ + success: function(results) { + equal(results.length, 1); + done(); + } + }); + }); + }); + }); + + var BoxedNumber = Parse.Object.extend({ + className: "BoxedNumber" + }); + + 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(); + } + }); + }); + }); + + 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("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(); + } + }); + }); + }); + + 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("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(); + } + }); + }); + }); + + 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("lessThanOrEqualTo 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.lessThanOrEqualTo('number', 7); + query.greaterThanOrEqualTo('number', 7); + query.find({ + success: function(results) { + equal(results.length, 1); + done(); + } + }); + }); + }); + + it("lessThan 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.lessThan('number', 9); + query.greaterThan('number', 3); + query.find({ + success: function(results) { + equal(results.length, 5); + done(); + } + }); + }); + }); + + it("notEqualTo 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.notEqualTo('number', 5); + query.find({ + success: function(results) { + equal(results.length, 9); + done(); + } + }); + }); + }); + + it("containedIn 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.containedIn('number', [3,5,7,9,11]); + query.find({ + success: function(results) { + equal(results.length, 4); + done(); + } + }); + }); + }); + + it("notContainedIn 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.notContainedIn('number', [3,5,7,9,11]); + query.find({ + success: function(results) { + equal(results.length, 6); + done(); + } + }); + }); + }); + + + it("objectId containedIn 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(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(); + } + }); + }); + }); + + it("objectId 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(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(); + } + }); + }); + }); + + it("find no elements", 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', 17); + query.find(expectSuccess({ + success: function(results) { + equal(results.length, 0); + 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("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); + done(); + } + }); + }); + }); + + it("get undefined", function(done) { + Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { + ok(items[0]); + var query = new Parse.Query(TestObject); + query.get(undefined, { + success: fail, + error: done, + }); + }); + }); + + it("get error", 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("InvalidObjectID", { + success: function(result) { + ok(false, "The get should have failed."); + done(); + }, + error: function(object, 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); + query.equalTo('foo', 'bar'); + query.first({ + success: 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); + query.equalTo('foo', 'baz'); + query.first({ + success: 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); + query.equalTo('foo', 'bar'); + query.first({ + success: function(result) { + equal(result.get('foo'), 'bar'); + done(); + } + }); + }); + }); + + it("first with error", function(done) { + var query = new Parse.Query(BoxedNumber); + query.equalTo('$foo', 'bar'); + query.first(expectError(Parse.Error.INVALID_KEY_NAME, done)); + }); + + var 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) { + equal(results.length, 1); + done(); + } + }); + }); + }); + + it("skip", function(done) { + Parse.Object.saveAll([new TestObject(), new TestObject()], function() { + var 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(); + } + }); + } + }); + }); + }); + + 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) { + 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(); + } + }); + } + }); + } + }); + }); + }); + + it("count", 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", 1); + query.count({ + success: function(count) { + equal(count, 8); + done(); + } + }); + }); + }); + + it("order by ascending number", function(done) { + var makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { + 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(); + } + })); + }); + }); + + it("order by descending number", function(done) { + var makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { + 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(); + } + })); + }); + }); + + it("order by ascending number then descending string", function(done) { + var strings = ["a", "b", "c", "d"]; + var makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll( + [3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + 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(); + } + })); + }); + }); + + it("order by descending number then ascending string", function(done) { + var strings = ["a", "b", "c", "d"]; + var makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + var query = new Parse.Query(BoxedNumber); + query.descending("number").addAscending("string"); + query.find(expectSuccess({ + success: function(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(); + } + })); + }); + }); + + it("order by descending number and string", function(done) { + var strings = ["a", "b", "c", "d"]; + var makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + 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(); + } + })); + }); + }); + + 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] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + 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(); + } + })); + }); + }); + + it("order by descending number and string, with array arg", function(done) { + var strings = ["a", "b", "c", "d"]; + var makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + 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(); + } + })); + }); + }); + + it("order by descending number and string, with multiple args", function(done) { + var strings = ["a", "b", "c", "d"]; + var makeBoxedNumber = function(num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), + function(list) { + 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(); + } + })); + }); + }); + + it("can't order by password", function(done) { + var makeBoxedNumber = function(i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { + var query = new Parse.Query(BoxedNumber); + query.ascending("_password"); + query.find(expectError(Parse.Error.INVALID_KEY_NAME, done)); + }); + }); + + it("order by _created_at", 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() { + var query = new Parse.Query(BoxedNumber); + query.ascending("_created_at"); + query.find({ + success: function(results) { + equal(results.length, 3); + equal(results[0].get("number"), 3); + equal(results[1].get("number"), 1); + equal(results[2].get("number"), 2); + done(); + }, + error: function(e) { + fail(e); + done(); + }, + }); + }); + }); + + it("order by createdAt", 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() { + var query = new Parse.Query(BoxedNumber); + query.descending("createdAt"); + query.find({ + success: function(results) { + equal(results.length, 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) { + 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(model) { + 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(); + } + }); + } + }); + }); + }); + + 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(model) { + 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(); + } + }); + } + }); + }); + }); + + // Returns a promise + function makeTimeObject(start, i) { + var time = new Date(); + time.setSeconds(start.getSeconds() + i); + var 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]; + }); + } + + 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(); + } + }); + }); + }); + + 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(); + } + }); + }); + }); + + // This test requires Date objects to be consistently stored as a Date. + 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(); + } + }); + }); + }); + + 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 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("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(results) { + 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("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); + query.matches( + "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) { + equal(results.length, 1); + 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(); + } + }); + }); + }); + + var 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, foo) { + equal(results.length, 4); + 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})], function() { + var query = new Parse.Query(TestObject); + query.startsWith("myString", someAscii); + query.find({ + success: function(results, foo) { + 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.startsWith("myString", someAscii); + query.find({ + success: function(results, foo) { + 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(); + if (i % 2 === 0) { + item.set('x', i + 1); + } else { + item.set('y', i + 1); + } + 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(); + } + }); + }); + }); + + it("doesNotExist", function(done) { + var objects = []; + for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + var item = new TestObject(); + if (i % 2 === 0) { + item.set('x', i + 1); + } else { + item.set('y', i + 1); + } + 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(); + } + }); + }); + }); + + it("exists relation", function(done) { + var objects = []; + for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + var container = new Container(); + if (i % 2 === 0) { + var item = new TestObject(); + item.set('x', i); + container.set('x', item); + objects.push(item); + } else { + container.set('y', i); + } + objects.push(container); + }; + Parse.Object.saveAll(objects, 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(); + } + }); + }); + }); + + it("doesNotExist relation", function(done) { + var objects = []; + for (var i of [0, 1, 2, 3, 4, 5, 6, 7]) { + var container = new Container(); + if (i % 2 === 0) { + var item = new TestObject(); + item.set('x', i); + container.set('x', item); + objects.push(item); + } else { + container.set('y', i); + } + 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(); + } + }); + }); + }); + + 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() { + 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(); + } + }); + }); + }); + + 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 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("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, + parent: new Parent({ + y:i, + child: new Child({ + 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(); + } + }); + }); + }); + + 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(); + } + }); + }); + }); + + it("result object creation uses current extension", function(done) { + var ParentObject = Parse.Object.extend({ className: "ParentObject" }); + // Add a foo() method to ChildObject. + var ChildObject = Parse.Object.extend("ChildObject", { + foo: function() { + return "foo"; + } + }); + + var parent = new ParentObject(); + var child = new ChildObject(); + parent.set("child", child); + Parse.Object.saveAll([child, parent], function() { + // Add a bar() method to ChildObject. + ChildObject = Parse.Object.extend("ChildObject", { + bar: function() { + 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(); + } + }); + }); + }); + + 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) { + objects.push( + new ParentObject({ + 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(); + } + } + }); + } + }); + }); + }); + + 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" }) + ]; + + 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'); + 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" }) + ]; + + 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) => { + fail(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" }) + ]; + + 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(); + } + })); + }); + }); + + 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(obj) { + var query = new Parse.Query(TestObject); + query.find({ + success: function(results) { + equal(results.length, 1); + equal(results[0].get("length"), 5); + done(); + }, + error: function(error) { + ok(false, error.message); + done(); + } + }); + }, + 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); + done(); + } + }); + }, + error: function(user, error) { + ok(false, error.message); + done(); + } + }); + }); + + 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'); + 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(); + } + })); + } + })); + }); + + // 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'); + child.set('x', x); + var 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(); + } + })); + } + })); + }); + + 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); + return obj.save(); + }); + + Parse.Promise.when(saves).then(function() { + var query = new Parse.Query("TestObject"); + query.ascending("x"); + return query.first(); + + }).then(function(obj) { + equal(obj.get("x"), 1); + var query = new Parse.Query("TestObject"); + query.descending("x"); + return query.find(); + + }).then(function(results) { + equal(results.length, 10); + var query = new Parse.Query("TestObject"); + return query.get(results[0].id); + + }).then(function(obj1) { + equal(obj1.get("x"), 10); + var query = new Parse.Query("TestObject"); + return query.count(); + + }).then(function(count) { + equal(count, 10); + + }).then(function() { + done(); + + }); + }); + + it("query.each", function(done) { + var TOTAL = 50; + var COUNT = 25; + + var items = range(TOTAL).map(function(x) { + var 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; + + }, { + 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); + }; + done(); + }, + error: function(error) { + ok(false, error); + done(); + } + }); + }); + }); + + it("query.each async", function(done) { + var TOTAL = 50; + var COUNT = 25; + + expect(COUNT + 1); + + var items = range(TOTAL).map(function(x) { + var obj = new TestObject(); + obj.set("x", x); + return obj; + }); + + var 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(); + }); + 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(); + }); + }); + + it("query.each fails with order", function(done) { + var TOTAL = 50; + var COUNT = 25; + + var items = range(TOTAL).map(function(x) { + var obj = new TestObject(); + obj.set("x", x); + return obj; + }); + + 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; + }); + + }).then(function() { + ok(false, "This should have failed."); + done(); + }, function(error) { + done(); + }); + }); + + it("query.each fails with skip", function(done) { + var TOTAL = 50; + var COUNT = 25; + + var items = range(TOTAL).map(function(x) { + var obj = new TestObject(); + obj.set("x", x); + return obj; + }); + + 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(error) { + done(); + }); + }); + + 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; + }); + + 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(error) { + done(); + }); + }); + + 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('select keys with each 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'); + 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) { + ok(false, JSON.stringify(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'); + children.push(child); + var parent = new Parse.Object('Parent'); + parents.push(parent); + promises.push( + child.save().then(() => { + parents[iter].set('child', [children[iter]]); + return parents[iter].save(); + }) + ); + }; + 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); }); + }); + + it('querying for null value', (done) => { + var 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 objs = []; + var promises = []; + for (var i = 0; i < 2; i++) { + var proc = (iter) => { + var 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); + }); + }); + + it('include on the wrong key type', (done) => { + var obj = new Parse.Object('TestObject'); + obj.set('foo', 'bar'); + obj.save().then(() => { + var query = new Parse.Query('TestObject'); + query.include('foo'); + return query.find(); + }).then((results) => { + console.log('results:', results); + fail('Should have failed to query.'); + done(); + }, (error) => { + done(); + }); + }); + + it('query match on array value', (done) => { + var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'}; + var 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); + }); + }); + +}); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js new file mode 100644 index 0000000000..e8e7258c10 --- /dev/null +++ b/spec/ParseRelation.spec.js @@ -0,0 +1,338 @@ +// 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"}); + +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})); + }; + + Parse.Object.saveAll(childObjects, expectSuccess({ + success: function(list) { + 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(); + } + })); + } + })); + } + })); + }); + + 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})); + } + + Parse.Object.saveAll(childObjects, { + success: function(list) { + 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(list) { + ok(false, "This shouldn't have failed"); + done(); + } + }); + + } + }); + } + }); + } + }); + + }); + + 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})); + } + + var parent; + var relation; + + Parse.Object.saveAll(childObjects).then(function(list) { + 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(); + }); + }); + + + it("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})); + } + + 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]); + 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(); + } + }); + } + }); + } + }); + }); + + 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})); + } + + 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(); + } + }); + } + }); + } + }); + }); + + 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(); + }); + }); + + 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(); + }); + }); + + 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(); + }); + }); + +}); + diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js new file mode 100644 index 0000000000..44476f1bd2 --- /dev/null +++ b/spec/ParseRole.spec.js @@ -0,0 +1,62 @@ + + +// 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. + +describe('Parse Role testing', () => { + + it('Do a bunch of basic role testing', (done) => { + + var user; + var role; + + createTestUser().then((x) => { + user = x; + role = new Parse.Object('_Role'); + role.set('name', 'Foos'); + var users = role.relation('users'); + users.add(user); + return role.save({}, { useMasterKey: true }); + }).then((x) => { + 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((x) => { + 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(); + }).then((x) => { + fail('Should not have been able to save.'); + }, (e) => { + done(); + }); + + }); + +}); + diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js new file mode 100644 index 0000000000..cd7e850f74 --- /dev/null +++ b/spec/ParseUser.spec.js @@ -0,0 +1,1595 @@ +// This is a port of the test suite: +// hungry/js/test/parse_user_test.js +// +// Things that we didn't port: +// Tests that involve revocable sessions. +// Tests that involve sending password reset emails. + +var request = require('request'); +var crypto = require('../crypto'); + +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 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 login wrong username", (done) => { + Parse.User.signUp("asdf", "zxcv", null, { + success: function(user) { + Parse.User.logIn("non_existent_user", "asdf3", + expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + } + }); + }); + + it("user login wrong password", (done) => { + Parse.User.signUp("asdf", "zxcv", null, { + success: function(user) { + Parse.User.logIn("asdf", "asdfWrong", + expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + } + }); + }); + + it("user login", (done) => { + Parse.User.signUp("asdf", "zxcv", null, { + success: function(user) { + Parse.User.logIn("asdf", "zxcv", { + success: function(user) { + equal(user.get("username"), "asdf"); + done(); + } + }); + } + }); + }); + + it("become", (done) => { + var user = null; + var sessionToken = null; + + Parse.Promise.as().then(function() { + return Parse.User.signUp("Jason", "Parse", { "code": "red" }); + + }).then(function(newUser) { + equal(Parse.User.current(), newUser); + + user = newUser; + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + + Parse.User.logOut(); + ok(!Parse.User.current()); + + return Parse.User.become(sessionToken); + + }).then(function(newUser) { + equal(Parse.User.current(), newUser); + + ok(newUser); + equal(newUser.id, user.id); + equal(newUser.get("username"), "Jason"); + equal(newUser.get("code"), "red"); + + Parse.User.logOut(); + ok(!Parse.User.current()); + + 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 Parse.Promise.as(); + + }).then(function() { + done(); + }, function(error) { + ok(false, error); + 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", (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", (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(object) { + 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("current user", (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 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); + + // test logging out the current user + Parse.User.logOut(); + + 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(null, { + success: function () { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + user2.signUp(null, { + success: function() { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + user3.signUp(null, { + success: function() { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), true); + Parse.User.logIn("a", "password", { + success: function(user1) { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + Parse.User.logIn("b", "password", { + success: function(user2) { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + Parse.User.logIn("b", "password", { + success: function(user3) { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), true); + Parse.User.logOut(); + equal(user3.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 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(); + } + }); + } + }); + } + }); + }); + + function signUpAll(list, optionsOrCallback) { + var promise = Parse.Promise.as(); + list.forEach((user) => { + promise = promise.then(function() { + return user.signUp(); + }); + }); + promise = promise.then(function() { return list; }); + return promise._thenRunCallbacks(optionsOrCallback); + } + + it("contained in user array queries", (done) => { + var USERS = 4; + var 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); + return user; + }); + + signUpAll(userList, function(users) { + // Make a list of messages. + var messageList = range(MESSAGES).map(function(i) { + var 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(messages) { + + // 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(); + } + }); + + }); + }); + }); + + 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("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("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(); + } + }); + } + }); + } + }); + }); + + it("user sign up with container class", (done) => { + Parse.User.signUp("ilya", "mypass", { "array": ["hello"] }, { + success: function() { + 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 modified while saving with unsaved child", (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(); + } + }); + } + }); + 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"); + done(); + } + }); + }); + + + it("user loaded from localStorage from login", (done) => { + + Parse.User.signUp("alice", "password", null, { + success: function(alice) { + var id = alice.id; + Parse.User.logOut(); + + Parse.User.logIn("alice", "password", { + success: function(user) { + // 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("saving user after browser refresh", (done) => { + var _ = Parse._; + var id; + + Parse.User.signUp("alice", "password", null).then(function(alice) { + id = alice.id; + Parse.User.logOut(); + + 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); + + ok(userFromDisk.getSessionToken(), + "userFromDisk should have a sessionToken"); + + done(); + }, function(error) { + ok(false, error); + done(); + }); + }); + + 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(); + } + }); + }); + + 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(); + } + }); + }); + + it("user stupid subclassing", (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 signup class method uses subclassing", (done) => { + + var 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(); + } + }); + }); + + it("user on disk gets updated after save", (done) => { + + var SuperUser = 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) => { + + 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(); + } + })); + }); + + // Note that this mocks out client-side Facebook action rather than + // server-side. + var getMockFacebookProvider = function() { + return { + userId: "8675309", + authToken: "jenny", + expiration: new Date().toJSON(), + shouldError: false, + loggedOut: false, + synchronizedUserId: null, + synchronizedAuthToken: null, + synchronizedExpiration: null, + + authenticate: function(options) { + if (this.shouldError) { + options.error(this, "An error occurred"); + } else if (this.shouldCancel) { + options.error(this, null); + } else { + options.success(this, { + id: this.userId, + access_token: this.authToken, + expiration_date: this.expiration + }); + } + }, + restoreAuthentication: function(authData) { + if (!authData) { + this.synchronizedUserId = null; + this.synchronizedAuthToken = null; + this.synchronizedExpiration = null; + return true; + } + this.synchronizedUserId = authData.id; + this.synchronizedAuthToken = authData.access_token; + this.synchronizedExpiration = authData.expiration_date; + return true; + }, + getAuthType: function() { + return "facebook"; + }, + deauthenticate: function() { + this.loggedOut = true; + this.restoreAuthentication(null); + } + }; + }; + + var ExtendedUser = Parse.User.extend({ + extended: function() { + return true; + } + }); + + it("log in with provider", (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 subclass."); + strictEqual(provider.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + done(); + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + it("log in with provider twice", (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.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked to facebook"); + + Parse.User.logOut(); + 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.userId === provider.synchronizedUserId); + ok(provider.authToken === 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) { + ok(false, "LogIn should have worked"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "LogIn should have worked"); + done(); + } + }); + }); + + it("log in with provider failed", (done) => { + var provider = getMockFacebookProvider(); + provider.shouldError = true; + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(false, "logIn should not have succeeded"); + }, + error: function(model, error) { + ok(error, "Error should be non-null"); + done(); + } + }); + }); + + it("log in with provider cancelled", (done) => { + var provider = getMockFacebookProvider(); + provider.shouldCancel = true; + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + ok(false, "logIn should not have succeeded"); + }, + error: function(model, error) { + ok(error === null, "Error should be null"); + done(); + } + }); + }); + + it("link with provider", (done) => { + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + var user = new Parse.User(); + user.set("username", "testLinkWithProvider"); + user.set("password", "mypass"); + user.signUp(null, { + success: function(model) { + user._linkWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + strictEqual(provider.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, provider.synchronizedExpiration); + ok(model._isLinked("facebook"), "User should be linked"); + done(); + }, + error: function(model, error) { + ok(false, "linking should have succeeded"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "signup should not have failed"); + 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(); + Parse.User._registerAuthenticationProvider(provider); + var user = new Parse.User(); + user.set("username", "testLinkWithProviderToAlreadyLinkedUser"); + user.set("password", "mypass"); + user.signUp(null, { + success: function(model) { + user._linkWith("facebook", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + strictEqual(provider.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, 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(model) { + user2._linkWith('facebook', { + success: fail, + error: function(model, error) { + expect(error.code).toEqual( + Parse.Error.ACCOUNT_ALREADY_LINKED); + done(); + }, + }); + }, + error: function(model, error) { + ok(false, "linking should have failed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "linking should have succeeded"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "signup should not have failed"); + done(); + } + }); + }); + + it("link with provider failed", (done) => { + var 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(model) { + user._linkWith("facebook", { + success: function(model) { + 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(model, error) { + ok(false, "signup should not have failed"); + done(); + } + }); + }); + + it("link with provider cancelled", (done) => { + var 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(model) { + user._linkWith("facebook", { + success: function(model) { + 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(model, error) { + ok(false, "signup should not have failed"); + done(); + } + }); + }); + + it("unlink with provider", (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.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, 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(model, error) { + ok(false, "unlinking should succeed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "linking should have worked"); + 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.userId, provider.synchronizedUserId); + strictEqual(provider.authToken, provider.synchronizedAuthToken); + strictEqual(provider.expiration, 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(model, error) { + ok(false, "linking again should succeed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "unlinking should succeed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "linking should have worked"); + 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("authenticated check", (done) => { + var user = new Parse.User(); + user.set("username", "darkhelmet"); + user.set("password", "onetwothreefour"); + ok(!user.authenticated()); + user.signUp(null, expectSuccess({ + success: function(result) { + 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 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); + 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) => { + fail(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("querying for users doesn't get session tokens", (done) => { + Parse.Promise.as().then(function() { + return Parse.User.signUp("finn", "human", { foo: "bar" }); + + }).then(function() { + Parse.User.logOut(); + + var user = new Parse.User(); + user.set("username", "jake"); + user.set("password", "dog"); + user.set("foo", "baz"); + return user.signUp(); + + }).then(function() { + Parse.User.logOut(); + + 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); + done(); + }); + }); + + it("querying for users only gets the expected fields", (done) => { + Parse.Promise.as().then(() => { + return Parse.User.signUp("finn", "human", { foo: "bar" }); + }).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(5); + done(); + }); + }); + }); + + it('user save should fail with invalid email', (done) => { + var 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(); + }); + }); + + it('user signup should error if email taken', (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'); + user2.set('password', 'test'); + user2.set('email', 'test@test.com'); + return user2.signUp(); + }).then(() => { + fail('Should not have been able to sign up.'); + done(); + }, (error) => { + 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'); + 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(); + }, (error) => { + done(); + }); + }); + + 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' + }, + 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('user get session from 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); + 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('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({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken() + }, + 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); + 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); + var b = JSON.parse(body); + expect(b.results.length).toEqual(1); + expect(typeof b.results[0].user).toEqual('object'); + expect(b.results[0].user.objectId).toEqual(user.id); + 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 b = JSON.parse(body); + expect(b.results.length).toEqual(1); + var objId = b.results[0].objectId; + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest' + }, + url: 'http://localhost:8378/1/sessions/' + objId + }, (error, response, body) => { + expect(error).toBe(null); + 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 b = JSON.parse(body); + expect(b.code).toEqual(209); + done(); + }); + }); + }); + }); + }); + + it('password format matches hosted parse', (done) => { + var hashed = '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; + crypto.compare('test', hashed) + .then((pass) => { + expect(pass).toBe(true); + done(); + }, (e) => { + 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(newUser) { + fail('Session should have been invalidated'); + done(); + }, function() { + done(); + }); + }); + +}); + diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js new file mode 100644 index 0000000000..59de11ead9 --- /dev/null +++ b/spec/RestCreate.spec.js @@ -0,0 +1,128 @@ +// These tests check the "create" functionality of the REST API. +var auth = require('../Auth'); +var cache = require('../cache'); +var Config = require('../Config'); +var DatabaseAdapter = require('../DatabaseAdapter'); +var Parse = require('parse/node').Parse; +var rest = require('../rest'); +var request = require('request'); + +var config = new Config('test'); +var database = DatabaseAdapter.getDatabaseConnection('test'); + +describe('rest create', () => { + it('handles _id', (done) => { + rest.create(config, auth.nobody(config), 'Foo', {}).then(() => { + return database.mongoFind('Foo', {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(typeof obj._id).toEqual('string'); + expect(obj.objectId).toBeUndefined(); + done(); + }); + }); + + it('handles array, object, date', (done) => { + var obj = { + array: [1, 2, 3], + object: {foo: 'bar'}, + date: Parse._encode(new Date()), + }; + rest.create(config, auth.nobody(config), 'MyClass', obj).then(() => { + return database.mongoFind('MyClass', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var mob = results[0]; + expect(mob.array instanceof Array).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(mob.date instanceof Date).toBe(true); + done(); + }); + }); + + it('handles user signup', (done) => { + var 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(); + }); + }); + + it('test facebook signup and login', (done) => { + var data = { + authData: { + facebook: { + id: '8675309', + access_token: 'jenny' + } + } + }; + 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'); + return rest.create(config, auth.nobody(config), '_User', data); + }).then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + done(); + }); + }); + + it('stores pointers with a _p_ prefix', (done) => { + var obj = { + foo: 'bar', + aPointer: { + __type: 'Pointer', + className: 'JustThePointer', + objectId: 'qwerty' + } + }; + rest.create(config, auth.nobody(config), 'APointerDarkly', obj) + .then((r) => { + return database.mongoFind('APointerDarkly', {}); + }).then((results) => { + expect(results.length).toEqual(1); + var output = results[0]; + expect(typeof output._id).toEqual('string'); + expect(typeof output._p_aPointer).toEqual('string'); + expect(output._p_aPointer).toEqual('JustThePointer$qwerty'); + expect(output.aPointer).toBeUndefined(); + done(); + }); + }); + + it("cannot set objectId", (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/classes/TestObject', + body: JSON.stringify({ + 'foo': 'bar', + 'objectId': 'hello' + }) + }, (error, response, body) => { + var b = JSON.parse(body); + expect(b.code).toEqual(105); + expect(b.error).toEqual('objectId is an invalid field name.'); + done(); + }); + }); + +}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js new file mode 100644 index 0000000000..08d0176654 --- /dev/null +++ b/spec/RestQuery.spec.js @@ -0,0 +1,95 @@ +// These tests check the "find" functionality of the REST API. +var auth = require('../Auth'); +var cache = require('../cache'); +var Config = require('../Config'); +var rest = require('../rest'); + +var config = new Config('test'); +var nobody = auth.nobody(config); + +describe('rest query', () => { + 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(); + }); + }); + + // Created to test a scenario in AnyPic + it('query with include', (done) => { + var photo = { + foo: 'bar' + }; + var user = { + username: 'aUsername', + password: 'aPassword' + }; + var activity = { + type: 'comment', + photo: { + __type: 'Pointer', + className: 'TestPhoto', + objectId: '' + }, + fromUser: { + __type: 'Pointer', + className: '_User', + objectId: '' + } + }; + var queryWhere = { + photo: { + __type: 'Pointer', + className: 'TestPhoto', + objectId: '' + }, + type: 'comment' + }; + var queryOptions = { + include: 'fromUser', + order: 'createdAt', + 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); }); + }); + +}); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js new file mode 100644 index 0000000000..abf178ab03 --- /dev/null +++ b/spec/Schema.spec.js @@ -0,0 +1,134 @@ +// These tests check that the Schema operates correctly. +var Config = require('../Config'); +var Schema = require('../Schema'); + +var config = new Config('test'); + +describe('Schema', () => { + it('can validate one object', (done) => { + config.database.loadSchema().then((schema) => { + return schema.validateObject('TestObject', {a: 1, b: 'yo', c: false}); + }).then((schema) => { + done(); + }, (error) => { + fail(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((schema) => { + 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((schema) => { + var query = new Parse.Query('Stuff'); + return query.find(); + }).then((results) => { + fail('Class permissions should have rejected this query.'); + done(); + }, (e) => { + 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((schema) => { + var query = new Parse.Query('Stuff'); + return query.find(); + }).then((results) => { + done(); + }, (e) => { + fail('Class permissions should have allowed this query.'); + done(); + }); + }); + + it('class-level permissions test get', (done) => { + var user; + var obj; + 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 = {}; + var get = {}; + get[user.id] = true; + return schema.setPermissions('Stuff', { + 'find': find, + 'get': get + }); + }).then((schema) => { + 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) => { + fail('Class permissions should have rejected this query.'); + done(); + }, (e) => { + var query = new Parse.Query('Stuff'); + return query.get(obj.id).then((o) => { + done(); + }, (e) => { + fail('Class permissions should have allowed this get query'); + }); + }); + }); +}); diff --git a/spec/helper.js b/spec/helper.js new file mode 100644 index 0000000000..255d61f810 --- /dev/null +++ b/spec/helper.js @@ -0,0 +1,217 @@ +// Sets up a Parse API server for testing. + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; + +var cache = require('../cache'); +var DatabaseAdapter = require('../DatabaseAdapter'); +var express = require('express'); +var facebook = require('../facebook'); +var ParseServer = require('../index').ParseServer; + +var databaseURI = process.env.DATABASE_URI; +var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js'; + +// Set up an API server for testing +var api = new ParseServer({ + databaseURI: databaseURI, + cloud: cloudMain, + appId: 'test', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test' +}); + +var app = express(); +app.use('/1', api); +var port = 8378; +var server = app.listen(port); + +// Set up a Parse client to talk to our test API server +var 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(); + +beforeEach(function(done) { + Parse.initialize('test', 'test', 'test'); + mockFacebook(); + Parse.User.enableUnsafeCurrentUser(); + done(); +}); + +afterEach(function(done) { + Parse.User.logOut(); + Parse.Promise.as().then(() => { + return clearData(); + }).then(() => { + done(); + }, (error) => { + console.log('error in clearData', error); + done(); + }); +}); + +var TestObject = Parse.Object.extend({ + className: "TestObject" +}); +var Item = Parse.Object.extend({ + className: "Item" +}); +var 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 }); +} + +function createTestUser(success, error) { + var 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; + } +} + +// Mark the tests that are known to not work. +function notWorking() {} + +// Shims for compatibility with the old qunit tests. +function ok(bool, message) { + expect(bool).toBeTruthy(message); +} +function equal(a, b, message) { + expect(a).toEqual(b, message); +} +function strictEqual(a, b, message) { + expect(a).toBe(b, message); +} +function notEqual(a, b, message) { + expect(a).not.toEqual(b, message); +} +function expectSuccess(params) { + return { + success: params.success, + error: function(e) { + console.log('got error', e); + fail('failure happened in expectSuccess'); + }, + } +} +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) { + return -1 != arr.indexOf(item); +} + +// Normalizes a JSON object. +function normalize(obj) { + if (typeof obj !== 'object') { + return JSON.stringify(obj); + } + if (obj instanceof Array) { + return '[' + obj.map(normalize).join(', ') + ']'; + } + var answer = '{'; + for (key of Object.keys(obj).sort()) { + answer += key + ': '; + answer += normalize(obj[key]); + answer += ', '; + } + answer += '}'; + return answer; +} + +// Asserts two json structures are equal. +function jequal(o1, o2) { + expect(normalize(o1)).toEqual(normalize(o2)); +} + +function range(n) { + var answer = []; + for (var i = 0; i < n; i++) { + answer.push(i); + } + return answer; +} + +function mockFacebook() { + facebook.validateUserId = function(userId, accessToken) { + if (userId === '8675309' && accessToken === 'jenny') { + return Promise.resolve(); + } + return Promise.reject(); + }; + facebook.validateAppId = function(appId, accessToken) { + if (accessToken === 'jenny') { + return Promise.resolve(); + } + return Promise.reject(); + }; +} + +function clearData() { + var promises = []; + for (conn in DatabaseAdapter.dbConnections) { + promises.push(DatabaseAdapter.dbConnections[conn].deleteEverything()); + } + return Promise.all(promises); +} + +// This is polluting, but, it makes it way easier to directly port old tests. +global.Parse = Parse; +global.TestObject = TestObject; +global.Item = Item; +global.Container = Container; +global.create = create; +global.createTestUser = createTestUser; +global.notWorking = notWorking; +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; diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000000..b1aae6661f --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,10 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "*spec.js" + ], + "helpers": [ + "helper.js" + ] +} + diff --git a/spec/transform.spec.js b/spec/transform.spec.js new file mode 100644 index 0000000000..559d787b50 --- /dev/null +++ b/spec/transform.spec.js @@ -0,0 +1,154 @@ +// These tests are unit tests designed to only test transform.js. + +var transform = require('../transform'); + +var dummyConfig = { + schema: { + data: {}, + getExpectedType: function(className, key) { + if (key == 'userPointer') { + return '*_User'; + } + return; + } + } +}; + + +describe('transformCreate', () => { + + it('a basic number', (done) => { + var input = {five: 5}; + var output = transform.transformCreate(dummyConfig, null, input); + 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" + }; + var output = transform.transformCreate(dummyConfig, null, input); + expect(output._created_at instanceof Date).toBe(true); + expect(output._updated_at instanceof Date).toBe(true); + done(); + }); + + it('array of pointers', (done) => { + var pointer = { + __type: 'Pointer', + objectId: 'myId', + className: 'Blah', + }; + var out = transform.transformCreate(dummyConfig, null, {pointers: [pointer]}); + jequal([pointer], out.pointers); + done(); + }); + + it('a delete op', (done) => { + var input = {deleteMe: {__op: 'Delete'}}; + var output = transform.transformCreate(dummyConfig, null, input); + jequal(output, {}); + done(); + }); + + it('basic ACL', (done) => { + var input = {ACL: {'0123': {'read': true, 'write': true}}}; + var output = transform.transformCreate(dummyConfig, null, input); + // This just checks that it doesn't crash, but it should check format. + done(); + }); +}); + +describe('transformWhere', () => { + it('objectId', (done) => { + var out = transform.transformWhere(dummyConfig, null, {objectId: 'foo'}); + expect(out._id).toEqual('foo'); + done(); + }); + + it('objectId in a list', (done) => { + var input = { + objectId: {'$in': ['one', 'two', 'three']}, + }; + var output = transform.transformWhere(dummyConfig, null, input); + jequal(input.objectId, output._id); + done(); + }); +}); + +describe('untransformObject', () => { + it('built-in timestamps', (done) => { + var input = {createdAt: new Date(), updatedAt: new Date()}; + var output = transform.untransformObject(dummyConfig, null, input); + expect(typeof output.createdAt).toEqual('string'); + expect(typeof output.updatedAt).toEqual('string'); + done(); + }); +}); + +describe('transformKey', () => { + it('throws out _password', (done) => { + try { + transform.transformKey(dummyConfig, '_User', '_password'); + fail('should have thrown'); + } catch (e) { + done(); + } + }); +}); + +describe('transform schema key changes', () => { + + it('changes new pointer key', (done) => { + var input = { + somePointer: {__type: 'Pointer', className: 'Micro', objectId: 'oft'} + }; + var output = transform.transformCreate(dummyConfig, null, input); + 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'} + }; + var output = transform.transformCreate(dummyConfig, null, input); + expect(typeof output._p_userPointer).toEqual('string'); + expect(output._p_userPointer).toEqual('_User$qwerty'); + done(); + }); + + it('changes ACL storage to _rperm and _wperm', (done) => { + var input = { + ACL: { + "*": { "read": true }, + "Kevin": { "write": true } + } + }; + var output = transform.transformCreate(dummyConfig, null, input); + expect(typeof output._rperm).toEqual('object'); + expect(typeof output._wperm).toEqual('object'); + expect(output.ACL).toBeUndefined(); + expect(output._rperm[0]).toEqual('*'); + expect(output._wperm[0]).toEqual('Kevin'); + done(); + }); + + it('untransforms from _rperm and _wperm to ACL', (done) => { + var input = { + _rperm: ["*"], + _wperm: ["Kevin"] + }; + var output = transform.untransformObject(dummyConfig, null, input); + expect(typeof output.ACL).toEqual('object'); + expect(output._rperm).toBeUndefined(); + expect(output._wperm).toBeUndefined(); + expect(output.ACL['*']['read']).toEqual(true); + expect(output.ACL['Kevin']['write']).toEqual(true); + done(); + }); + +}); diff --git a/testing-routes.js b/testing-routes.js new file mode 100644 index 0000000000..85db148516 --- /dev/null +++ b/testing-routes.js @@ -0,0 +1,73 @@ +// testing-routes.js + +var express = require('express'), + cache = require('./cache'), + middlewares = require('./middlewares'), + rack = require('hat').rack(); + +var router = express.Router(); + +// creates a unique app in the cache, with a collection prefix +function createApp(req, res) { + var appId = rack(); + cache.apps[appId] = { + 'collectionPrefix': appId + '_', + 'masterKey': 'master' + }; + var 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); +} + +// deletes all collections with the collectionPrefix of the app +function clearApp(req, res) { + if (!req.auth.isMaster) { + return res.status(401).send({"error": "unauthorized"}); + } + req.database.deleteEverything().then(() => { + res.status(200).send({}); + }); +} + +// deletes all collections and drops the app from cache +function dropApp(req, res) { + if (!req.auth.isMaster) { + return res.status(401).send({"error": "unauthorized"}); + } + req.database.deleteEverything().then(() => { + delete cache.apps[req.config.applicationId]; + res.status(200).send({}); + }); +} + +// Lets just return a success response and see what happens. +function notImplementedYet(req, res) { + res.status(200).send({}); +} + +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_create_app', createApp); +router.post('/rest_drop_app', + middlewares.handleParseHeaders, dropApp); +router.post('/rest_configure_app', + middlewares.handleParseHeaders, notImplementedYet); + +module.exports = { + router: router +}; \ No newline at end of file diff --git a/transform.js b/transform.js new file mode 100644 index 0000000000..9b6b1bf2d3 --- /dev/null +++ b/transform.js @@ -0,0 +1,717 @@ +var mongodb = require('mongodb'); +var Parse = require('parse/node').Parse; + +// TODO: Turn this into a helper library for the database adapter. + +// Transforms a key-value pair from REST API form to Mongo form. +// This is the main entry point for converting anything from REST form +// to Mongo form; no conversion should happen that doesn't pass +// through this function. +// Schema should already be loaded. +// +// There are several options that can help transform: +// +// query: true indicates that query constraints like $lt are allowed in +// the value. +// +// update: true indicates that __op operators like Add and Delete +// in the value are converted to a mongo update form. Otherwise they are +// converted to static data. +// +// validate: true indicates that key names are to be validated. +// +// Returns an object with {key: key, value: value}. +function transformKeyValue(schema, className, restKey, restValue, options) { + options = options || {}; + + // 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': + 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 '_rperm': + case '_wperm': + return {key: key, value: restValue}; + break; + case 'authData.anonymous.id': + if (options.query) { + return {key: '_auth_data_anonymous.id', value: restValue}; + } + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + key); + break; + case 'authData.facebook.id': + if (options.query) { + // Special-case auth data. + return {key: '_auth_data_facebook.id', value: restValue}; + } + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + key); + break; + case '$or': + if (!options.query) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'you can only use $or in queries'); + } + if (!(restValue instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'bad $or format - use an array value'); + } + var mongoSubqueries = restValue.map((s) => { + return transformWhere(schema, className, s); + }); + return {key: '$or', value: mongoSubqueries}; + case '$and': + if (!options.query) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'you can only use $and in queries'); + } + if (!(restValue instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'bad $and format - use an array value'); + } + var mongoSubqueries = restValue.map((s) => { + return transformWhere(schema, className, s); + }); + return {key: '$and', value: mongoSubqueries}; + default: + if (options.validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'invalid key name: ' + key); + } + } + + // Handle special schema key changes + // TODO: it seems like this is likely to have edge cases where + // pointer types are missed + var expected = undefined; + if (schema && schema.getExpectedType) { + expected = schema.getExpectedType(className, key); + } + if ((expected && expected[0] == '*') || + (!expected && restValue && restValue.__type == 'Pointer')) { + key = '_p_' + key; + } + var inArray = (expected === 'array'); + + // Handle query constraints + if (options.query) { + value = transformConstraint(restValue, inArray); + if (value !== CannotTransform) { + return {key: key, value: value}; + } + } + + if (inArray && options.query && !(restValue instanceof Array)) { + return { + key: key, value: [restValue] + }; + } + + // Handle atomic values + var value = transformAtom(restValue, false, options); + if (value !== CannotTransform) { + if (timeField && (typeof value === 'string')) { + value = new Date(value); + } + return {key: key, value: value}; + } + + // ACLs are handled before this method is called + // If an ACL key still exists here, something is wrong. + if (key === 'ACL') { + throw 'There was a problem transforming an ACL.'; + } + + + + // Handle arrays + if (restValue instanceof Array) { + if (options.query) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'cannot use array as query param'); + } + value = restValue.map((restObj) => { + var out = transformKeyValue(schema, className, restKey, restObj, { inArray: true }); + return out.value; + }); + return {key: key, value: value}; + } + + // Handle update operators + value = transformUpdateOperator(restValue, !options.update); + if (value !== CannotTransform) { + return {key: key, value: value}; + } + + // Handle normal objects by recursing + value = {}; + for (var subRestKey in restValue) { + var subRestValue = restValue[subRestKey]; + var out = transformKeyValue(schema, className, subRestKey, subRestValue, { inObject: true }); + // For recursed objects, keep the keys in rest format + value[subRestKey] = out.value; + } + return {key: key, value: value}; +} + + +// Main exposed method to help run queries. +// restWhere is the "where" clause in REST API form. +// Returns the mongo form of the query. +// Throws a Parse.Error if the input query is invalid. +function transformWhere(schema, className, restWhere) { + var mongoWhere = {}; + if (restWhere['ACL']) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'Cannot query on ACL.'); + } + for (var restKey in restWhere) { + var out = transformKeyValue(schema, className, restKey, restWhere[restKey], + {query: true, validate: true}); + mongoWhere[out.key] = out.value; + } + return mongoWhere; +} + +// Main exposed method to create new objects. +// restCreate is the "create" clause in REST API form. +// Returns the mongo form of the object. +function transformCreate(schema, className, restCreate) { + var mongoCreate = transformACL(restCreate); + for (var restKey in restCreate) { + var out = transformKeyValue(schema, className, restKey, restCreate[restKey]); + if (out.value !== undefined) { + mongoCreate[out.key] = out.value; + } + } + return mongoCreate; +} + +// Main exposed method to help update old objects. +function transformUpdate(schema, className, restUpdate) { + if (!restUpdate) { + throw 'got empty restUpdate'; + } + var mongoUpdate = {}; + var acl = transformACL(restUpdate); + if (acl._rperm || acl._wperm) { + mongoUpdate['$set'] = {}; + if (acl._rperm) { + mongoUpdate['$set']['_rperm'] = acl._rperm; + } + if (acl._wperm) { + mongoUpdate['$set']['_wperm'] = acl._wperm; + } + } + + for (var restKey in restUpdate) { + var out = transformKeyValue(schema, className, restKey, restUpdate[restKey], + {update: true}); + + // If the output value is an object with any $ keys, it's an + // operator that needs to be lifted onto the top level update + // object. + if (typeof out.value === 'object' && out.value !== null && + out.value.__op) { + mongoUpdate[out.value.__op] = mongoUpdate[out.value.__op] || {}; + mongoUpdate[out.value.__op][out.key] = out.value.arg; + } else { + mongoUpdate['$set'] = mongoUpdate['$set'] || {}; + mongoUpdate['$set'][out.key] = out.value; + } + } + + return mongoUpdate; +} + +// Transforms a REST API formatted ACL object to our two-field mongo format. +// This mutates the restObject passed in to remove the ACL key. +function transformACL(restObject) { + var output = {}; + if (!restObject['ACL']) { + return output; + } + var acl = restObject['ACL']; + var rperm = []; + var wperm = []; + for (var entry in acl) { + if (acl[entry].read) { + rperm.push(entry); + } + if (acl[entry].write) { + wperm.push(entry); + } + } + if (rperm.length) { + output._rperm = rperm; + } + if (wperm.length) { + output._wperm = wperm; + } + delete restObject.ACL; + return output; +} + +// Transforms a mongo format ACL to a REST API format ACL key +// This mutates the mongoObject passed in to remove the _rperm/_wperm keys +function untransformACL(mongoObject) { + var output = {}; + if (!mongoObject['_rperm'] && !mongoObject['_wperm']) { + return output; + } + var acl = {}; + var rperm = mongoObject['_rperm'] || []; + var wperm = mongoObject['_wperm'] || []; + rperm.map((entry) => { + if (!acl[entry]) { + acl[entry] = {read: true}; + } else { + acl[entry]['read'] = true; + } + }); + wperm.map((entry) => { + if (!acl[entry]) { + acl[entry] = {write: true}; + } else { + acl[entry]['write'] = true; + } + }); + output['ACL'] = acl; + delete mongoObject._rperm; + delete mongoObject._wperm; + return output; +} + +// Transforms a key used in the REST API format to its mongo format. +function transformKey(schema, className, key) { + return transformKeyValue(schema, className, key, null, {validate: true}).key; +} + +// A sentinel value that helper transformations return when they +// cannot perform a transformation +function CannotTransform() {} + +// Helper function to transform an atom from REST format to Mongo format. +// An atom is anything that can't contain other expressions. So it +// includes things where objects are used to represent other +// datatypes, like pointers and dates, but it does not include objects +// or arrays with generic stuff inside. +// If options.inArray is true, we'll leave it in REST format. +// If options.inObject is true, we'll leave files in REST format. +// Raises an error if this cannot possibly be valid REST format. +// Returns CannotTransform if it's just not an atom, or if force is +// true, throws an error. +function transformAtom(atom, force, options) { + options = options || {}; + var inArray = options.inArray; + var inObject = options.inObject; + switch(typeof atom) { + case 'string': + case 'number': + case 'boolean': + return atom; + + case 'undefined': + 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; + } + + if (atom === null) { + return atom; + } + + // TODO: check validity harder for the __type-defined types + if (atom.__type == 'Pointer') { + if (!inArray && !inObject) { + return atom.className + '$' + atom.objectId; + } + return { + __type: 'Pointer', + className: atom.className, + objectId: atom.objectId + }; + } + if (atom.__type == 'Date') { + return new Date(atom.iso); + } + if (atom.__type == 'GeoPoint') { + return [atom.longitude, atom.latitude]; + } + if (atom.__type == 'Bytes') { + return new mongodb.Binary(new Buffer(atom.base64, 'base64')); + } + if (atom.__type == 'File') { + if (!inArray && !inObject) { + return atom.name; + } + return atom; + } + + if (force) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad atom: ' + 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); + } +} + +// Transforms a query constraint from REST API format to Mongo format. +// A constraint is something with fields like $lt. +// 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, inArray) { + if (typeof constraint !== 'object' || !constraint) { + return CannotTransform; + } + + // 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 answer = {}; + for (var key of keys) { + switch(key) { + case '$lt': + case '$lte': + case '$gt': + case '$gte': + case '$exists': + case '$ne': + answer[key] = transformAtom(constraint[key], true, + {inArray: inArray}); + break; + + case '$in': + case '$nin': + var arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad ' + key + ' value'); + } + answer[key] = arr.map((v) => { + return transformAtom(v, true); + }); + break; + + case '$all': + var arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad ' + key + ' value'); + } + answer[key] = arr.map((v) => { + return transformAtom(v, true, { inArray: true }); + }); + 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 '$options': + var options = constraint[key]; + if (!answer['$regex'] || (typeof options !== 'string') + || !options.match(/^[imxs]+$/)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'got a bad $options'); + } + answer[key] = options; + 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; + + default: + if (key.match(/^\$+/)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad constraint: ' + key); + } + return CannotTransform; + } + } + return answer; +} + +// Transforms an update operator from REST format to mongo format. +// To be transformed, the input should have an __op field. +// If flatten is true, this will flatten operators to their static +// data format. For example, an increment of 2 would simply become a +// 2. +// The output for a non-flattened operator is a hash with __op being +// the mongo op, and arg being the argument. +// The output for a flattened operator is just a value. +// Returns CannotTransform if this cannot transform it. +// Returns undefined if this should be a no-op. +function transformUpdateOperator(operator, flatten) { + if (typeof operator !== 'object' || !operator.__op) { + return CannotTransform; + } + + switch(operator.__op) { + case 'Delete': + if (flatten) { + return undefined; + } else { + return {__op: '$unset', arg: ''}; + } + + case 'Increment': + if (typeof operator.amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'incrementing must provide a number'); + } + if (flatten) { + return operator.amount; + } else { + return {__op: '$inc', arg: operator.amount}; + } + + case 'Add': + case 'AddUnique': + if (!(operator.objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'objects to add must be an array'); + } + var toAdd = operator.objects.map((obj) => { + return transformAtom(obj, true, { inArray: true }); + }); + if (flatten) { + return toAdd; + } else { + var mongoOp = { + Add: '$push', + AddUnique: '$addToSet' + }[operator.__op]; + return {__op: mongoOp, arg: {'$each': toAdd}}; + } + + case 'Remove': + if (!(operator.objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'objects to remove must be an array'); + } + var toRemove = operator.objects.map((obj) => { + return transformAtom(obj, true, { inArray: true }); + }); + if (flatten) { + return []; + } else { + return {__op: '$pullAll', arg: toRemove}; + } + + default: + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + 'the ' + operator.__op + ' op is not supported yet'); + } +} + + +// Converts from a mongo-format object to a REST-format object. +// Does not strip out anything based on a lack of authentication. +function untransformObject(schema, className, mongoObject) { + switch(typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + return mongoObject; + case 'undefined': + case 'symbol': + case 'function': + throw 'bad value in untransformObject'; + case 'object': + if (mongoObject === null) { + return null; + } + + if (mongoObject instanceof Array) { + return mongoObject.map((o) => { + return untransformObject(schema, className, o); + }); + } + + if (mongoObject instanceof Date) { + return Parse._encode(mongoObject); + } + + if (mongoObject instanceof mongodb.Binary) { + return { + __type: 'Bytes', + base64: mongoObject.buffer.toString('base64') + }; + } + + var restObject = untransformACL(mongoObject); + for (var key in mongoObject) { + switch(key) { + case '_id': + restObject['objectId'] = '' + mongoObject[key]; + break; + case '_hashed_password': + restObject['password'] = mongoObject[key]; + break; + case '_acl': + case '_perishable_token': + 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 '_auth_data_anonymous': + restObject['authData'] = restObject['authData'] || {}; + restObject['authData']['anonymous'] = mongoObject[key]; + break; + case '_auth_data_facebook': + restObject['authData'] = restObject['authData'] || {}; + restObject['authData']['facebook'] = mongoObject[key]; + break; + default: + if (key.indexOf('_p_') == 0) { + var newKey = key.substring(3); + var expected; + if (schema && schema.getExpectedType) { + expected = schema.getExpectedType(className, newKey); + } + if (!expected) { + console.log( + 'Found a pointer column not in the schema, dropping it.', + className, newKey); + break; + } + if (expected && expected[0] != '*') { + console.log('Found a pointer in a non-pointer column, dropping it.', className, key); + break; + } + var objData = mongoObject[key].split('$'); + var newClass = (expected ? expected.substring(1) : objData[0]); + if (objData[0] !== newClass) { + 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 expected = schema.getExpectedType(className, key); + if (expected == 'file') { + restObject[key] = { + __type: 'File', + name: mongoObject[key] + }; + break; + } + if (expected == 'geopoint') { + restObject[key] = { + __type: 'GeoPoint', + latitude: mongoObject[key][1], + longitude: mongoObject[key][0] + }; + break; + } + } + restObject[key] = untransformObject(schema, className, + mongoObject[key]); + } + } + return restObject; + default: + throw 'unknown js type'; + } +} + +module.exports = { + transformKey: transformKey, + transformCreate: transformCreate, + transformUpdate: transformUpdate, + transformWhere: transformWhere, + untransformObject: untransformObject +}; + diff --git a/triggers.js b/triggers.js new file mode 100644 index 0000000000..9756051a87 --- /dev/null +++ b/triggers.js @@ -0,0 +1,99 @@ +// triggers.js + +var Parse = require('parse/node').Parse; + +var Types = { + beforeSave: 'beforeSave', + afterSave: 'afterSave', + beforeDelete: 'beforeDelete', + afterDelete: 'afterDelete' +}; + +var getTrigger = function(className, triggerType) { + if (Parse.Cloud.Triggers + && Parse.Cloud.Triggers[triggerType] + && Parse.Cloud.Triggers[triggerType][className]) { + return Parse.Cloud.Triggers[triggerType][className]; + } + return undefined; +}; + +var getRequestObject = function(triggerType, auth, parseObject, originalParseObject) { + var request = { + triggerName: triggerType, + object: parseObject, + master: false + }; + if (originalParseObject) { + request.original = originalParseObject; + } + if (!auth) { + return request; + } + if (auth.isMaster) { + request['master'] = true; + } + if (auth.user) { + request['user'] = auth.user; + } + // TODO: Add installation to Auth? + if (auth.installationId) { + request['installationId'] = auth.installationId; + } + return request; +}; + +// Creates the response object, and uses the request object to pass data +// The API will call this with REST API formatted objects, this will +// transform them to Parse.Object instances expected by Cloud Code. +// Any changes made to the object in a beforeSave will be included. +var getResponseObject = function(request, resolve, reject) { + return { + success: function() { + var response = {}; + if (request.triggerName === Types.beforeSave) { + response['object'] = request.object.toJSON(); + } + return resolve(response); + }, + error: function(error) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, error); + } + } +}; + +// To be used as part of the promise chain when saving/deleting an object +// Will resolve successfully if no trigger is configured +// Resolves to an object, empty or containing an object key. A beforeSave +// trigger will set the object key to the rest format object to save. +// originalParseObject is optional, we only need that for befote/afterSave functions +var maybeRunTrigger = function(triggerType, auth, parseObject, originalParseObject) { + if (!parseObject) { + return Promise.resolve({}); + } + return new Promise(function (resolve, reject) { + var trigger = getTrigger(parseObject.className, triggerType); + if (!trigger) return resolve({}); + var request = getRequestObject(triggerType, auth, parseObject, originalParseObject); + var response = getResponseObject(request, resolve, reject); + trigger(request, response); + }); +}; + +// Converts a REST-format object to a Parse.Object +// data is either className or an object +function inflate(data, restObject) { + var copy = typeof data == 'object' ? data : {className: data}; + for (var key in restObject) { + copy[key] = restObject[key]; + } + return Parse.Object.fromJSON(copy); +} + +module.exports = { + getTrigger: getTrigger, + getRequestObject: getRequestObject, + inflate: inflate, + maybeRunTrigger: maybeRunTrigger, + Types: Types +}; diff --git a/users.js b/users.js new file mode 100644 index 0000000000..3820931fa7 --- /dev/null +++ b/users.js @@ -0,0 +1,180 @@ +// These methods handle the User-related routes. + +var mongodb = require('mongodb'); +var Parse = require('parse/node').Parse; +var rack = require('hat').rack(); + +var Auth = require('./Auth'); +var crypto = require('./crypto'); +var facebook = require('./facebook'); +var PromiseRouter = require('./PromiseRouter'); +var rest = require('./rest'); +var RestWrite = require('./RestWrite'); + +var router = new PromiseRouter(); + +// Returns a promise for a {status, response, location} object. +function handleCreate(req) { + return rest.create(req.config, req.auth, + '_User', req.body); +} + +// Returns a promise for a {response} object. +function handleLogIn(req) { + + // Use query parameters instead if provided in url + if (!req.body.username && req.query.username) { + req.body = req.query; + } + + // TODO: use the right error codes / descriptions. + if (!req.body.username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, + 'username is required.'); + } + if (!req.body.password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, + 'password is required.'); + } + + var user; + return req.database.find('_User', {username: req.body.username}) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.'); + } + user = results[0]; + return crypto.compare(req.body.password, user.password); + }).then((correct) => { + if (!correct) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.'); + } + var token = 'r:' + rack(); + user.sessionToken = token; + delete user.password; + + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: user.objectId + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false, + expiresAt: 0, + installationId: req.info.installationId + }; + var create = new RestWrite(req.config, Auth.master(req.config), + '_Session', null, sessionData); + return create.execute(); + }).then(() => { + return {response: user}; + }); +} + +// Returns a promise that resolves to a {response} object. +// TODO: share code with classes.js +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (typeof req.body.keys == 'string') { + options.keys = req.body.keys; + } + if (req.body.include) { + options.include = String(req.body.include); + } + if (req.body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); + } + + return rest.find(req.config, req.auth, + '_User', req.body.where, options) + .then((response) => { + return {response: response}; + }); + +} + +// Returns a promise for a {response} object. +function handleGet(req) { + return rest.find(req.config, req.auth, '_User', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +function handleMe(req) { + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + {_session_token: req.info.sessionToken}, + {include: 'user'}) + .then((response) => { + if (!response.results || response.results.length == 0 || + !response.results[0].user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + var user = response.results[0].user; + return {response: user}; + } + }); +} + +function handleDelete(req) { + return rest.del(req.config, req.auth, + req.params.className, req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +function handleUpdate(req) { + return rest.update(req.config, req.auth, '_User', + req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +function notImplementedYet(req) { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, + 'This path is not implemented yet.'); +} + +router.route('POST', '/users', handleCreate); +router.route('GET', '/login', handleLogIn); +router.route('GET', '/users/me', handleMe); +router.route('GET', '/users/:objectId', handleGet); +router.route('PUT', '/users/:objectId', handleUpdate); +router.route('GET', '/users', handleFind); +router.route('DELETE', '/users/:objectId', handleDelete); + +router.route('POST', '/requestPasswordReset', notImplementedYet); + +module.exports = router;