From f87e0a243850be65a2904abeb51d394ece6d032a Mon Sep 17 00:00:00 2001 From: Tom Wadeson <3607811+tomwadeson@users.noreply.github.com> Date: Thu, 21 Mar 2024 09:16:51 +0000 Subject: [PATCH] Provide a Lambda to update our cache of Feast subscriptions --- .../src/feast/update-subs/updatesubs.ts | 44 ++++++++++++ typescript/src/models/app.ts | 4 ++ .../src/services/appleValidateReceipts.ts | 18 +++-- typescript/src/update-subs/apple.ts | 2 +- .../feast/update-subs/updatesubs.test.ts | 69 +++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 typescript/src/feast/update-subs/updatesubs.ts create mode 100644 typescript/src/models/app.ts create mode 100644 typescript/tests/feast/update-subs/updatesubs.test.ts diff --git a/typescript/src/feast/update-subs/updatesubs.ts b/typescript/src/feast/update-subs/updatesubs.ts new file mode 100644 index 000000000..549d6c067 --- /dev/null +++ b/typescript/src/feast/update-subs/updatesubs.ts @@ -0,0 +1,44 @@ +import { SQSEvent, SQSRecord } from "aws-lambda"; +import { validateReceipt } from "../../services/appleValidateReceipts"; +import { AppleSubscriptionReference } from "../../models/subscriptionReference"; +import { toAppleSubscription } from "../../update-subs/apple"; +import { Subscription } from "../../models/subscription"; +import { dynamoMapper } from "../../utils/aws"; +import { App } from "../../models/app" + +const decodeSubscriptionReference = + (record: SQSRecord): AppleSubscriptionReference => { + return JSON.parse(record.body) as AppleSubscriptionReference; + } + +const defaultFetchSubscriptionsFromApple = + (reference: AppleSubscriptionReference): Promise => { + return validateReceipt(reference.receipt, {sandboxRetry: false}, App.Feast).then(subs => subs.map(toAppleSubscription)) + } + +const defaultStoreSubscriptionInDynamo = + (subscription: Subscription): Promise => { + return dynamoMapper.put({item: subscription}).then(_ => {}) + } + +export function buildHandler( + fetchSubscriptionsFromApple: (reference: AppleSubscriptionReference) => Promise = defaultFetchSubscriptionsFromApple, + storeSubscriptionInDynamo: (subscription: Subscription) => Promise = defaultStoreSubscriptionInDynamo +): (event: SQSEvent) => Promise { + return async (event: SQSEvent) => { + const work = + event.Records.map(async record => { + const reference = + decodeSubscriptionReference(record) + + const subscriptions = + await fetchSubscriptionsFromApple(reference) + + await Promise.all(subscriptions.map(storeSubscriptionInDynamo)) + }) + + return Promise.all(work).then(_ => "OK") + } +} + +export const handler = buildHandler(); diff --git a/typescript/src/models/app.ts b/typescript/src/models/app.ts new file mode 100644 index 000000000..bf886d843 --- /dev/null +++ b/typescript/src/models/app.ts @@ -0,0 +1,4 @@ +export enum App { + Live = "live", + Feast = "feast" +} \ No newline at end of file diff --git a/typescript/src/services/appleValidateReceipts.ts b/typescript/src/services/appleValidateReceipts.ts index acbe8fa87..1dfd8053b 100644 --- a/typescript/src/services/appleValidateReceipts.ts +++ b/typescript/src/services/appleValidateReceipts.ts @@ -6,6 +6,7 @@ import {Option} from "../utils/option"; import {restClient} from "../utils/restClient"; import {IHttpClientResponse} from "typed-rest-client/Interfaces"; import {GracefulProcessingError} from "../models/GracefulProcessingError"; +import {App} from "../models/app" export interface PendingRenewalInfo { auto_renew_product_id?: string, @@ -77,9 +78,18 @@ const sandboxReceiptEndpoint = "https://sandbox.itunes.apple.com/verifyReceipt"; const prodReceiptEndpoint = "https://buy.itunes.apple.com/verifyReceipt"; const receiptEndpoint = (Stage === "PROD") ? prodReceiptEndpoint : sandboxReceiptEndpoint; -function callValidateReceipt(receipt: string, forceSandbox: boolean = false): Promise { +function passwordForApp(app: App): Promise { + switch (app) { + case App.Live: + return getConfigValue("apple.password") + case App.Feast: + return getConfigValue("feast.apple.password") + } +} + +function callValidateReceipt(receipt: string, forceSandbox: boolean = false, app: App = App.Live): Promise { const endpoint = forceSandbox ? sandboxReceiptEndpoint : receiptEndpoint; - return getConfigValue("apple.password") + return passwordForApp(app) .then(password => { const body = JSON.stringify({ "receipt-data": receipt, @@ -225,8 +235,8 @@ async function retryInSandboxIfNecessary(parsedResponse: AppleValidationServerRe } } -export function validateReceipt(receipt: string, options: ValidationOptions): Promise { - return callValidateReceipt(receipt) +export function validateReceipt(receipt: string, options: ValidationOptions, app: App = App.Live): Promise { + return callValidateReceipt(receipt, false, app) .then(response => response.readBody()) .then(body => JSON.parse(body)) .then(body => body as AppleValidationServerResponse) diff --git a/typescript/src/update-subs/apple.ts b/typescript/src/update-subs/apple.ts index 569da78c5..e2168379c 100644 --- a/typescript/src/update-subs/apple.ts +++ b/typescript/src/update-subs/apple.ts @@ -8,7 +8,7 @@ import {AppleValidationResponse, validateReceipt} from "../services/appleValidat import {fromAppleBundle} from "../services/appToPlatform"; import {PRODUCT_BILLING_PERIOD} from "../services/productBillingPeriod"; -function toAppleSubscription(response: AppleValidationResponse): Subscription { +export function toAppleSubscription(response: AppleValidationResponse): Subscription { const latestReceiptInfo = response.latestReceiptInfo; let autoRenewStatus: boolean = false; diff --git a/typescript/tests/feast/update-subs/updatesubs.test.ts b/typescript/tests/feast/update-subs/updatesubs.test.ts new file mode 100644 index 000000000..315c48a49 --- /dev/null +++ b/typescript/tests/feast/update-subs/updatesubs.test.ts @@ -0,0 +1,69 @@ + +import { SQSEvent } from "aws-lambda"; +import { buildHandler } from "../../../src/feast/update-subs/updatesubs"; +import { Subscription } from "../../../src/models/subscription"; +import { AppleSubscriptionReference } from "../../../src/models/subscriptionReference"; + +describe("The Feast (Apple) subscription updater", () => { + it("Should fetch the subscription(s) associated with the reference from Apple and persist them to Dynamo", async () => { + const event = + buildSqsEvent(["TEST_RECEIPT_1", "TEST_RECEIPT_2"]) + + const handler = + buildHandler(stubFetchSubscriptionsFromApple, mockStoreSubscriptionInDynamo) + + const result = + await handler(event) + + expect(mockStoreSubscriptionInDynamo.mock.calls.length).toEqual(3) + + const storedSubscriptionIds = + mockStoreSubscriptionInDynamo.mock.calls.map(call => call[0].subscriptionId) + + // The receipts `"TEST_RECEIPT_1"` & `"TEST_RECEIPT_2"` together reference the subscriptions with IDs + // `"sub-1"`, `"sub-2"` & `"sub-3"`. Importantly, `"sub-4"` is referenced by the receipt `"TEST_RECEIPT_3"`, + // which is not present in the event payload and therefore not looked up and persisted to the Dynamo table. + expect(storedSubscriptionIds).toEqual(["sub-1", "sub-2", "sub-3"]) + }); +}); + +const buildSqsEvent = (receipts: string[]): SQSEvent => { + const records = receipts.map(receipt => + ({ + messageId: "", + receiptHandle: "", + body: JSON.stringify({ receipt: receipt }), + attributes: { + ApproximateReceiveCount: "", + SentTimestamp: "", + SenderId: "", + ApproximateFirstReceiveTimestamp: "", + }, + messageAttributes: {}, + md5OfBody: "", + eventSource: "", + eventSourceARN: "", + awsRegion: "", + })) + + return { + Records: records + } +} + +const mockStoreSubscriptionInDynamo = + jest.fn((subscription: Subscription) => Promise.resolve()); + +const stubFetchSubscriptionsFromApple = + (reference: AppleSubscriptionReference) => Promise.resolve(subscriptions.filter(s => s.receipt == reference.receipt)); + +const subscription = + (id: string, product: string, receipt: string) => + new Subscription(id, "", "", "", false, product, "ios-feast", false, "6M", null, receipt, null) + +const subscriptions = [ + subscription( "sub-1", "prod-1", "TEST_RECEIPT_1"), + subscription( "sub-2", "prod-1", "TEST_RECEIPT_2"), + subscription( "sub-3", "prod-2", "TEST_RECEIPT_2"), + subscription( "sub-4", "prod-1", "TEST_RECEIPT_3") +]