Adds receipt validation endpoint
This commit is contained in:
208
spec/PurchaseValidation.spec.js
Normal file
208
spec/PurchaseValidation.spec.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
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("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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -105,9 +105,9 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) {
|
|||||||
// Returns a promise that resolves to the new schema.
|
// Returns a promise that resolves to the new schema.
|
||||||
// This does not update this.schema, because in a situation like a
|
// This does not update this.schema, because in a situation like a
|
||||||
// batch request, that could confuse other users of the schema.
|
// 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 this.loadSchema().then((schema) => {
|
||||||
return schema.validateObject(className, object);
|
return schema.validateObject(className, object, query);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ RestWrite.prototype.execute = function() {
|
|||||||
|
|
||||||
// Validates this operation against the schema.
|
// Validates this operation against the schema.
|
||||||
RestWrite.prototype.validateSchema = function() {
|
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.
|
// Runs any beforeSave triggers against this operation.
|
||||||
|
|||||||
@@ -59,9 +59,23 @@ var defaultColumns = {
|
|||||||
"sessionToken": {type:'String'},
|
"sessionToken": {type:'String'},
|
||||||
"expiresAt": {type:'Date'},
|
"expiresAt": {type:'Date'},
|
||||||
"createdWith": {type:'Object'}
|
"createdWith": {type:'Object'}
|
||||||
|
},
|
||||||
|
_Product: {
|
||||||
|
"productIdentifier": {type:'String'},
|
||||||
|
"download": {type:'File'},
|
||||||
|
"downloadName": {type:'String'},
|
||||||
|
"icon": {type:'File'},
|
||||||
|
"order": {type:'Number'},
|
||||||
|
"title": {type:'String'},
|
||||||
|
"subtile": {type:'String'},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
var requiredColumns = {
|
||||||
|
_Product: ["productIdentifier", "icon", "order", "title", "subtitle"]
|
||||||
|
}
|
||||||
|
|
||||||
// Valid classes must:
|
// Valid classes must:
|
||||||
// Be one of _User, _Installation, _Role, _Session OR
|
// Be one of _User, _Installation, _Role, _Session OR
|
||||||
// Be a join table OR
|
// Be a join table OR
|
||||||
@@ -75,6 +89,7 @@ function classNameIsValid(className) {
|
|||||||
className === '_Session' ||
|
className === '_Session' ||
|
||||||
className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects.
|
className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects.
|
||||||
className === '_Role' ||
|
className === '_Role' ||
|
||||||
|
className === '_Product' ||
|
||||||
joinClassRegex.test(className) ||
|
joinClassRegex.test(className) ||
|
||||||
//Class names have the same constraints as field names, but also allow the previous additional names.
|
//Class names have the same constraints as field names, but also allow the previous additional names.
|
||||||
fieldNameIsValid(className)
|
fieldNameIsValid(className)
|
||||||
@@ -565,7 +580,7 @@ function thenValidateField(schemaPromise, className, key, type) {
|
|||||||
// Validates an object provided in REST format.
|
// Validates an object provided in REST format.
|
||||||
// Returns a promise that resolves to the new schema if this object is
|
// Returns a promise that resolves to the new schema if this object is
|
||||||
// valid.
|
// valid.
|
||||||
Schema.prototype.validateObject = function(className, object) {
|
Schema.prototype.validateObject = function(className, object, query) {
|
||||||
var geocount = 0;
|
var geocount = 0;
|
||||||
var promise = this.validateClassName(className);
|
var promise = this.validateClassName(className);
|
||||||
for (var key in object) {
|
for (var key in object) {
|
||||||
@@ -586,9 +601,48 @@ Schema.prototype.validateObject = function(className, object) {
|
|||||||
}
|
}
|
||||||
promise = thenValidateField(promise, className, key, expected);
|
promise = thenValidateField(promise, className, key, expected);
|
||||||
}
|
}
|
||||||
|
promise = thenValidateRequiredColumns(promise, className, object, query);
|
||||||
return promise;
|
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
|
// Validates an operation passes class-level-permissions set in the schema
|
||||||
Schema.prototype.validatePermission = function(className, aclGroup, operation) {
|
Schema.prototype.validatePermission = function(className, aclGroup, operation) {
|
||||||
if (!this.perms[className] || !this.perms[className][operation]) {
|
if (!this.perms[className] || !this.perms[className][operation]) {
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ function ParseServer(args) {
|
|||||||
require('./functions'),
|
require('./functions'),
|
||||||
require('./schemas'),
|
require('./schemas'),
|
||||||
new PushController(pushAdapter).getExpressRouter(),
|
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) {
|
if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) {
|
||||||
routers.push(require('./global_config'));
|
routers.push(require('./global_config'));
|
||||||
|
|||||||
90
src/validate_purchase.js
Normal file
90
src/validate_purchase.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user