From c81b0d43708c4cd8de04198d6131e337ff275ad3 Mon Sep 17 00:00:00 2001 From: Bianca Danforth Date: Fri, 22 Apr 2022 09:04:33 -0400 Subject: [PATCH] refactor(auth): create AppStoreHelper class to manage client API internals --- .../iap/apple-app-store/app-store-helper.ts | 70 +++++++++ .../payments/iap/apple-app-store/apple-iap.ts | 33 +---- .../iap/apple-app-store/purchase-manager.ts | 39 +++-- .../iap/apple-app-store/app-store-helper.js | 133 ++++++++++++++++++ .../payments/iap/apple-app-store/apple-iap.js | 1 - .../iap/apple-app-store/purchase-manager.js | 32 ++--- 6 files changed, 245 insertions(+), 63 deletions(-) create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts new file mode 100644 index 00000000000..9d8ccbc1c1d --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { + AppStoreServerAPI, + Environment, + StatusResponse, +} from 'app-store-server-api'; +import { Container } from 'typedi'; + +import { AppConfig, AuthLogger } from '../../../types'; + +export class AppStoreHelper { + private log: AuthLogger; + private appStoreServerApiClients: { + [key: string]: AppStoreServerAPI; + }; + private credentialsByBundleId: any; + private environment: Environment; + + constructor() { + this.log = Container.get(AuthLogger); + const { + subscriptions: { appStore }, + } = Container.get(AppConfig); + this.credentialsByBundleId = {}; + // Initialize App Store Server API client per bundle ID + this.environment = appStore.sandbox + ? Environment.Sandbox + : Environment.Production; + this.appStoreServerApiClients = {}; + for (const [bundleIdWithUnderscores, credentials] of Object.entries( + appStore.credentials + )) { + // Cannot use an actual bundleId (e.g. 'org.mozilla.ios.FirefoxVPN') as the key + // due to https://github.com/mozilla/node-convict/issues/250 + const bundleId = bundleIdWithUnderscores.replace('_', '.'); + this.credentialsByBundleId[bundleId] = credentials; + this.clientByBundleId(bundleId); + } + } + + /** + * Returns an App Store Server API client by bundleId, initializing it first + * if needed. + */ + clientByBundleId(bundleId: string): AppStoreServerAPI { + if (this.appStoreServerApiClients.hasOwnProperty(bundleId)) { + return this.appStoreServerApiClients[bundleId]; + } + const { serverApiKey, serverApiKeyId, issuerId } = + this.credentialsByBundleId[bundleId]; + this.appStoreServerApiClients[bundleId] = new AppStoreServerAPI( + serverApiKey, + serverApiKeyId, + issuerId, + bundleId, + this.environment + ); + return this.appStoreServerApiClients[bundleId]; + } + + async getSubscriptionStatuses( + bundleId: string, + originalTransactionId: string + ): Promise { + const apiClient = this.clientByBundleId(bundleId); + return apiClient.getSubscriptionStatuses(originalTransactionId); + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts index ce90fc6cf78..8666e2ed560 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts @@ -2,10 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Firestore } from '@google-cloud/firestore'; -import { AppStoreServerAPI, Environment } from 'app-store-server-api'; import { Container } from 'typedi'; import { AppConfig, AuthFirestore, AuthLogger } from '../../../types'; +import { AppStoreHelper } from './app-store-helper'; import { PurchaseManager } from './purchase-manager'; export class AppleIAP { @@ -16,39 +16,14 @@ export class AppleIAP { public purchaseManager: PurchaseManager; constructor() { this.log = Container.get(AuthLogger); - const { - authFirestore, - subscriptions: { appStore }, - } = Container.get(AppConfig); + const appStoreHelper = new AppStoreHelper(); - // Initialize App Store Server API client per bundle ID - const environment = appStore.sandbox - ? Environment.Sandbox - : Environment.Production; - const appStoreServerApiClients = {}; - for (const [bundleIdWithUnderscores, credentials] of Object.entries( - appStore.credentials - )) { - // Cannot use an actual bundleId (e.g. 'org.mozilla.ios.FirefoxVPN') as the key - // due to https://github.com/mozilla/node-convict/issues/250 - const bundleId = bundleIdWithUnderscores.replace('_', '.'); - const { serverApiKey, serverApiKeyId, issuerId } = credentials; - appStoreServerApiClients[bundleId] = new AppStoreServerAPI( - serverApiKey, - serverApiKeyId, - issuerId, - bundleId, - environment - ); - } this.firestore = Container.get(AuthFirestore); + const { authFirestore } = Container.get(AppConfig); this.prefix = `${authFirestore.prefix}iap-`; const purchasesDbRef = this.firestore.collection( `${this.prefix}app-store-purchases` ); - this.purchaseManager = new PurchaseManager( - purchasesDbRef, - appStoreServerApiClients - ); + this.purchaseManager = new PurchaseManager(purchasesDbRef, appStoreHelper); } } diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts index d8f69995c09..a6576a1336b 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts @@ -3,14 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { CollectionReference } from '@google-cloud/firestore'; import { - AppStoreServerAPI, decodeRenewalInfo, decodeTransaction, TransactionType, } from 'app-store-server-api'; import Container from 'typedi'; -import { AppConfig, AuthLogger } from '../../../types'; +import { AuthLogger } from '../../../types'; +import { AppStoreHelper } from './app-store-helper'; import { APPLE_APP_STORE_FORM_OF_PAYMENT, mergePurchaseWithFirestorePurchaseRecord, @@ -25,21 +25,21 @@ import { PurchaseQueryError, PurchaseUpdateError } from './types'; * https://developer.apple.com/documentation/appstoreserverapi */ export class PurchaseManager { - private appStoreConfig: any; + private appStoreHelper: AppStoreHelper; private log: AuthLogger; + private purchasesDbRef: CollectionReference; /* * This class is intended to be initialized by the library. * Library consumer should not initialize this class themselves. */ constructor( - private purchasesDbRef: CollectionReference, - private appStoreDeveloperApiClients: { - [key: string]: AppStoreServerAPI; - } + purchasesDbRef: CollectionReference, + appStoreHelper: AppStoreHelper ) { + this.appStoreHelper = appStoreHelper; this.log = Container.get(AuthLogger); - this.appStoreConfig = Container.get(AppConfig).subscriptions.appStore; + this.purchasesDbRef = purchasesDbRef; } /* @@ -60,14 +60,16 @@ export class PurchaseManager { let transactionInfo; let renewalInfo; try { - apiResponse = await this.appStoreDeveloperApiClients[ - bundleId - ].getSubscriptionStatuses(originalTransactionId); + apiResponse = await this.appStoreHelper.getSubscriptionStatuses( + bundleId, + originalTransactionId + ); // Find the latest transaction for the subscription const item = apiResponse.data[0].lastTransactions.find( (item) => item.originalTransactionId === originalTransactionId ); subscriptionStatus = item.status; + // FIXME: improve performance; see https://mozilla-hub.atlassian.net/browse/FXA-4949 transactionInfo = await decodeTransaction(item.signedTransactionInfo); renewalInfo = await decodeRenewalInfo(item.signedRenewalInfo); } catch (err) { @@ -239,9 +241,18 @@ export class PurchaseManager { ); if (!purchase.isEntitlementActive()) { - // If a subscription purchase record in Firestore indicates says that it has expired, - // and we know that its status could have been changed since we last fetch its details, - // then we should query the App Store Server API to get its latest status + // Unlike inactive Google Play subscriptions, which get replaced and + // deregistered, App Store subscriptions can be revived at any time + // (see https://developer.apple.com/forums/thread/704167). + // This means we could be hitting Apple here for any past, inactive + // subscriptions the user has, which could generate a lot of requests + // to Apple via querySubscriptionPurchase. + // However, since anything they buy in the same subscription group will + // get the same original transaction ID, the number of requests is + // limited to the number of different iOS products (N = 1 currently), + // and we already minimize calls to this method by caching the results + // of the capability service in the profile server, so this extra request + // per subscription is not currently a performance concern. this.log.info('queryCurrentSubscriptionPurchases.cache.update', { originalTransactionId: purchase.originalTransactionId, }); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js new file mode 100644 index 00000000000..de083bb2231 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const sinon = require('sinon'); +const { assert } = require('chai'); +const { default: Container } = require('typedi'); +const proxyquire = require('proxyquire').noPreserveCache(); + +const { mockLog } = require('../../../../mocks'); +const { AuthLogger, AppConfig } = require('../../../../../lib/types'); +const { AppStoreServerAPI } = require('app-store-server-api'); + +const mockAppStoreServerAPI = sinon.createStubInstance(AppStoreServerAPI); +const { AppStoreHelper } = proxyquire( + '../../../../../lib/payments/iap/apple-app-store/app-store-helper', + { + 'app-store-server-api': { + AppStoreServerAPI: function () { + return mockAppStoreServerAPI; + }, + }, + } +); + +const mockBundleIdWithUnderscores = 'org_mozilla_ios_FirefoxVPN'; +const mockBundleId = mockBundleIdWithUnderscores.replace('_', '.'); +const mockConfig = { + subscriptions: { + appStore: { + credentials: { + [mockBundleIdWithUnderscores]: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + sandbox: true, + }, + }, +}; + +describe('AppStoreHelper', () => { + let appStoreHelper; + let sandbox; + let log; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + log = mockLog(); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + appStoreHelper = Container.get(AppStoreHelper); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('can be instantiated', () => { + const appStoreServerApiClients = { + [mockBundleId]: mockAppStoreServerAPI, + }; + const credentialsByBundleId = { + [mockBundleId]: + mockConfig.subscriptions.appStore.credentials[ + mockBundleIdWithUnderscores + ], + }; + assert.strictEqual(appStoreHelper.log, log); + assert.deepEqual( + appStoreHelper.appStoreServerApiClients, + appStoreServerApiClients + ); + assert.deepEqual( + appStoreHelper.credentialsByBundleId, + credentialsByBundleId + ); + assert.strictEqual(appStoreHelper.environment, 'Sandbox'); + }); + + describe('clientByBundleId', () => { + let mockApiClient; + + beforeEach(() => { + mockApiClient = {}; + }); + + it('returns the existing API client for the bundleId', () => { + appStoreHelper.appStoreServerApiClients[mockBundleId] = mockApiClient; + const actual = appStoreHelper.clientByBundleId(mockBundleId); + const expected = mockApiClient; + assert.deepEqual(actual, expected); + }); + it("initializes an API client for a given bundleId if it doesn't exist", () => { + appStoreHelper.appStoreServerApiClients = {}; + const actual = appStoreHelper.clientByBundleId(mockBundleId); + const expected = mockAppStoreServerAPI; + assert.deepEqual(actual, expected); + }); + }); + + describe('getSubscriptionStatuses', async () => { + it('calls the corresponding method on the API client', async () => { + const mockOriginalTransactionId = '100000000'; + // Mock App Store Client API response + const expected = { data: 'wow' }; + mockAppStoreServerAPI.getSubscriptionStatuses = sinon + .stub() + .resolves(expected); + appStoreHelper.clientByBundleId = sandbox + .stub() + .returns(mockAppStoreServerAPI); + const actual = await appStoreHelper.getSubscriptionStatuses( + mockBundleId, + mockOriginalTransactionId + ); + assert.deepEqual(actual, expected); + + sinon.assert.calledOnceWithExactly( + appStoreHelper.clientByBundleId, + mockBundleId + ); + sinon.assert.calledOnceWithExactly( + mockAppStoreServerAPI.getSubscriptionStatuses, + mockOriginalTransactionId + ); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js index 0e69233b022..51ea31f12f1 100644 --- a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js @@ -50,7 +50,6 @@ describe('AppleIAP', () => { purchasesDbRefMock = {}; collectionMock.returns(purchasesDbRefMock); Container.set(AuthFirestore, firestore); - Container.set(AuthFirestore, firestore); Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); Container.remove(AppleIAP); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js index aae7e811e73..95d3fa767c6 100644 --- a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js @@ -76,9 +76,8 @@ const { PurchaseManager: UnmockedPurchaseManager } = proxyquire( describe('PurchaseManager', () => { let log; + let mockAppStoreHelper; let mockPurchaseDbRef; - let mockApiClientsByBundleId; - let mockApiClient; const mockConfig = { authFirestore: { @@ -98,13 +97,8 @@ describe('PurchaseManager', () => { }; beforeEach(() => { + mockAppStoreHelper = {}; log = mockLog(); - mockApiClient = { - getSubscriptionStatuses: sinon.fake.resolves(mockApiResult), - }; - mockApiClientsByBundleId = { - [mockBundleId]: mockApiClient, - }; Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); }); @@ -116,7 +110,7 @@ describe('PurchaseManager', () => { it('can be instantiated', () => { const purchaseManager = new PurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); assert.ok(purchaseManager); }); @@ -129,6 +123,9 @@ describe('PurchaseManager', () => { mockPurchaseDbRef = {}; beforeEach(() => { + mockAppStoreHelper = { + getSubscriptionStatuses: sinon.fake.resolves(mockApiResult), + }; mockPurchaseDoc = { exists: false, ref: { @@ -144,12 +141,9 @@ describe('PurchaseManager', () => { }; mockSubscriptionPurchase.fromApiResponse = sinon.fake.returns(mockSubscription); - mockApiClientsByBundleId = { - [mockBundleId]: mockApiClient, - }; purchaseManager = new PurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); }); @@ -164,7 +158,7 @@ describe('PurchaseManager', () => { ); assert.strictEqual(result, mockSubscription); - sinon.assert.calledOnce(mockApiClient.getSubscriptionStatuses); + sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); sinon.assert.calledOnce(mockDecodeTransactionInfo); sinon.assert.calledOnce(mockDecodeRenewalInfo); @@ -185,7 +179,7 @@ describe('PurchaseManager', () => { ); assert.strictEqual(result, mockSubscription); - sinon.assert.calledOnce(mockApiClient.getSubscriptionStatuses); + sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); sinon.assert.calledOnce(mockDecodeTransactionInfo); sinon.assert.calledOnce(mockDecodeRenewalInfo); @@ -225,7 +219,7 @@ describe('PurchaseManager', () => { }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); }); @@ -272,7 +266,7 @@ describe('PurchaseManager', () => { }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); mockSubscriptionPurchase.fromFirestoreObject = sinon.fake.returns({}); }); @@ -314,7 +308,7 @@ describe('PurchaseManager', () => { }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); purchaseManager.querySubscriptionPurchase = sinon.fake.resolves(mockSubscription); @@ -416,7 +410,7 @@ describe('PurchaseManager', () => { }); purchaseManager = new UnmockedPurchaseManager( mockPurchaseDbRef, - mockApiClientsByBundleId + mockAppStoreHelper ); });