diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js new file mode 100644 index 0000000000..a49374f8c1 --- /dev/null +++ b/spec/PurchaseValidation.spec.js @@ -0,0 +1,209 @@ +var request = require("request"); + + + +function createProduct() { + const file = new Parse.File("name", { + base64: new Buffer("download_file", "utf-8").toString("base64") + }, "text"); + return file.save().then(function(){ + var product = new Parse.Object("_Product"); + product.set({ + download: file, + icon: file, + title: "a product", + subtitle: "a product", + order: 1, + productIdentifier: "a-product" + }) + return product.save(); + }) + +} + + +describe("test validate_receipt endpoint", () => { + + beforeEach( done => { + createProduct().then(done).fail(function(err){ + console.error(err); + done(); + }) + }) + + it("should bypass appstore validation", (done) => { + + request.post({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest'}, + url: 'http://localhost:8378/1/validate_purchase', + json: true, + body: { + productIdentifier: "a-product", + receipt: { + __type: "Bytes", + base64: new Buffer("receipt", "utf-8").toString("base64") + }, + bypassAppStoreValidation: true + } + }, function(err, res, body){ + if (typeof body != "object") { + fail("Body is not an object"); + done(); + } else { + expect(body.__type).toEqual("File"); + const url = body.url; + request.get({ + url: url + }, function(err, res, body) { + expect(body).toEqual("download_file"); + done(); + }); + } + }); + }); + + it("should fail for missing receipt", (done) => { + request.post({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest'}, + url: 'http://localhost:8378/1/validate_purchase', + json: true, + body: { + productIdentifier: "a-product", + bypassAppStoreValidation: true + } + }, function(err, res, body){ + if (typeof body != "object") { + fail("Body is not an object"); + done(); + } else { + expect(body.code).toEqual(Parse.Error.INVALID_JSON); + done(); + } + }); + }); + + it("should fail for missing product identifier", (done) => { + request.post({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest'}, + url: 'http://localhost:8378/1/validate_purchase', + json: true, + body: { + receipt: { + __type: "Bytes", + base64: new Buffer("receipt", "utf-8").toString("base64") + }, + bypassAppStoreValidation: true + } + }, function(err, res, body){ + if (typeof body != "object") { + fail("Body is not an object"); + done(); + } else { + expect(body.code).toEqual(Parse.Error.INVALID_JSON); + done(); + } + }); + }); + + it("should bypass appstore validation and not find product", (done) => { + + request.post({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest'}, + url: 'http://localhost:8378/1/validate_purchase', + json: true, + body: { + productIdentifier: "another-product", + receipt: { + __type: "Bytes", + base64: new Buffer("receipt", "utf-8").toString("base64") + }, + bypassAppStoreValidation: true + } + }, function(err, res, body){ + if (typeof body != "object") { + fail("Body is not an object"); + done(); + } else { + expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(body.error).toEqual('Object not found.'); + done(); + } + }); + }); + + it("should fail at appstore validation", (done) => { + + request.post({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest'}, + url: 'http://localhost:8378/1/validate_purchase', + json: true, + body: { + productIdentifier: "a-product", + receipt: { + __type: "Bytes", + base64: new Buffer("receipt", "utf-8").toString("base64") + }, + } + }, function(err, res, body){ + if (typeof body != "object") { + fail("Body is not an object"); + } else { + expect(body.status).toBe(21002); + expect(body.error).toBe('The data in the receipt-data property was malformed or missing.'); + } + done(); + }); + }); + + it("should not create a _Product", (done) => { + var product = new Parse.Object("_Product"); + product.save().then(function(){ + fail("Should not be able to save"); + done(); + }, function(err){ + expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); + done(); + }) + }); + + it("should be able to update a _Product", (done) => { + var query = new Parse.Query("_Product"); + query.first().then(function(product){ + product.set("title", "a new title"); + return product.save(); + }).then(function(productAgain){ + expect(productAgain.get('downloadName')).toEqual(productAgain.get('download').name()); + expect(productAgain.get("title")).toEqual("a new title"); + done(); + }).fail(function(err){ + fail(JSON.stringify(err)); + done(); + }); + }); + + it("should not be able to remove a require key in a _Product", (done) => { + var query = new Parse.Query("_Product"); + query.first().then(function(product){ + product.unset("title"); + return product.save(); + }).then(function(productAgain){ + fail("Should not succeed"); + done(); + }).fail(function(err){ + expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(err.message).toEqual("title is required."); + done(); + }); + }); + +}); diff --git a/src/ExportAdapter.js b/src/ExportAdapter.js index abcc862dcf..821c69cdca 100644 --- a/src/ExportAdapter.js +++ b/src/ExportAdapter.js @@ -105,9 +105,9 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { // 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) { +ExportAdapter.prototype.validateObject = function(className, object, query) { return this.loadSchema().then((schema) => { - return schema.validateObject(className, object); + return schema.validateObject(className, object, query); }); }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 54f5cfc996..2f1c12ec04 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -90,7 +90,7 @@ RestWrite.prototype.execute = function() { // Validates this operation against the schema. RestWrite.prototype.validateSchema = function() { - return this.config.database.validateObject(this.className, this.data); + return this.config.database.validateObject(this.className, this.data, this.query); }; // Runs any beforeSave triggers against this operation. @@ -683,6 +683,10 @@ RestWrite.prototype.runDatabaseOperation = function() { throw new Parse.Error(Parse.Error.SESSION_MISSING, 'cannot modify user ' + this.query.objectId); } + + if (this.className === '_Product' && this.data.download) { + this.data.downloadName = this.data.download.name; + } // TODO: Add better detection for ACL, ensuring a user can't be locked from // their own user record. diff --git a/src/Schema.js b/src/Schema.js index 0d60144937..54aa3d9e1f 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -59,9 +59,23 @@ var defaultColumns = { "sessionToken": {type:'String'}, "expiresAt": {type:'Date'}, "createdWith": {type:'Object'} + }, + _Product: { + "productIdentifier": {type:'String'}, + "download": {type:'File'}, + "downloadName": {type:'String'}, + "icon": {type:'File'}, + "order": {type:'Number'}, + "title": {type:'String'}, + "subtitle": {type:'String'}, } }; + +var requiredColumns = { + _Product: ["productIdentifier", "icon", "order", "title", "subtitle"] +} + // Valid classes must: // Be one of _User, _Installation, _Role, _Session OR // Be a join table OR @@ -75,6 +89,7 @@ function classNameIsValid(className) { className === '_Session' || className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. className === '_Role' || + className === '_Product' || joinClassRegex.test(className) || //Class names have the same constraints as field names, but also allow the previous additional names. fieldNameIsValid(className) @@ -565,7 +580,7 @@ function thenValidateField(schemaPromise, className, key, type) { // Validates an object provided in REST format. // Returns a promise that resolves to the new schema if this object is // valid. -Schema.prototype.validateObject = function(className, object) { +Schema.prototype.validateObject = function(className, object, query) { var geocount = 0; var promise = this.validateClassName(className); for (var key in object) { @@ -586,9 +601,48 @@ Schema.prototype.validateObject = function(className, object) { } promise = thenValidateField(promise, className, key, expected); } + promise = thenValidateRequiredColumns(promise, className, object, query); return promise; }; +// Given a schema promise, construct another schema promise that +// validates this field once the schema loads. +function thenValidateRequiredColumns(schemaPromise, className, object, query) { + return schemaPromise.then((schema) => { + return schema.validateRequiredColumns(className, object, query); + }); +} + +// Validates that all the properties are set for the object +Schema.prototype.validateRequiredColumns = function(className, object, query) { + + var columns = requiredColumns[className]; + if (!columns || columns.length == 0) { + return Promise.resolve(this); + } + + var missingColumns = columns.filter(function(column){ + if (query && query.objectId) { + if (object[column] && typeof object[column] === "object") { + // Trying to delete a required column + return object[column].__op == 'Delete'; + } + // Not trying to do anything there + return false; + } + return !object[column] + }); + + if (missingColumns.length > 0) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + missingColumns[0]+' is required.'); + } + + return Promise.resolve(this); +} + + // Validates an operation passes class-level-permissions set in the schema Schema.prototype.validatePermission = function(className, aclGroup, operation) { if (!this.perms[className] || !this.perms[className][operation]) { diff --git a/src/index.js b/src/index.js index 47b639f423..b837c4745f 100644 --- a/src/index.js +++ b/src/index.js @@ -148,7 +148,8 @@ function ParseServer(args) { require('./functions'), require('./schemas'), new PushController(pushAdapter).getExpressRouter(), - new LoggerController(loggerAdapter).getExpressRouter() + new LoggerController(loggerAdapter).getExpressRouter(), + require('./validate_purchase') ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(require('./global_config')); diff --git a/src/validate_purchase.js b/src/validate_purchase.js new file mode 100644 index 0000000000..dfcf5c0f56 --- /dev/null +++ b/src/validate_purchase.js @@ -0,0 +1,90 @@ +var PromiseRouter = require("./PromiseRouter"); +var request = require("request"); +var rest = require("./rest"); +var Auth = require("./Auth"); + +const IAP_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"; +const IAP_PRODUCTION_URL = "https://buy.itunes.apple.com/verifyReceipt"; + +const APP_STORE_ERRORS = { + 21000: "The App Store could not read the JSON object you provided.", + 21002: "The data in the receipt-data property was malformed or missing.", + 21003: "The receipt could not be authenticated.", + 21004: "The shared secret you provided does not match the shared secret on file for your account.", + 21005: "The receipt server is not currently available.", + 21006: "This receipt is valid but the subscription has expired.", + 21007: "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.", + 21008: "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead." +} + +function appStoreError(status) { + status = parseInt(status); + var errorString = APP_STORE_ERRORS[status] || "unknown error."; + return { status: status, error: errorString } +} + +function validateWithAppStore(url, receipt) { + return new Promise(function(fulfill, reject) { + request.post({ + url: url, + body: { "receipt-data": receipt }, + json: true, + }, function(err, res, body) { + var status = body.status; + if (status == 0) { + // No need to pass anything, status is OK + return fulfill(); + } + // receipt is from test and should go to test + if (status == 21007) { + return validateWithAppStore(IAP_SANDBOX_URL); + } + return reject(body); + }); + }); +} + +function getFileForProductIdentifier(productIdentifier, req) { + return rest.find(req.config, req.auth, '_Product', { productIdentifier: productIdentifier }).then(function(result){ + const products = result.results; + if (!products || products.length != 1) { + // Error not found or too many + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.') + } + + var download = products[0].download; + return Promise.resolve({response: download}); + }); +} + +function handleRequest(req) { + let receipt = req.body.receipt; + const productIdentifier = req.body.productIdentifier; + + if (!receipt || ! productIdentifier) { + // TODO: Error, malformed request + throw new Parse.Error(Parse.Error.INVALID_JSON, "missing receipt or productIdentifier"); + } + + // Transform the object if there + // otherwise assume it's in Base64 already + if (typeof receipt == "object") { + if (receipt["__type"] == "Bytes") { + receipt = receipt.base64; + } + } + + if (process.env.NODE_ENV == "test" && req.body.bypassAppStoreValidation) { + return getFileForProductIdentifier(productIdentifier, req); + } + + return validateWithAppStore(IAP_PRODUCTION_URL, receipt).then( () => { + return getFileForProductIdentifier(productIdentifier, req); + }, (error) => { + return Promise.resolve({response: appStoreError(error.status) }); + }); +} + +var router = new PromiseRouter(); +router.route("POST","/validate_purchase", handleRequest); +module.exports = router;