diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index c342622342..de0d8cdd89 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -272,6 +272,7 @@ BE80C00D29C8B4B900793A6C /* BTCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE80C00C29C8B4B900793A6C /* BTCard.swift */; }; BE82E73B29C49C050059FE97 /* BTThreeDSecureV2ButtonCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E73A29C49C050059FE97 /* BTThreeDSecureV2ButtonCustomization.swift */; }; BE82E73F29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E73E29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift */; }; + BE849EED2D66908E005BD215 /* InvalidAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */; }; BE895C61299433FB008112AB /* BTApplePayCardNonce.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C60299433FB008112AB /* BTApplePayCardNonce.swift */; }; BE895C6329944BD3008112AB /* BTApplePayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C6229944BD3008112AB /* BTApplePayError.swift */; }; BE895C6529944BF5008112AB /* BTApplePayClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C6429944BF5008112AB /* BTApplePayClient.swift */; }; @@ -966,6 +967,7 @@ BE82E73C29C49ECE0059FE97 /* BTThreeDSecureV2LabelCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2LabelCustomization.swift; sourceTree = ""; }; BE82E73E29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2ToolbarCustomization.swift; sourceTree = ""; }; BE82E74029C4A1330059FE97 /* BTThreeDSecureV2TextBoxCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2TextBoxCustomization.swift; sourceTree = ""; }; + BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidAuthorization.swift; sourceTree = ""; }; BE895C60299433FB008112AB /* BTApplePayCardNonce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayCardNonce.swift; sourceTree = ""; }; BE895C6229944BD3008112AB /* BTApplePayError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayError.swift; sourceTree = ""; }; BE895C6429944BF5008112AB /* BTApplePayClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayClient.swift; sourceTree = ""; }; @@ -1583,6 +1585,7 @@ 80B207342BF6D3B000787E37 /* Authorization */ = { isa = PBXGroup; children = ( + BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */, BED00CAF28A579D700D74AEC /* BTClientToken.swift */, BED00CB128A57AD400D74AEC /* BTClientTokenError.swift */, BE24C67428E7491E0067B11A /* ClientAuthorization.swift */, @@ -3207,6 +3210,7 @@ BE2F98D028A2BCCD008EF189 /* BTConfiguration.swift in Sources */, 804DC45D2B2D08FF00F17A15 /* BTConfigurationRequest.swift in Sources */, BED00CB228A57AD400D74AEC /* BTClientTokenError.swift in Sources */, + BE849EED2D66908E005BD215 /* InvalidAuthorization.swift in Sources */, BE24C67328E73E810067B11A /* BTAPIClientHTTPType.swift in Sources */, 457D7FC82C29CEC300EF6523 /* RepeatingTimer.swift in Sources */, BE9EC0982899CF040022EC63 /* BTAPIPinnedCertificates.swift in Sources */, diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a6116ba4..28dd9c06b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ * Remove `fetchPaymentMethodNonces` methods and parser * BraintreePayPal * Update PayPal app URL query scheme from `paypal-app-switch-checkout` to `paypal` + * BraintreeAmericanExpress + * Update initializer to `BTAmericanExpressClient(authorization:)` ## 6.27.0 (2025-01-23) * BraintreePayPal diff --git a/Demo/Application/Base/PaymentButtonBaseViewController.swift b/Demo/Application/Base/PaymentButtonBaseViewController.swift index 3c1456fc2b..ec267a4bb3 100644 --- a/Demo/Application/Base/PaymentButtonBaseViewController.swift +++ b/Demo/Application/Base/PaymentButtonBaseViewController.swift @@ -3,7 +3,9 @@ import BraintreeCore class PaymentButtonBaseViewController: BaseViewController { + // TODO: remove API client in final PR let apiClient: BTAPIClient + let authorization: String var heightConstraint: CGFloat? @@ -12,6 +14,7 @@ class PaymentButtonBaseViewController: BaseViewController { override init(authorization: String) { // swiftlint:disable:next force_unwrapping apiClient = BTAPIClient(authorization: authorization)! + self.authorization = authorization super.init(authorization: authorization) } diff --git a/Demo/Application/Features/AmexViewController.swift b/Demo/Application/Features/AmexViewController.swift index 1c05997905..e2d94c2fba 100644 --- a/Demo/Application/Features/AmexViewController.swift +++ b/Demo/Application/Features/AmexViewController.swift @@ -4,7 +4,7 @@ import BraintreeCard class AmexViewController: PaymentButtonBaseViewController { - lazy var amexClient = BTAmericanExpressClient(apiClient: apiClient) + lazy var amexClient = BTAmericanExpressClient(authorization: authorization) lazy var cardClient = BTCardClient(apiClient: apiClient) override func viewDidLoad() { diff --git a/IntegrationTests/BraintreeAmexExpress_IntegrationTests.swift b/IntegrationTests/BraintreeAmexExpress_IntegrationTests.swift index 2e5f523677..57c031546e 100644 --- a/IntegrationTests/BraintreeAmexExpress_IntegrationTests.swift +++ b/IntegrationTests/BraintreeAmexExpress_IntegrationTests.swift @@ -8,7 +8,7 @@ class BraintreeAmexExpress_IntegrationTests: XCTestCase { func testGetRewardsBalance_returnsResult() async { let apiClient = BTAPIClient(authorization: BTIntegrationTestsConstants.sandboxClientTokenVersion3)! let cardClient = BTCardClient(apiClient: apiClient) - let amexClient = BTAmericanExpressClient(apiClient: apiClient) + let amexClient = BTAmericanExpressClient(authorization: BTIntegrationTestsConstants.sandboxClientTokenVersion3) let card = BTCard( number: "371260714673002", diff --git a/SampleApps/CarthageTest/CarthageTest/ViewController.swift b/SampleApps/CarthageTest/CarthageTest/ViewController.swift index 9e369e5dc4..88570aae90 100644 --- a/SampleApps/CarthageTest/CarthageTest/ViewController.swift +++ b/SampleApps/CarthageTest/CarthageTest/ViewController.swift @@ -16,7 +16,7 @@ class ViewController: UIViewController { override func viewDidLoad() { let apiClient = BTAPIClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")! - let amexClient = BTAmericanExpressClient(apiClient: apiClient) + let amexClient = BTAmericanExpressClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn") let applePayClient = BTApplePayClient(apiClient: apiClient) let cardClient = BTCardClient(apiClient: apiClient) let dataCollector = BTDataCollector(apiClient: apiClient) diff --git a/SampleApps/SPMTest/SPMTest/ViewController.swift b/SampleApps/SPMTest/SPMTest/ViewController.swift index b1525c84a1..b6a3941bcd 100644 --- a/SampleApps/SPMTest/SPMTest/ViewController.swift +++ b/SampleApps/SPMTest/SPMTest/ViewController.swift @@ -16,7 +16,7 @@ class ViewController: UIViewController { override func viewDidLoad() { let apiClient = BTAPIClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")! - let amexClient = BTAmericanExpressClient(apiClient: apiClient) + let amexClient = BTAmericanExpressClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn") let applePayClient = BTApplePayClient(apiClient: apiClient) let cardClient = BTCardClient(apiClient: apiClient) let dataCollector = BTDataCollector(apiClient: apiClient) diff --git a/Sources/BraintreeAmericanExpress/BTAmericanExpressClient.swift b/Sources/BraintreeAmericanExpress/BTAmericanExpressClient.swift index fe3c2ca58f..6fc27b5ac7 100644 --- a/Sources/BraintreeAmericanExpress/BTAmericanExpressClient.swift +++ b/Sources/BraintreeAmericanExpress/BTAmericanExpressClient.swift @@ -7,13 +7,14 @@ import BraintreeCore /// `BTAmericanExpressClient` enables you to look up the rewards balance of American Express cards. @objc public class BTAmericanExpressClient: NSObject { - private let apiClient: BTAPIClient + /// exposed for testing + var apiClient: BTAPIClient /// Creates an American Express client. - /// - Parameter apiClient: An instance of `BTAPIClient` + /// - Parameter authorization: A valid client token or tokenization key used to authorize API calls @objc(initWithAPIClient:) - public init(apiClient: BTAPIClient) { - self.apiClient = apiClient + public init(authorization: String) { + self.apiClient = BTAPIClient(newAuthorization: authorization) } /// Gets the rewards balance associated with a Braintree nonce. Only for American Express cards. diff --git a/Sources/BraintreeCore/Authorization/ClientAuthorization.swift b/Sources/BraintreeCore/Authorization/ClientAuthorization.swift index 1b5d0d594f..d4b8fc93ce 100644 --- a/Sources/BraintreeCore/Authorization/ClientAuthorization.swift +++ b/Sources/BraintreeCore/Authorization/ClientAuthorization.swift @@ -24,4 +24,5 @@ public protocol ClientAuthorization { public enum AuthorizationType { case tokenizationKey case clientToken + case invalidAuthorization } diff --git a/Sources/BraintreeCore/Authorization/InvalidAuthorization.swift b/Sources/BraintreeCore/Authorization/InvalidAuthorization.swift new file mode 100644 index 0000000000..7766868f3d --- /dev/null +++ b/Sources/BraintreeCore/Authorization/InvalidAuthorization.swift @@ -0,0 +1,20 @@ +import Foundation + +/// An invalid authorization type +class InvalidAuthorization: ClientAuthorization { + + let type = AuthorizationType.invalidAuthorization + let configURL: URL + let bearer: String + let originalValue: String + + init(_ rawValue: String) { + self.bearer = rawValue + self.originalValue = rawValue + + // swiftlint:disable force_unwrapping + /// This URL is never used in the SDK as we always return an error if the authorization type is `.invalidAuthorization` + /// before construting or using the `configURL` in any way + self.configURL = URL(string: "https://paypal.com")! + } +} diff --git a/Sources/BraintreeCore/Authorization/TokenizationKey.swift b/Sources/BraintreeCore/Authorization/TokenizationKey.swift index e291045b47..8aebe3492d 100644 --- a/Sources/BraintreeCore/Authorization/TokenizationKey.swift +++ b/Sources/BraintreeCore/Authorization/TokenizationKey.swift @@ -17,6 +17,7 @@ class TokenizationKey: ClientAuthorization { guard let configURL = TokenizationKey.baseURLFromTokenizationKey(rawValue) else { throw TokenizationKeyError.invalid } + self.configURL = configURL } diff --git a/Sources/BraintreeCore/BTAPIClient.swift b/Sources/BraintreeCore/BTAPIClient.swift index ecdf34656f..442eb7eee3 100644 --- a/Sources/BraintreeCore/BTAPIClient.swift +++ b/Sources/BraintreeCore/BTAPIClient.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable type_body_length file_length /// This class acts as the entry point for accessing the Braintree APIs via common HTTP methods performed on API endpoints. /// - Note: It also manages authentication via tokenization key and provides access to a merchant's gateway configuration. @objcMembers public class BTAPIClient: NSObject, BTHTTPNetworkTiming { @@ -12,7 +13,7 @@ import Foundation /// The TokenizationKey or ClientToken used to authorize the APIClient public var authorization: ClientAuthorization - + /// Client metadata that is used for tracking the client session public private(set) var metadata: BTClientMetadata @@ -34,7 +35,7 @@ import Foundation public init?(authorization: String) { self.metadata = BTClientMetadata() - guard let authorizationType = Self.authorizationType(for: authorization) else { return nil } + let authorizationType = Self.authorizationType(for: authorization) switch authorizationType { case .tokenizationKey: @@ -50,6 +51,8 @@ import Foundation } catch { return nil } + case .invalidAuthorization: + return nil } let btHttp = BTHTTP(authorization: self.authorization) @@ -65,6 +68,31 @@ import Foundation // No-op } } + + // TODO: rename param to authorization in final PR - set as newAuthorization currently since otherwise the two inits have the same signature + /// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. + /// Initialize a new API client. + /// - Parameter authorization: Your tokenization key or client token. + @_documentation(visibility: private) + public init(newAuthorization: String) { + self.authorization = Self.authorization(from: newAuthorization) + self.metadata = BTClientMetadata() + + let btHTTP = BTHTTP(authorization: self.authorization) + http = btHTTP + configurationLoader = ConfigurationLoader(http: btHTTP) + + super.init() + + analyticsService.setAPIClient(self) + http?.networkTimingDelegate = self + + // Kickoff the background request to fetch the config + fetchOrReturnRemoteConfiguration { _, _ in + // No-op + } + } + // MARK: - Deinit @@ -93,7 +121,6 @@ import Foundation @_documentation(visibility: private) public func fetchOrReturnRemoteConfiguration(_ completion: @escaping (BTConfiguration?, Error?) -> Void) { // TODO: - Consider updating all feature clients to use async version of this method? - Task { @MainActor in do { let configuration = try await configurationLoader.getConfig() @@ -127,6 +154,11 @@ import Foundation httpType: BTAPIClientHTTPService = .gateway, completion: @escaping RequestCompletion ) { + if authorization.type == .invalidAuthorization { + completion(nil, nil, BTAPIClientError.invalidAuthorization(authorization.originalValue)) + return + } + fetchOrReturnRemoteConfiguration { [weak self] configuration, error in guard let self else { completion(nil, nil, BTAPIClientError.deallocated) @@ -162,6 +194,11 @@ import Foundation httpType: BTAPIClientHTTPService = .gateway, completion: @escaping RequestCompletion ) { + if authorization.type == .invalidAuthorization { + completion(nil, nil, BTAPIClientError.invalidAuthorization(authorization.originalValue)) + return + } + fetchOrReturnRemoteConfiguration { [weak self] configuration, error in guard let self else { completion(nil, nil, BTAPIClientError.deallocated) @@ -197,6 +234,11 @@ import Foundation httpType: BTAPIClientHTTPService = .gateway, completion: @escaping RequestCompletion ) { + if authorization.type == .invalidAuthorization { + completion(nil, nil, BTAPIClientError.invalidAuthorization(authorization.originalValue)) + return + } + fetchOrReturnRemoteConfiguration { [weak self] configuration, error in guard let self else { completion(nil, nil, BTAPIClientError.deallocated) @@ -291,9 +333,11 @@ import Foundation // MARK: - Internal Static Methods - static func authorizationType(for authorization: String) -> AuthorizationType? { + static func authorizationType(for authorization: String) -> AuthorizationType { let pattern: String = "([a-zA-Z0-9]+)_[a-zA-Z0-9]+_([a-zA-Z0-9_]+)" - guard let regularExpression = try? NSRegularExpression(pattern: pattern) else { return nil } + guard let regularExpression = try? NSRegularExpression(pattern: pattern) else { + return .invalidAuthorization + } let tokenizationKeyMatch: NSTextCheckingResult? = regularExpression.firstMatch( in: authorization, @@ -335,7 +379,7 @@ import Foundation } } - func payPalAPIURL(forEnvironment environment: String) -> URL? { + private func payPalAPIURL(forEnvironment environment: String) -> URL? { if environment.caseInsensitiveCompare("sandbox") == .orderedSame || environment.caseInsensitiveCompare("development") == .orderedSame { return BTCoreConstants.payPalSandboxURL @@ -343,6 +387,27 @@ import Foundation return BTCoreConstants.payPalProductionURL } } + + private static func authorization(from authorization: String) -> ClientAuthorization { + let authorizationType = Self.authorizationType(for: authorization) + + switch authorizationType { + case .tokenizationKey: + do { + return try TokenizationKey(authorization) + } catch { + return InvalidAuthorization(authorization) + } + case .clientToken: + do { + return try BTClientToken(clientToken: authorization) + } catch { + return InvalidAuthorization(authorization) + } + case .invalidAuthorization: + return InvalidAuthorization(authorization) + } + } // MARK: BTAPITimingDelegate conformance diff --git a/Sources/BraintreeCore/BTAPIClientError.swift b/Sources/BraintreeCore/BTAPIClientError.swift index dffb34f80a..74a8f1d255 100644 --- a/Sources/BraintreeCore/BTAPIClientError.swift +++ b/Sources/BraintreeCore/BTAPIClientError.swift @@ -1,7 +1,7 @@ import Foundation /// Error codes associated with a API Client. -public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatable { +public enum BTAPIClientError: Error, CustomNSError, LocalizedError, Equatable { /// 0. Configuration fetch failed case configurationUnavailable @@ -14,13 +14,27 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab /// 3. Failed to base64 encode an authorizationFingerprint or tokenizationKey, when used as a cacheKey case failedBase64Encoding + + /// 4. Invalid authorization + case invalidAuthorization(String) public static var errorDomain: String { "com.braintreepayments.BTAPIClientErrorDomain" } public var errorCode: Int { - rawValue + switch self { + case .configurationUnavailable: + return 0 + case .notAuthorized: + return 1 + case .deallocated: + return 2 + case .failedBase64Encoding: + return 3 + case .invalidAuthorization: + return 4 + } } public var errorDescription: String? { @@ -36,6 +50,9 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab case .failedBase64Encoding: return "Unable to base64 encode the authorization string." + + case .invalidAuthorization(let authorization): + return "Invalid authorization provided: \(authorization)." } } } diff --git a/UnitTests/BraintreeAmericanExpressTests/BTAmericanExpressClient_Tests.swift b/UnitTests/BraintreeAmericanExpressTests/BTAmericanExpressClient_Tests.swift index f55e284a72..972068c861 100644 --- a/UnitTests/BraintreeAmericanExpressTests/BTAmericanExpressClient_Tests.swift +++ b/UnitTests/BraintreeAmericanExpressTests/BTAmericanExpressClient_Tests.swift @@ -10,12 +10,12 @@ class BTAmericanExpressClient_Tests: XCTestCase { override func setUp() { super.setUp() - mockAPIClient = MockAPIClient(authorization: "development_tokenization_key")! - amexClient = BTAmericanExpressClient(apiClient: mockAPIClient) + amexClient = BTAmericanExpressClient(authorization: "development_tokenization_key") + amexClient?.apiClient = mockAPIClient } func testGetRewardsBalance_formatsGETRequest() async { - let result = try? await amexClient!.getRewardsBalance(forNonce: "fake-nonce", currencyISOCode: "fake-code") + let _ = try? await amexClient!.getRewardsBalance(forNonce: "fake-nonce", currencyISOCode: "fake-code") XCTAssertEqual(mockAPIClient.lastGETPath, "v1/payment_methods/amex_rewards_balance") @@ -91,4 +91,27 @@ class BTAmericanExpressClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedAnalyticsEvents[mockAPIClient.postedAnalyticsEvents.count - 2], "amex:rewards-balance:started") XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last!, "amex:rewards-balance:failed") } + + func testGetRewardsBalance_withInvalidAuthorization_returnsError() { + amexClient = BTAmericanExpressClient(authorization: "badAuth") + mockAPIClient.cannedResponseError = NSError( + domain: BTAPIClientError.errorDomain, + code: BTAPIClientError.invalidAuthorization("").errorCode, + userInfo: [NSLocalizedDescriptionKey: BTAPIClientError.invalidAuthorization("").errorDescription ?? ""] + ) + + let expectation = expectation(description: "Amex reward balance should return invalid authorization error") + amexClient?.getRewardsBalance(forNonce: "", currencyISOCode: "") { rewardsBalance, error in + XCTAssertNil(rewardsBalance) + if let error = error as NSError? { + XCTAssertEqual(error.code, BTAPIClientError.invalidAuthorization("").errorCode) + XCTAssertEqual(error.localizedDescription, "Invalid authorization provided: badAuth.") + XCTAssertEqual(error.domain, BTAPIClientError.errorDomain) + } + + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + } } diff --git a/UnitTests/BraintreeCoreTests/Configuration/ConfigurationLoader_Tests.swift b/UnitTests/BraintreeCoreTests/Configuration/ConfigurationLoader_Tests.swift index 9bf0238d64..b938f44d24 100644 --- a/UnitTests/BraintreeCoreTests/Configuration/ConfigurationLoader_Tests.swift +++ b/UnitTests/BraintreeCoreTests/Configuration/ConfigurationLoader_Tests.swift @@ -75,7 +75,7 @@ class ConfigurationLoader_Tests: XCTestCase { } catch { guard let error = error as NSError? else { return } XCTAssertEqual(error.domain, BTAPIClientError.errorDomain) - XCTAssertEqual(error.code, BTAPIClientError.configurationUnavailable.rawValue) + XCTAssertEqual(error.code, BTAPIClientError.configurationUnavailable.errorCode) XCTAssertEqual(error.localizedDescription, "The operation couldn’t be completed. Unable to fetch remote configuration from Braintree API at this time.") } } diff --git a/V7_MIGRATION.md b/V7_MIGRATION.md index 6a3e212135..fd421087ed 100644 --- a/V7_MIGRATION.md +++ b/V7_MIGRATION.md @@ -14,6 +14,7 @@ _Documentation for v7 will be published to https://developer.paypal.com/braintre 1. [3D Secure](#3d-secure)] 1. [PayPal](#paypal) 1. [PayPal Native Checkout](#paypal-native-checkout) +1. [American Express](#american-express) ## Supported Versions @@ -68,3 +69,9 @@ For the App Switch flow, you must update your `info.plist` with a simplified URL ## PayPal Native Checkout The PayPal Native Checkout integration is no longer supported. Please remove it from your app and use the [PayPal (web)](https://developer.paypal.com/braintree/docs/guides/paypal/overview/ios/v6) integration. + +## American Express +Update initializer for `BTAmericanExpressClient`: +```diff +- var amexClient = BTAmericanExpressClient(apiClient: apiClient) ++ var amexClient = BTAmericanExpressClient(authorization: "")