From e0a25f07abaf59abf23e0005d1485fd68670af86 Mon Sep 17 00:00:00 2001 From: Bianca Danforth Date: Thu, 14 Apr 2022 08:33:32 -0400 Subject: [PATCH] chore(auth): WIP - add tests for Apple IAP modules * add AppleIAP module test --- .../lib/payments/iap/apple-app-store/index.ts | 5 + .../iap/apple-app-store/purchase-manager.ts | 11 +- .../apple-app-store/subscription-purchase.ts | 5 +- .../iap/apple-app-store/types/api-client.ts | 187 ++++++++++++++++++ .../iap/apple-app-store/types/index.ts | 9 + .../payments/iap/apple-app-store/apple-iap.js | 147 ++++++++++++++ 6 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/api-client.ts create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts new file mode 100644 index 00000000000..3fcf74b26fa --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts @@ -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'; 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 3d3fca28d15..50662c60890 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 @@ -7,10 +7,13 @@ import { decodeRenewalInfo, decodeTransaction, } from 'app-store-server-api'; -import { TransactionType } from 'app-store-server-api/dist/types/Models'; -import { Logger } from 'mozlog'; +// TODO: Why is this import throwing an error when this module is loaded? +// (e.g. in a local test: yarn test test/local/payments/iap/apple-iap.js) +// import { TransactionType } from 'app-store-server-api/dist/types/Models'; +import { TransactionType } from './types'; import Container from 'typedi'; +import { AuthLogger } from '../../../types'; import { APPLE_APP_STORE_FORM_OF_PAYMENT, mergePurchaseWithFirestorePurchaseRecord, @@ -25,7 +28,7 @@ import { PurchaseQueryError, PurchaseUpdateError } from './types'; * https://developer.apple.com/documentation/appstoreserverapi */ export class PurchaseManager { - private log: Logger; + private log: AuthLogger; /* * This class is intended to be initialized by the library. @@ -37,7 +40,7 @@ export class PurchaseManager { [key: string]: AppStoreServerAPI; } ) { - this.log = Container.get(Logger); + this.log = Container.get(AuthLogger); } /* diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts index a660943720b..3c2b2a28ade 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts @@ -9,7 +9,10 @@ import { StatusResponse, SubscriptionStatus, TransactionType, -} from 'app-store-server-api/dist/types/Models'; +} from './types'; +// TODO: Why is this import throwing an error when this module is loaded? +// (e.g. in a local test: yarn test test/local/payments/iap/apple-iap.js) +// } from 'app-store-server-api/dist/types/Models'; const FIRESTORE_OBJECT_INTERNAL_KEYS = ['formOfPayment']; export const APPLE_APP_STORE_FORM_OF_PAYMENT = 'APPLE_APP_STORE'; diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/api-client.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/api-client.ts new file mode 100644 index 00000000000..3df5aa364cf --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/api-client.ts @@ -0,0 +1,187 @@ +/** + * MIT License + * + * Copyright (c) 2021 August Heegaard + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// TODO: Below is copied over from app-store-server-api/dist/types/Models +// as an module load error occurs if imported from that path e.g. in +// subscription-purchase.ts and running a local test: +// yarn test test/local/payments/iap/apple-iap.js. +export declare enum Environment { + Production = 'Production', + Sandbox = 'Sandbox', +} +export interface HistoryResponse { + appAppleId: string; + bundleId: string; + environment: Environment; + hasMore: boolean; + revision: string; + signedTransactions: JWSTransaction[]; +} +export declare type JWSTransaction = string; +export interface JWSDecodedHeader { + alg: string; + kid: string; + x5c: string[]; +} +export interface JWSTransactionDecodedPayload { + appAccountToken?: string; + bundleId: string; + expiresDate?: number; + inAppOwnershipType: OwnershipType; + isUpgraded?: boolean; + offerIdentifier?: string; + offerType?: OfferType; + originalPurchaseDate: number; + originalTransactionId: string; + productId: string; + purchaseDate: number; + quantity: number; + revocationDate?: number; + revocationReason?: number; + signedDate: number; + subscriptionGroupIdentifier?: string; + transactionId: string; + type: TransactionType; + webOrderLineItemId: string; +} +export declare enum OwnershipType { + Purchased = 'PURCHASED', + FamilyShared = 'FAMILY_SHARED', +} +export declare enum TransactionType { + AutoRenewableSubscription = 'Auto-Renewable Subscription', + NonConsumable = 'Non-Consumable', + Consumable = 'Consumable', + NonRenewingSubscription = 'Non-Renewing Subscription', +} +export interface StatusResponse { + data: SubscriptionGroupIdentifierItem[]; + environment: Environment; + appAppleId: string; + bundleId: string; +} +export interface SubscriptionGroupIdentifierItem { + subscriptionGroupIdentifier: string; + lastTransactions: LastTransactionsItem[]; +} +export interface LastTransactionsItem { + originalTransactionId: string; + status: SubscriptionStatus; + signedRenewalInfo: JWSRenewalInfo; + signedTransactionInfo: JWSTransaction; +} +export declare type JWSRenewalInfo = string; +export declare enum SubscriptionStatus { + Active = 1, + Expired = 2, + InBillingRetry = 3, + InBillingGracePeriod = 4, + Revoked = 5, +} +export interface JWSRenewalInfoDecodedPayload { + autoRenewProductId: string; + autoRenewStatus: AutoRenewStatus; + expirationIntent?: ExpirationIntent; + gracePeriodExpiresDate?: number; + isInBillingRetryPeriod?: boolean; + offerIdentifier?: string; + offerType?: OfferType; + originalTransactionId: string; + priceIncreaseStatus?: PriceIncreaseStatus; + productId: string; + signedDate: number; +} +export declare enum AutoRenewStatus { + Off = 0, + On = 1, +} +export declare enum ExpirationIntent { + Canceled = 1, + BillingError = 2, + RejectedPriceIncrease = 3, + ProductUnavailable = 4, +} +export declare enum OfferType { + Introductory = 1, + Promotional = 2, + SubscriptionOfferCode = 3, +} +export declare enum PriceIncreaseStatus { + NoResponse = 0, + Consented = 1, +} +export interface OrderLookupResponse { + orderLookupStatus: OrderLookupStatus; + signedTransactions: JWSTransaction[]; +} +export declare enum OrderLookupStatus { + Valid = 0, + Invalid = 1, +} +export interface DecodedNotificationPayload { + notificationType: NotificationType; + subtype?: NotificationSubtype; + notificationUUID: string; + version: string; + data: NotificationData; +} +export interface NotificationData { + appAppleId: string; + bundleId: string; + bundleVersion: number; + environment: Environment; + signedRenewalInfo: JWSRenewalInfo; + signedTransactionInfo: JWSTransaction; +} +export declare enum NotificationType { + ConsumptionRequest = 'CONSUMPTION_REQUEST', + DidChangeRenewalPref = 'DID_CHANGE_RENEWAL_PREF', + DidChangeRenewalStatus = 'DID_CHANGE_RENEWAL_STATUS', + DidFailToRenew = 'DID_FAIL_TO_RENEW', + DidRenew = 'DID_RENEW', + Expired = 'EXPIRED', + GracePeriodExpired = 'GRACE_PERIOD_EXPIRED', + OfferRedeemed = 'OFFER_REDEEMED', + PriceIncrease = 'PRICE_INCREASE', + Refund = 'REFUND', + RefundDeclined = 'REFUND_DECLINED', + RenewalExtended = 'RENEWAL_EXTENDED', + Revoke = 'REVOKE', + Subscribed = 'SUBSCRIBED', +} +export declare enum NotificationSubtype { + InitialBuy = 'INITIAL_BUY', + Resubscribe = 'RESUBSCRIBE', + Downgrade = 'DOWNGRADE', + Upgrade = 'UPGRADE', + AutoRenewEnabled = 'AUTO_RENEW_ENABLED', + AutoRenewDisabled = 'AUTO_RENEW_DISABLED', + Voluntary = 'VOLUNTARY', + BillingRetry = 'BILLING_RETRY', + PriceIncrease = 'PRICE_INCREASE', + GracePeriod = 'GRACE_PERIOD', + BillingRecovery = 'BILLING_RECOVERY', + Pending = 'PENDING', + Accepted = 'ACCEPTED', +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts index 8d6d83e6ed7..55edd9ebc76 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts @@ -2,3 +2,12 @@ * 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 { PurchaseQueryError, PurchaseUpdateError } from './errors'; +export { + AutoRenewStatus, + JWSRenewalInfoDecodedPayload, + JWSTransactionDecodedPayload, + LastTransactionsItem, + StatusResponse, + SubscriptionStatus, + TransactionType, +} from './api-client'; 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 new file mode 100644 index 00000000000..d65cef9e4cb --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js @@ -0,0 +1,147 @@ +/* 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 { mockLog } = require('../../../../mocks'); +const { + AuthFirestore, + AuthLogger, + AppConfig, +} = require('../../../../../lib/types'); +const { AppleIAP } = require('../../../../../lib/payments/iap/apple-app-store'); + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + appStore: { + credentials: { + org_mozilla_ios_FirefoxVPN: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + }, + }, +}; + +describe('AppleIAP', () => { + let sandbox; + let firestore; + let log; + let appleIAP; + let planDbRefMock; + let purchasesDbRefMock; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + planDbRefMock = {}; + purchasesDbRefMock = {}; + const collectionMock = sinon.stub(); + collectionMock.onFirstCall().returns(planDbRefMock); + collectionMock.onSecondCall().returns(purchasesDbRefMock); + firestore = { + collection: collectionMock, + }; + log = mockLog(); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + Container.remove(AppleIAP); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('can be instantiated', () => { + const appleIAP = Container.get(AppleIAP); + assert.strictEqual(appleIAP.log, log); + assert.strictEqual(appleIAP.firestore, firestore); + assert.strictEqual(appleIAP.prefix, 'mock-fxa-iap-'); + }); + + describe('plans', () => { + beforeEach(() => { + // Create and set a new one per test + appleIAP = new AppleIAP(); + Container.set(AppleIAP, appleIAP); + }); + + it('returns successfully', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: true, + data: sinon.fake.returns({ plans: 'testObject' }), + }), + }); + const result = await appleIAP.plans(); + assert.strictEqual(result, 'testObject'); + }); + + it('throws error with no document found', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: false, + }), + }); + try { + await appleIAP.plans('testApp'); + assert.fail('Expected exception thrown.'); + } catch (err) { + assert.strictEqual( + err.message, + 'IAP Plans document does not exist for testApp' + ); + } + }); + }); + + describe('getBundleId', () => { + beforeEach(() => { + // Create and set a new one per test + appleIAP = new AppleIAP(); + Container.set(AppleIAP, appleIAP); + }); + + it('returns successfully', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: true, + data: sinon.fake.returns({ + bundleId: 'org.mozilla.testApp', + plans: 'testObject', + }), + }), + }); + const result = await appleIAP.getBundleId('testApp'); + assert.strictEqual(result, 'org.mozilla.testApp'); + }); + + it('throws error with no document found', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: false, + }), + }); + try { + await appleIAP.getBundleId('testApp'); + assert.fail('Expected exception thrown.'); + } catch (err) { + assert.strictEqual( + err.message, + 'IAP Plans document does not exist for testApp' + ); + } + }); + }); +});