Skip to content

Commit

Permalink
hub: Add IAP verification (#3059)
Browse files Browse the repository at this point in the history
This replaces the stubbed implementation of the InAppPurchases service.
In the case of Google Play purchase validation, it does not include the
required authentication code.
  • Loading branch information
backspace committed Jul 7, 2022
1 parent d7ef333 commit 5c2e11e
Show file tree
Hide file tree
Showing 9 changed files with 462 additions and 7 deletions.
9 changes: 9 additions & 0 deletions packages/hub/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ module.exports = {
messageVerificationDelayMs: 1000 * 15,
onCallInternalWebhook: null,
},
iap: {
apple: {
verificationUrl: 'https://sandbox.itunes.apple.com/verifyReceipt',
},
google: {
verificationUrlBase:
'https://www.googleapis.com/androidpublisher/v3/applications/com.cardstack.cardpay/purchases/products/0001/tokens',
},
},
mailchimp: {
apiKey: null,
serverPrefix: null,
Expand Down
5 changes: 5 additions & 0 deletions packages/hub/config/production.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"allowedGuilds": "584043165066199050",
"allowedChannels": "898954606242054185"
},
"iap": {
"apple": {
"verificationUrl": "https://buy.itunes.apple.com/verifyReceipt"
}
},
"mailchimp": {
"serverPrefix": "us8",
"newsletterListId": "f2fe559998"
Expand Down
9 changes: 9 additions & 0 deletions packages/hub/config/test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ module.exports = {
},
verificationUrl: 'https://card-drop-email.test/email-card-drop/verify',
},
iap: {
apple: {
verificationUrl: 'https://buy.itunes.apple.test/verifyReceipt',
},
google: {
verificationUrlBase:
'https://www.googleapis.test/androidpublisher/v3/applications/com.cardstack.cardpay/purchases/products/0001/tokens',
},
},
emailHashSalt: 'P91APjz3Ef6q3KAdOCfKa5hOcEmOyrPeRPG6+g380LY=',
checkly: {
handleWebhookRequests: true,
Expand Down
12 changes: 12 additions & 0 deletions packages/hub/node-tests/routes/profile-purchases-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { registry, setupHub } from '../helpers/server';
import CardSpaceQueries from '../../queries/card-space';
import MerchantInfoQueries from '../../queries/merchant-info';
import shortUUID from 'short-uuid';
import { setupSentry, waitForSentryReport } from '../helpers/sentry';
import { setupStubWorkerClient } from '../helpers/stub-worker-client';
import JobTicketsQueries from '../../queries/job-tickets';

Expand Down Expand Up @@ -30,6 +31,7 @@ class StubInAppPurchases {
}

describe('POST /api/profile-purchases', function () {
setupSentry(this);
let { getJobIdentifiers, getJobPayloads, getJobSpecs } = setupStubWorkerClient(this);

this.beforeEach(function () {
Expand Down Expand Up @@ -352,6 +354,16 @@ describe('POST /api/profile-purchases', function () {
},
],
});

let sentryReport = await waitForSentryReport();

expect(sentryReport.tags).to.deep.equal({
action: 'profile-purchases-route',
});

expect(sentryReport.error?.message).to.equal(
`Unable to validate purchase, response: ${JSON.stringify(purchaseValidationResponse)}`
);
});

it('rejects when the merchant information is incomplete', async function () {
Expand Down
202 changes: 202 additions & 0 deletions packages/hub/node-tests/services/in-app-purchases-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import InAppPurchases from '../../services/in-app-purchases';
import { setupHub } from '../helpers/server';
import { rest } from 'msw';
import { setupServer, SetupServerApi } from 'msw/node';
import config from 'config';

describe('InAppPurchases', function () {
let { getContainer } = setupHub(this);
let subject: InAppPurchases;

this.beforeEach(async function () {
subject = await getContainer().lookup('in-app-purchases');
});

describe('for Apple provider', function () {
let mockServer: SetupServerApi;
let receiptSentToServer: keyof typeof mockResponses.apple;

this.beforeEach(function () {
mockServer = setupServer(
rest.post(config.get('iap.apple.verificationUrl'), (req, res, ctx) => {
let body = JSON.parse(req.body as string);
receiptSentToServer = body['receipt-data'];
return res(ctx.status(200), ctx.json(mockResponses.apple[receiptSentToServer]));
})
);

mockServer.listen({ onUnhandledRequest: 'error' });
});

this.afterEach(function () {
mockServer.close();
});

it('passes along a successful validation', async function () {
let validationResponse = await subject.validate('apple', 'VALID_RECEIPT');

expect(receiptSentToServer).to.equal('VALID_RECEIPT');

expect(validationResponse).to.deep.equal({
valid: true,
response: mockResponses.apple['VALID_RECEIPT'],
});
});

it('passes along a failed validation', async function () {
let validationResponse = await subject.validate('apple', 'INVALID_RECEIPT');

expect(receiptSentToServer).to.equal('INVALID_RECEIPT');

expect(validationResponse).to.deep.equal({
valid: false,
response: mockResponses.apple['INVALID_RECEIPT'],
});
});
});

describe('for Google provider', function () {
let mockServer: SetupServerApi;
let receiptSentToServer: keyof typeof mockResponses.google;

this.beforeEach(function () {
mockServer = setupServer(
rest.get(`${config.get('iap.google.verificationUrlBase')}/:token`, (req, res, ctx) => {
receiptSentToServer = req.params.token as keyof typeof mockResponses.google;
let response = mockResponses.google[receiptSentToServer];
return res(ctx.status(response.status), ctx.json(response.json));
})
);

mockServer.listen({ onUnhandledRequest: 'error' });
});

this.afterEach(function () {
mockServer.close();
});

it('passes along a successful validation', async function () {
let validationResponse = await subject.validate('google', 'VALID_RECEIPT');

expect(receiptSentToServer).to.equal('VALID_RECEIPT');

expect(validationResponse).to.deep.equal({
valid: true,
response: mockResponses.google['VALID_RECEIPT'].json,
});
});

it('passes along a failed validation', async function () {
let validationResponse = await subject.validate('google', 'INVALID_RECEIPT');

expect(receiptSentToServer).to.equal('INVALID_RECEIPT');

expect(validationResponse).to.deep.equal({
valid: false,
response: mockResponses.google['INVALID_RECEIPT'].json,
});
});

it('passes along a failed validation for a canceled purchase', async function () {
let validationResponse = await subject.validate('google', 'CANCELED_RECEIPT');

expect(validationResponse).to.deep.equal({
valid: false,
response: mockResponses.google['CANCELED_RECEIPT'].json,
});
});
});
});

const mockResponses = {
apple: {
VALID_RECEIPT: {
receipt: {
receipt_type: 'ProductionSandbox',
adam_id: 0,
app_item_id: 0,
bundle_id: 'com.cardstack.cardpay',
application_version: '1',
download_id: 0,
version_external_identifier: 0,
receipt_creation_date: '2022-07-01 12:28:57 Etc/GMT',
receipt_creation_date_ms: '1656678537000',
receipt_creation_date_pst: '2022-07-01 05:28:57 America/Los_Angeles',
request_date: '2022-07-04 16:18:11 Etc/GMT',
request_date_ms: '1656951491832',
request_date_pst: '2022-07-04 09:18:11 America/Los_Angeles',
original_purchase_date: '2013-08-01 07:00:00 Etc/GMT',
original_purchase_date_ms: '1375340400000',
original_purchase_date_pst: '2013-08-01 00:00:00 America/Los_Angeles',
original_application_version: '1.0',
in_app: [
{
quantity: '1',
product_id: '0001',
transaction_id: '2000000094963678',
original_transaction_id: '2000000094963678',
purchase_date: '2022-07-01 12:28:57 Etc/GMT',
purchase_date_ms: '1656678537000',
purchase_date_pst: '2022-07-01 05:28:57 America/Los_Angeles',
original_purchase_date: '2022-07-01 12:28:57 Etc/GMT',
original_purchase_date_ms: '1656678537000',
original_purchase_date_pst: '2022-07-01 05:28:57 America/Los_Angeles',
is_trial_period: 'false',
in_app_ownership_type: 'PURCHASED',
},
],
},
environment: 'Sandbox',
status: 0,
},
INVALID_RECEIPT: { status: 21003 },
},
google: {
VALID_RECEIPT: {
status: 200,
json: {
resource: {
purchaseTimeMillis: '1630529397125',
purchaseState: 0,
consumptionState: 0,
developerPayload: '',
orderId: 'GPA.3374-2691-3583-90384',
acknowledgementState: 1,
kind: 'androidpublisher#productPurchase',
regionCode: 'RU',
},
},
},
INVALID_RECEIPT: {
status: 400,
json: {
error: {
code: 400,
message: 'Invalid Value',
errors: [
{
message: 'Invalid Value',
domain: 'global',
reason: 'invalid',
},
],
},
},
},
CANCELED_RECEIPT: {
status: 200,
json: {
resource: {
purchaseTimeMillis: '1630529397125',
purchaseState: 1,
consumptionState: 0,
developerPayload: '',
orderId: 'GPA.3374-2691-3583-90384',
acknowledgementState: 1,
kind: 'androidpublisher#productPurchase',
regionCode: 'RU',
},
},
},
},
};
1 change: 1 addition & 0 deletions packages/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"json-stable-stringify": "^1.0.1",
"mocha": "^8.3.2",
"moment-timezone": "^0.5.33",
"msw": "^0.40.0",
"node-loader": "^2.0.0",
"npm-run-all": "^4.1.5",
"sentry-testkit": "^3.3.7",
Expand Down
6 changes: 6 additions & 0 deletions packages/hub/routes/profile-purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MerchantInfo } from './merchant-infos';
import { validateRequiredFields } from './utils/validation';
import shortUuid from 'short-uuid';
import { JobTicket } from './job-tickets';
import * as Sentry from '@sentry/node';

export default class ProfilePurchasesRoute {
databaseManager = inject('database-manager', { as: 'databaseManager' });
Expand Down Expand Up @@ -174,6 +175,11 @@ export default class ProfilePurchasesRoute {
);

if (!purchaseValidationResult) {
let error = new Error(`Unable to validate purchase, response: ${JSON.stringify(purchaseValidationResponse)}`);
Sentry.captureException(error, {
tags: { action: 'profile-purchases-route' },
});

ctx.status = 422;
ctx.body = {
errors: [
Expand Down
54 changes: 51 additions & 3 deletions packages/hub/services/in-app-purchases.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,56 @@
/* global fetch */
import config from 'config';

interface InAppPurchaseValidationResult {
valid: boolean;
response: any;
}

export default class InAppPurchases {
async validate(provider: string, receipt: any) {
console.error('In-app purchase validation is not implemented, arguments are', provider, receipt);
async validate(provider: 'google' | 'apple', receipt: string): Promise<InAppPurchaseValidationResult> {
if (provider === 'apple') {
return this.validateFromApple(receipt);
}

if (provider === 'google') {
return this.validateFromGoogle(receipt);
}

throw new Error(`In-app purchase validation is not implemented for provider ${provider}; receipt: ${receipt}`);
}

private async validateFromApple(receipt: string): Promise<InAppPurchaseValidationResult> {
let requestBody = {
'receipt-data': receipt,
};

let response = await fetch(config.get('iap.apple.verificationUrl'), {
method: 'POST',
body: JSON.stringify(requestBody),
});

let json = await response.json();

if (json.status === 0) {
return { valid: true, response: json };
} else {
return { valid: false, response: json };
}
}

private async validateFromGoogle(token: string): Promise<InAppPurchaseValidationResult> {
// TODO this needs OAuth to truly work, see CS-4199
let response = await fetch(`${config.get('iap.google.verificationUrlBase')}/${token}`, {
method: 'GET',
});

let json = await response.json();

return { valid: true, response: {} };
if (response.ok && json.resource.purchaseState === 0) {
return { valid: true, response: json };
} else {
return { valid: false, response: json };
}
}
}

Expand Down
Loading

0 comments on commit 5c2e11e

Please sign in to comment.