Skip to content

Commit 78c5292

Browse files
committed
Merge pull request #515 from flovilmart/receipt-validation
Adds receipt validation endpoint
2 parents 941984f + 6e55e59 commit 78c5292

File tree

6 files changed

+363
-5
lines changed

6 files changed

+363
-5
lines changed

spec/PurchaseValidation.spec.js

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
var request = require("request");
2+
3+
4+
5+
function createProduct() {
6+
const file = new Parse.File("name", {
7+
base64: new Buffer("download_file", "utf-8").toString("base64")
8+
}, "text");
9+
return file.save().then(function(){
10+
var product = new Parse.Object("_Product");
11+
product.set({
12+
download: file,
13+
icon: file,
14+
title: "a product",
15+
subtitle: "a product",
16+
order: 1,
17+
productIdentifier: "a-product"
18+
})
19+
return product.save();
20+
})
21+
22+
}
23+
24+
25+
describe("test validate_receipt endpoint", () => {
26+
27+
beforeEach( done => {
28+
createProduct().then(done).fail(function(err){
29+
console.error(err);
30+
done();
31+
})
32+
})
33+
34+
it("should bypass appstore validation", (done) => {
35+
36+
request.post({
37+
headers: {
38+
'X-Parse-Application-Id': 'test',
39+
'X-Parse-REST-API-Key': 'rest'},
40+
url: 'http://localhost:8378/1/validate_purchase',
41+
json: true,
42+
body: {
43+
productIdentifier: "a-product",
44+
receipt: {
45+
__type: "Bytes",
46+
base64: new Buffer("receipt", "utf-8").toString("base64")
47+
},
48+
bypassAppStoreValidation: true
49+
}
50+
}, function(err, res, body){
51+
if (typeof body != "object") {
52+
fail("Body is not an object");
53+
done();
54+
} else {
55+
expect(body.__type).toEqual("File");
56+
const url = body.url;
57+
request.get({
58+
url: url
59+
}, function(err, res, body) {
60+
expect(body).toEqual("download_file");
61+
done();
62+
});
63+
}
64+
});
65+
});
66+
67+
it("should fail for missing receipt", (done) => {
68+
request.post({
69+
headers: {
70+
'X-Parse-Application-Id': 'test',
71+
'X-Parse-REST-API-Key': 'rest'},
72+
url: 'http://localhost:8378/1/validate_purchase',
73+
json: true,
74+
body: {
75+
productIdentifier: "a-product",
76+
bypassAppStoreValidation: true
77+
}
78+
}, function(err, res, body){
79+
if (typeof body != "object") {
80+
fail("Body is not an object");
81+
done();
82+
} else {
83+
expect(body.code).toEqual(Parse.Error.INVALID_JSON);
84+
done();
85+
}
86+
});
87+
});
88+
89+
it("should fail for missing product identifier", (done) => {
90+
request.post({
91+
headers: {
92+
'X-Parse-Application-Id': 'test',
93+
'X-Parse-REST-API-Key': 'rest'},
94+
url: 'http://localhost:8378/1/validate_purchase',
95+
json: true,
96+
body: {
97+
receipt: {
98+
__type: "Bytes",
99+
base64: new Buffer("receipt", "utf-8").toString("base64")
100+
},
101+
bypassAppStoreValidation: true
102+
}
103+
}, function(err, res, body){
104+
if (typeof body != "object") {
105+
fail("Body is not an object");
106+
done();
107+
} else {
108+
expect(body.code).toEqual(Parse.Error.INVALID_JSON);
109+
done();
110+
}
111+
});
112+
});
113+
114+
it("should bypass appstore validation and not find product", (done) => {
115+
116+
request.post({
117+
headers: {
118+
'X-Parse-Application-Id': 'test',
119+
'X-Parse-REST-API-Key': 'rest'},
120+
url: 'http://localhost:8378/1/validate_purchase',
121+
json: true,
122+
body: {
123+
productIdentifier: "another-product",
124+
receipt: {
125+
__type: "Bytes",
126+
base64: new Buffer("receipt", "utf-8").toString("base64")
127+
},
128+
bypassAppStoreValidation: true
129+
}
130+
}, function(err, res, body){
131+
if (typeof body != "object") {
132+
fail("Body is not an object");
133+
done();
134+
} else {
135+
expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND);
136+
expect(body.error).toEqual('Object not found.');
137+
done();
138+
}
139+
});
140+
});
141+
142+
it("should fail at appstore validation", (done) => {
143+
144+
request.post({
145+
headers: {
146+
'X-Parse-Application-Id': 'test',
147+
'X-Parse-REST-API-Key': 'rest'},
148+
url: 'http://localhost:8378/1/validate_purchase',
149+
json: true,
150+
body: {
151+
productIdentifier: "a-product",
152+
receipt: {
153+
__type: "Bytes",
154+
base64: new Buffer("receipt", "utf-8").toString("base64")
155+
},
156+
}
157+
}, function(err, res, body){
158+
if (typeof body != "object") {
159+
fail("Body is not an object");
160+
} else {
161+
expect(body.status).toBe(21002);
162+
expect(body.error).toBe('The data in the receipt-data property was malformed or missing.');
163+
}
164+
done();
165+
});
166+
});
167+
168+
it("should not create a _Product", (done) => {
169+
var product = new Parse.Object("_Product");
170+
product.save().then(function(){
171+
fail("Should not be able to save");
172+
done();
173+
}, function(err){
174+
expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE);
175+
done();
176+
})
177+
});
178+
179+
it("should be able to update a _Product", (done) => {
180+
var query = new Parse.Query("_Product");
181+
query.first().then(function(product){
182+
product.set("title", "a new title");
183+
return product.save();
184+
}).then(function(productAgain){
185+
expect(productAgain.get('downloadName')).toEqual(productAgain.get('download').name());
186+
expect(productAgain.get("title")).toEqual("a new title");
187+
done();
188+
}).fail(function(err){
189+
fail(JSON.stringify(err));
190+
done();
191+
});
192+
});
193+
194+
it("should not be able to remove a require key in a _Product", (done) => {
195+
var query = new Parse.Query("_Product");
196+
query.first().then(function(product){
197+
product.unset("title");
198+
return product.save();
199+
}).then(function(productAgain){
200+
fail("Should not succeed");
201+
done();
202+
}).fail(function(err){
203+
expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE);
204+
expect(err.message).toEqual("title is required.");
205+
done();
206+
});
207+
});
208+
209+
});

src/ExportAdapter.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) {
105105
// Returns a promise that resolves to the new schema.
106106
// This does not update this.schema, because in a situation like a
107107
// batch request, that could confuse other users of the schema.
108-
ExportAdapter.prototype.validateObject = function(className, object) {
108+
ExportAdapter.prototype.validateObject = function(className, object, query) {
109109
return this.loadSchema().then((schema) => {
110-
return schema.validateObject(className, object);
110+
return schema.validateObject(className, object, query);
111111
});
112112
};
113113

src/RestWrite.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ RestWrite.prototype.getUserAndRoleACL = function() {
112112

113113
// Validates this operation against the schema.
114114
RestWrite.prototype.validateSchema = function() {
115-
return this.config.database.validateObject(this.className, this.data);
115+
return this.config.database.validateObject(this.className, this.data, this.query);
116116
};
117117

118118
// Runs any beforeSave triggers against this operation.
@@ -705,6 +705,10 @@ RestWrite.prototype.runDatabaseOperation = function() {
705705
throw new Parse.Error(Parse.Error.SESSION_MISSING,
706706
'cannot modify user ' + this.query.objectId);
707707
}
708+
709+
if (this.className === '_Product' && this.data.download) {
710+
this.data.downloadName = this.data.download.name;
711+
}
708712

709713
// TODO: Add better detection for ACL, ensuring a user can't be locked from
710714
// their own user record.

src/Schema.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,23 @@ var defaultColumns = {
5959
"sessionToken": {type:'String'},
6060
"expiresAt": {type:'Date'},
6161
"createdWith": {type:'Object'}
62+
},
63+
_Product: {
64+
"productIdentifier": {type:'String'},
65+
"download": {type:'File'},
66+
"downloadName": {type:'String'},
67+
"icon": {type:'File'},
68+
"order": {type:'Number'},
69+
"title": {type:'String'},
70+
"subtitle": {type:'String'},
6271
}
6372
};
6473

74+
75+
var requiredColumns = {
76+
_Product: ["productIdentifier", "icon", "order", "title", "subtitle"]
77+
}
78+
6579
// Valid classes must:
6680
// Be one of _User, _Installation, _Role, _Session OR
6781
// Be a join table OR
@@ -75,6 +89,7 @@ function classNameIsValid(className) {
7589
className === '_Session' ||
7690
className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects.
7791
className === '_Role' ||
92+
className === '_Product' ||
7893
joinClassRegex.test(className) ||
7994
//Class names have the same constraints as field names, but also allow the previous additional names.
8095
fieldNameIsValid(className)
@@ -565,7 +580,7 @@ function thenValidateField(schemaPromise, className, key, type) {
565580
// Validates an object provided in REST format.
566581
// Returns a promise that resolves to the new schema if this object is
567582
// valid.
568-
Schema.prototype.validateObject = function(className, object) {
583+
Schema.prototype.validateObject = function(className, object, query) {
569584
var geocount = 0;
570585
var promise = this.validateClassName(className);
571586
for (var key in object) {
@@ -586,9 +601,48 @@ Schema.prototype.validateObject = function(className, object) {
586601
}
587602
promise = thenValidateField(promise, className, key, expected);
588603
}
604+
promise = thenValidateRequiredColumns(promise, className, object, query);
589605
return promise;
590606
};
591607

608+
// Given a schema promise, construct another schema promise that
609+
// validates this field once the schema loads.
610+
function thenValidateRequiredColumns(schemaPromise, className, object, query) {
611+
return schemaPromise.then((schema) => {
612+
return schema.validateRequiredColumns(className, object, query);
613+
});
614+
}
615+
616+
// Validates that all the properties are set for the object
617+
Schema.prototype.validateRequiredColumns = function(className, object, query) {
618+
619+
var columns = requiredColumns[className];
620+
if (!columns || columns.length == 0) {
621+
return Promise.resolve(this);
622+
}
623+
624+
var missingColumns = columns.filter(function(column){
625+
if (query && query.objectId) {
626+
if (object[column] && typeof object[column] === "object") {
627+
// Trying to delete a required column
628+
return object[column].__op == 'Delete';
629+
}
630+
// Not trying to do anything there
631+
return false;
632+
}
633+
return !object[column]
634+
});
635+
636+
if (missingColumns.length > 0) {
637+
throw new Parse.Error(
638+
Parse.Error.INCORRECT_TYPE,
639+
missingColumns[0]+' is required.');
640+
}
641+
642+
return Promise.resolve(this);
643+
}
644+
645+
592646
// Validates an operation passes class-level-permissions set in the schema
593647
Schema.prototype.validatePermission = function(className, aclGroup, operation) {
594648
if (!this.perms[className] || !this.perms[className][operation]) {

src/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ function ParseServer(args) {
146146
require('./functions'),
147147
require('./schemas'),
148148
new PushController(pushAdapter).getExpressRouter(),
149-
new LoggerController(loggerAdapter).getExpressRouter()
149+
new LoggerController(loggerAdapter).getExpressRouter(),
150+
require('./validate_purchase')
150151
];
151152
if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) {
152153
routers.push(require('./global_config'));

0 commit comments

Comments
 (0)