Skip to content

Commit

Permalink
refactor(auth): create AppStoreHelper class to manage client API inte…
Browse files Browse the repository at this point in the history
…rnals
  • Loading branch information
biancadanforth committed Apr 22, 2022
1 parent 6c38b06 commit c81b0d4
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 63 deletions.
Original file line number Diff line number Diff line change
@@ -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<StatusResponse> {
const apiClient = this.clientByBundleId(bundleId);
return apiClient.getSubscriptionStatuses(originalTransactionId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

/*
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit c81b0d4

Please sign in to comment.