Skip to content

Commit

Permalink
chore(auth): add tests for Apple IAP modules
Browse files Browse the repository at this point in the history
* add AppleIAP and PurchaseManager tests
* Updated App Store config to map appName to credentials instead of bundleId due to mozilla/node-convict#250.
  * Downstream effects include adding a new async static method to the AppleIAP module to initialize an App Store Server API client that can be called from PurchaseManager.querySubscriptionPurchase if the client isn't initialized by the time it's needed.
  • Loading branch information
biancadanforth committed Apr 16, 2022
1 parent 4863cb6 commit 713b82e
Show file tree
Hide file tree
Showing 14 changed files with 1,243 additions and 49 deletions.
10 changes: 6 additions & 4 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,12 +782,14 @@ const conf = convict({
},
appStore: {
credentials: {
doc: 'Map of AppStore Connect credentials by app bundle ID',
// Ideally, we'd key by bundleId instead of appName (e.g. 'org.mozilla.ios.FirefoxVPN'
// rather than 'guardian-vpn'), but we can't due to
// https://github.com/mozilla/node-convict/issues/250, so we will need to map appName
// to bundleId downstream.
doc: 'Map of appName to AppStore Connect credentials',
format: Object,
default: {
// Cannot use an actual bundleId (e.g. 'org.mozilla.ios.FirefoxVPN') as the key
// due to https://github.com/mozilla/node-convict/issues/250
org_mozilla_ios_FirefoxVPN: {
'guardian-vpn': {
issuerId: 'issuer_id',
serverApiKey: 'key',
serverApiKeyId: 'key_id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ import { PurchaseManager } from './purchase-manager';

export class AppleIAP {
private firestore: Firestore;
private iapConfigDbRef: TypedCollectionReference<IapConfig>;
private log: AuthLogger;
private prefix: string;
private iapConfigDbRef: TypedCollectionReference<IapConfig>;

public purchaseManager: PurchaseManager;
constructor() {
const {
authFirestore,
env,
subscriptions: { appStore },
} = Container.get(AppConfig);
this.prefix = `${authFirestore.prefix}iap-`;
Expand All @@ -31,23 +30,15 @@ export class AppleIAP {
this.log = Container.get(AuthLogger);

// Initialize App Store Server API client per bundle ID
const environment =
env === 'prod' ? Environment.Production : Environment.Sandbox;
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
);
for (const appName of Object.keys(appStore.credentials)) {
// Ideally, we'd key by bundleId instead of appName (e.g. 'org.mozilla.ios.FirefoxVPN'
// rather than 'guardian-vpn'), but we can't due to
// https://github.com/mozilla/node-convict/issues/250, so we will need to map appName
// to bundleId.
this.getBundleId(appName).then((bundleId) => {
AppleIAP.initializeApiClientByBundleId(bundleId, appStore);
});
}
const purchasesDbRef = this.firestore.collection(
`${this.prefix}app-store-purchases`
Expand All @@ -58,6 +49,24 @@ export class AppleIAP {
);
}

static async initializeApiClientByBundleId(
bundleId: string,
appStoreConfig: any
) {
const environment = appStoreConfig.sandbox
? Environment.Sandbox
: Environment.Production;
const { serverApiKey, serverApiKeyId, issuerId } =
appStoreConfig[bundleId].credentials;
return new AppStoreServerAPI(
serverApiKey,
serverApiKeyId,
issuerId,
bundleId,
environment
);
}

/**
* Fetch the Apple plans for iOS client usage.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* 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/. */

export { AppleIAP } from './apple-iap';
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,30 @@ import {
decodeRenewalInfo,
decodeTransaction,
} from 'app-store-server-api';
import { TransactionType } from 'app-store-server-api/dist/types/Models';
import { Logger } from 'mozlog';
import Container from 'typedi';

import { AppConfig, AuthLogger } from '../../../types';
import { AppleIAP } from './apple-iap';
import {
APPLE_APP_STORE_FORM_OF_PAYMENT,
mergePurchaseWithFirestorePurchaseRecord,
SubscriptionPurchase,
} from './subscription-purchase';
import { PurchaseQueryError, PurchaseUpdateError } from './types';
import {
PurchaseQueryError,
PurchaseUpdateError,
TransactionType,
} from './types';

/**
* This class wraps Firestore API calls for Apple IAP purchases. It
* will handle related CRUD operations by FxA uid.
* will handle related CRUD operations.
* This is using the V2 App Store Server API implementation:
* https://developer.apple.com/documentation/appstoreserverapi
*/
export class PurchaseManager {
private log: Logger;
private appStoreConfig: any;
private log: AuthLogger;

/*
* This class is intended to be initialized by the library.
Expand All @@ -37,7 +42,8 @@ export class PurchaseManager {
[key: string]: AppStoreServerAPI;
}
) {
this.log = Container.get(Logger);
this.log = Container.get(AuthLogger);
this.appStoreConfig = Container.get(AppConfig).subscriptions.appStore;
}

/*
Expand All @@ -58,6 +64,14 @@ export class PurchaseManager {
let transactionInfo;
let renewalInfo;
try {
if (!this.appStoreDeveloperApiClients[bundleId]) {
// API client not yet initialized
this.appStoreDeveloperApiClients[bundleId] =
await AppleIAP.initializeApiClientByBundleId(
bundleId,
this.appStoreConfig
);
}
apiResponse = await this.appStoreDeveloperApiClients[
bundleId
].getSubscriptionStatuses(originalTransactionId);
Expand Down Expand Up @@ -156,7 +170,6 @@ export class PurchaseManager {
async registerToUserAccount(
bundleId: string,
originalTransactionId: string,
productId: string,
userId: string
) {
// STEP 1. Check if the purchase record is already in Firestore
Expand All @@ -171,14 +184,14 @@ export class PurchaseManager {
} catch (err) {
// Error when attempt to query purchase. Return not found error to caller.
const libraryError = new Error(err.message);
libraryError.name = PurchaseUpdateError.INVALID_TRANSACTION_ID;
libraryError.name = PurchaseUpdateError.INVALID_ORIGINAL_TRANSACTION_ID;
throw libraryError;
}
}
// STEP 2. Check if subscription is registerable
if (!purchase.isRegisterable()) {
const libraryError = new Error('Purchase is not registerable');
libraryError.name = PurchaseUpdateError.INVALID_TRANSACTION_ID;
libraryError.name = PurchaseUpdateError.INVALID_ORIGINAL_TRANSACTION_ID;
throw libraryError;
}

Expand Down Expand Up @@ -242,8 +255,8 @@ export class PurchaseManager {
// 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
this.log.info('queryCurrentSubscriptions.cache.update', {
purchaseToken: purchase.originalTransactionId,
this.log.info('queryCurrentSubscriptionPurchases.cache.update', {
originalTransactionId: purchase.originalTransactionId,
});
purchase = await this.querySubscriptionPurchase(
purchase.bundleId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,26 @@ import {
StatusResponse,
SubscriptionStatus,
TransactionType,
} from 'app-store-server-api/dist/types/Models';
} from './types';
import { Environment, OfferType, OwnershipType } from './types/api-client';

const FIRESTORE_OBJECT_INTERNAL_KEYS = ['formOfPayment'];
export const APPLE_APP_STORE_FORM_OF_PAYMENT = 'APPLE_APP_STORE';

export const SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES = [
'autoRenewStatus',
'autoRenewProductId',
'bundleId',
'environment',
'inAppOwnershipType',
'originalPurchaseDate',
'originalTransactionId',
'productId',
'status',
'type',
'verifiedAt',
];

/**
* This file contains internal implementation of classes and utilities that
* is only used inside of the library.
Expand Down Expand Up @@ -62,15 +77,26 @@ export function mergePurchaseWithFirestorePurchaseRecord(
export class SubscriptionPurchase {
// Response from App Store API server Subscription Status endpoint
// https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses
// IMPORTANT: If adding a new required property, also add it to SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES
private autoRenewStatus: AutoRenewStatus;
private autoRenewProductId: string;
bundleId: string; // unique identifier for the iOS app; analogous to a Stripe product id
private expirationIntent?: number;
private expiresDate?: number;
private isInBillingRetry?: boolean;
private environment: Environment;
private inAppOwnershipType: OwnershipType;
private originalPurchaseDate: number;
originalTransactionId: string; // unique identifier for the subscription; analogous to a Stripe subscription id
private productId: string; // unique identifier for the plan; analogous to the Stripe plan id
private status: SubscriptionStatus;
private type: TransactionType;
private expirationIntent?: number;
private expiresDate?: number;
private gracePeriodExpiresDate?: number;
private isInBillingRetry?: boolean;
private isUpgraded?: boolean;
private offerType?: OfferType;
private offerIdentifier?: string;
private revocationDate?: number;
private revocationReason?: number;

// Library-managed properties
userId?: string; // hex string for FxA user id
Expand All @@ -87,25 +113,47 @@ export class SubscriptionPurchase {
): SubscriptionPurchase {
const purchase = new SubscriptionPurchase();
purchase.autoRenewStatus = renewalInfo.autoRenewStatus;
purchase.autoRenewProductId = renewalInfo.autoRenewProductId;
purchase.bundleId = apiResponse.bundleId;
purchase.environment = apiResponse.environment;
purchase.inAppOwnershipType = transactionInfo.inAppOwnershipType;
purchase.originalPurchaseDate = transactionInfo.originalPurchaseDate;
purchase.originalTransactionId = originalTransactionId;
purchase.productId = transactionInfo.productId;
purchase.status = subscriptionStatus;
purchase.type = transactionInfo.type;
purchase.verifiedAt = verifiedAt;

if (renewalInfo.expirationIntent) {
purchase.expirationIntent = renewalInfo.expirationIntent;
}
if (transactionInfo.expiresDate) {
purchase.expiresDate = transactionInfo.expiresDate;
}
if (renewalInfo.expirationIntent) {
purchase.expirationIntent = renewalInfo.expirationIntent;
if (renewalInfo.gracePeriodExpiresDate) {
purchase.gracePeriodExpiresDate = renewalInfo.gracePeriodExpiresDate;
}
if (renewalInfo.isInBillingRetryPeriod) {
if (renewalInfo.hasOwnProperty('isInBillingRetryPeriod')) {
// We don't check this.status === SubscriptionStatus.InBillingRetry, since
// it's not mutually exclusive with other subscription states (i.e.
// SubscriptionStatus.InBillingGracePeriod).
purchase.isInBillingRetry = renewalInfo.isInBillingRetryPeriod;
}
if (transactionInfo.hasOwnProperty('isUpgraded')) {
purchase.isUpgraded = transactionInfo.isUpgraded;
}
if (renewalInfo.offerIdentifier) {
purchase.offerIdentifier = renewalInfo.offerIdentifier;
}
if (renewalInfo.offerType) {
purchase.offerType = renewalInfo.offerType;
}
if (transactionInfo.revocationDate) {
purchase.revocationDate = transactionInfo.revocationDate;
}
if (transactionInfo.hasOwnProperty('revocationReason')) {
purchase.revocationReason = transactionInfo.revocationReason;
}

return purchase;
}
Expand Down Expand Up @@ -154,7 +202,15 @@ export class SubscriptionPurchase {
return this.isInBillingRetry;
}

isGracePeriod() {
isInGracePeriod() {
return this.status === SubscriptionStatus.InBillingGracePeriod;
}

isTestPurchase(): boolean {
return this.environment === Environment.Sandbox;
}

isFreeTrial(): boolean {
return this.offerType === OfferType.Introductory;
}
}
Loading

0 comments on commit 713b82e

Please sign in to comment.