From 09c1d91b78ffbff65a8fe8a8725d8021a67fa59b Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 2 Nov 2022 11:49:46 -0700 Subject: [PATCH 1/2] Fixed SK2 promo purchases Fixes [SDKONCALL-160]. --- .../StoreKitAbstractions/PromotionalOffer.swift | 11 ++++++++++- .../StoreKitIntegrationTests.swift | 2 -- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift b/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift index 5ec1ae2ef5..b71a2761e1 100644 --- a/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift +++ b/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift @@ -122,11 +122,20 @@ extension PromotionalOffer.SignedData { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) var sk2PurchaseOption: Product.PurchaseOption { + let signature: Data + + if let decoded = Data(base64Encoded: self.signature) { + signature = decoded + } else { + // TODO: log warning + signature = .init() + } + return .promotionalOffer( offerID: self.identifier, keyID: self.keyIdentifier, nonce: self.nonce, - signature: self.signature.asData, + signature: signature, timestamp: self.timestamp ) } diff --git a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift index 6fedc13517..718589face 100644 --- a/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitIntegrationTests.swift @@ -377,8 +377,6 @@ class StoreKit1IntegrationTests: BaseBackendIntegrationTests { @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) func testPurchaseWithPromotionalOffer() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - try XCTSkipIf(Self.storeKit2Setting == .enabledForCompatibleDevices, - "This test is not currently passing on SK2") let user = UUID().uuidString From a36c1b6c81677018f5bc389d055b186502fd6a38 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 5 Dec 2022 15:48:54 -0800 Subject: [PATCH 2/2] Improved error handling and added tests --- Sources/Error Handling/ErrorUtils.swift | 2 + .../Purchases/PurchasesOrchestrator.swift | 5 +- .../PromotionalOffer.swift | 49 +++++++++++++------ .../PurchasesOrchestratorTests.swift | 27 ++++++++++ .../PromotionalOfferTests.swift | 29 ++++++++++- 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/Sources/Error Handling/ErrorUtils.swift b/Sources/Error Handling/ErrorUtils.swift index 77363012d4..9648c22942 100644 --- a/Sources/Error Handling/ErrorUtils.swift +++ b/Sources/Error Handling/ErrorUtils.swift @@ -408,9 +408,11 @@ enum ErrorUtils { */ static func invalidPromotionalOfferError( error: Error? = nil, + message: String? = nil, fileName: String = #fileID, functionName: String = #function, line: UInt = #line ) -> PurchasesError { return ErrorUtils.error(with: .invalidPromotionalOfferError, + message: message, underlyingError: error) } diff --git a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift index 87b28ab07c..a6d7044329 100644 --- a/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift +++ b/Sources/Purchasing/Purchases/PurchasesOrchestrator.swift @@ -434,7 +434,7 @@ final class PurchasesOrchestrator { Logger.debug( Strings.storeKit.sk2_purchasing_added_promotional_offer_option(signedData.identifier) ) - options.insert(signedData.sk2PurchaseOption) + options.insert(try signedData.sk2PurchaseOption) } return try await sk2Product.purchase(options: options) @@ -446,6 +446,9 @@ final class PurchasesOrchestrator { fetchPolicy: .cachedOrFetched), userCancelled: true ) + } catch let error as PromotionalOffer.SignedData.Error { + throw ErrorUtils.invalidPromotionalOfferError(error: error, + message: error.localizedDescription) } catch { throw ErrorUtils.purchasesError(withStoreKitError: error) } diff --git a/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift b/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift index b71a2761e1..46ba39bab2 100644 --- a/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift +++ b/Sources/Purchasing/StoreKitAbstractions/PromotionalOffer.swift @@ -102,6 +102,13 @@ extension PromotionalOffer.SignedData: @unchecked Sendable {} extension PromotionalOffer.SignedData { + enum Error: Swift.Error { + + /// The signature generated by the backend could not be decoded. + case failedToDecodeSignature(String) + + } + @available(iOS 12.2, macOS 10.14.4, watchOS 6.2, macCatalyst 13.0, tvOS 12.2, *) convenience init(sk1PaymentDiscount discount: SKPaymentDiscount) { self.init(identifier: discount.identifier, @@ -122,22 +129,36 @@ extension PromotionalOffer.SignedData { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) var sk2PurchaseOption: Product.PurchaseOption { - let signature: Data - - if let decoded = Data(base64Encoded: self.signature) { - signature = decoded - } else { - // TODO: log warning - signature = .init() + get throws { + let signature: Data + + if let decoded = Data(base64Encoded: self.signature) { + signature = decoded + } else { + throw Error.failedToDecodeSignature(self.signature) + } + + return .promotionalOffer( + offerID: self.identifier, + keyID: self.keyIdentifier, + nonce: self.nonce, + signature: signature, + timestamp: self.timestamp + ) } + } - return .promotionalOffer( - offerID: self.identifier, - keyID: self.keyIdentifier, - nonce: self.nonce, - signature: signature, - timestamp: self.timestamp - ) +} + +// MARK: - + +extension PromotionalOffer.SignedData.Error: LocalizedError { + + var errorDescription: String? { + switch self { + case let .failedToDecodeSignature(signature): + return "The signature generated by RevenueCat could not be decoded: \(signature)" + } } } diff --git a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift index ac6b27dc0a..af25580a7e 100644 --- a/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift +++ b/Tests/StoreKitUnitTests/PurchasesOrchestratorTests.swift @@ -467,6 +467,33 @@ class PurchasesOrchestratorTests: StoreKitConfigTestCase { } } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testPurchaseSK2PackageWithInvalidPromotionalOfferSignatureThrowsError() async throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + self.customerInfoManager.stubbedCachedCustomerInfoResult = self.mockCustomerInfo + self.backend.stubbedPostReceiptResult = .success(self.mockCustomerInfo) + + let product = try await self.fetchSk2Product() + let offer = PromotionalOffer.SignedData( + identifier: "identifier \(Int.random(in: 0..<1000))", + keyIdentifier: "key identifier \(Int.random(in: 0..<1000))", + nonce: .init(), + // This should be base64 + signature: "signature \(Int.random(in: 0..<1000))", + timestamp: Int.random(in: 0..<1000) + ) + + do { + _ = try await orchestrator.purchase(sk2Product: product, promotionalOffer: offer) + XCTFail("Expected error") + } catch { + expect(error).to(matchError(ErrorCode.invalidPromotionalOfferError)) + expect(error.localizedDescription) + .to(contain("The signature generated by RevenueCat could not be decoded: \(offer.signature)")) + } + } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func testPurchaseSK2PackageReturnsCustomerInfoForFailedTransaction() async throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() diff --git a/Tests/UnitTests/Purchasing/StoreKitAbstractions/PromotionalOfferTests.swift b/Tests/UnitTests/Purchasing/StoreKitAbstractions/PromotionalOfferTests.swift index 450bf10d73..1b51805e85 100644 --- a/Tests/UnitTests/Purchasing/StoreKitAbstractions/PromotionalOfferTests.swift +++ b/Tests/UnitTests/Purchasing/StoreKitAbstractions/PromotionalOfferTests.swift @@ -32,18 +32,35 @@ class PromotionalOfferTests: TestCase { func testSK2PurchaseOption() throws { try AvailabilityChecks.iOS15APIAvailableOrSkipTest() - let option = Self.randomOffer.sk2PurchaseOption + let option = try Self.randomOffer.sk2PurchaseOption let expected: Product.PurchaseOption = .promotionalOffer( offerID: Self.randomOffer.identifier, keyID: Self.randomOffer.keyIdentifier, nonce: Self.randomOffer.nonce, - signature: Self.randomOffer.signature.asData, + // `Product.PurchaseOption` conforms to Equatable but it does not compare this + // The only way to validate this is correct is integration tests. + signature: Data(), timestamp: Self.randomOffer.timestamp ) expect(option) == expected } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testSK2PurchaseOptionWithInvalidSignatureThrows() throws { + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + do { + _ = try Self.invalidOffer.sk2PurchaseOption + fail("Expected error") + } catch { + expect(error).to(matchError( + PromotionalOffer.SignedData.Error + .failedToDecodeSignature(Self.invalidOffer.signature) + )) + } + } + } // MARK: - Private @@ -51,6 +68,14 @@ class PromotionalOfferTests: TestCase { private extension PromotionalOfferTests { static let randomOffer: PromotionalOffer.SignedData = .init( + identifier: "identifier \(Int.random(in: 0..<1000))", + keyIdentifier: "key identifier \(Int.random(in: 0..<1000))", + nonce: .init(), + signature: "signature \(Int.random(in: 0..<1000))".asData.base64EncodedString(), + timestamp: Int.random(in: 0..<1000) + ) + + static let invalidOffer: PromotionalOffer.SignedData = .init( identifier: "identifier \(Int.random(in: 0..<1000))", keyIdentifier: "key identifier \(Int.random(in: 0..<1000))", nonce: .init(),