diff --git a/StripeConnect/StripeConnect.xcodeproj/project.pbxproj b/StripeConnect/StripeConnect.xcodeproj/project.pbxproj index 21b44644d9e..a05bd0508ca 100644 --- a/StripeConnect/StripeConnect.xcodeproj/project.pbxproj +++ b/StripeConnect/StripeConnect.xcodeproj/project.pbxproj @@ -88,6 +88,9 @@ 41D17A6E2C5A7429007C6EE6 /* Version.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 41D17A632C5A7429007C6EE6 /* Version.xcconfig */; }; E6165CBF2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */; }; E6165CC12CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */; }; + E640C9CC2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */; }; + E640C9CF2CBF26DE009D0C6E /* AuthenticatedWebViewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */; }; + E640C9D32CBF479E009D0C6E /* AuthenticatedWebViewManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */; }; E65691202CA5248300E0DB00 /* AccountManagementViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */; }; E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */; }; E65691252CA52F9D00E0DB00 /* NotificationBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */; }; @@ -197,6 +200,9 @@ 41D17A632C5A7429007C6EE6 /* Version.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandler.swift; sourceTree = ""; }; E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandlerTests.swift; sourceTree = ""; }; + E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewManager.swift; sourceTree = ""; }; + E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewError.swift; sourceTree = ""; }; + E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewManagerTests.swift; sourceTree = ""; }; E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagementViewControllerTests.swift; sourceTree = ""; }; E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeConnect+Exports.swift"; sourceTree = ""; }; E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerViewController.swift; sourceTree = ""; }; @@ -294,6 +300,7 @@ 413987C62C63F34B001D375E /* Internal */ = { isa = PBXGroup; children = ( + E640C9CD2CBF26C9009D0C6E /* AuthenticatedWebView */, 416E9ED02C77F6C100A0B917 /* Extensions */, 410D0FE22C6D31C6009B0E26 /* StripeConnectConstants.swift */, 413987C42C63F34B001D375E /* Webview */, @@ -390,6 +397,7 @@ 41814EE62C6BC8690014EB5E /* Internal */ = { isa = PBXGroup; children = ( + E640C9D12CBF4790009D0C6E /* AuthenticatedWebView */, 41814EE52C6BC8610014EB5E /* Webview */, ); path = Internal; @@ -531,6 +539,23 @@ path = ../BuildConfigurations; sourceTree = ""; }; + E640C9CD2CBF26C9009D0C6E /* AuthenticatedWebView */ = { + isa = PBXGroup; + children = ( + E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */, + E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */, + ); + path = AuthenticatedWebView; + sourceTree = ""; + }; + E640C9D12CBF4790009D0C6E /* AuthenticatedWebView */ = { + isa = PBXGroup; + children = ( + E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */, + ); + path = AuthenticatedWebView; + sourceTree = ""; + }; E688AE012CADE35300951D97 /* NotificationBanner */ = { isa = PBXGroup; children = ( @@ -697,6 +722,7 @@ E6D3C8EE2CBE1404003CE967 /* HTTPStatusError.swift in Sources */, 413987CC2C63F34B001D375E /* VoidPayload.swift in Sources */, E65691272CA533CD00E0DB00 /* OnNotificationsChangeHandler.swift in Sources */, + E640C9CF2CBF26DE009D0C6E /* AuthenticatedWebViewError.swift in Sources */, 413987DD2C640A29001D375E /* FetchInitParamsMessageHandler.swift in Sources */, 413987D42C640848001D375E /* CallSetterWithSerializableValueSender.swift in Sources */, 416E9E742C751A1A00A0B917 /* ConnectComponentWebViewController.swift in Sources */, @@ -719,6 +745,7 @@ E6F485F82C9E35A5000D914F /* PaymentDetailsViewController.swift in Sources */, 416E9E762C751B0500A0B917 /* EmbeddedComponentManager.swift in Sources */, 416E9ECF2C77EAA400A0B917 /* EmbeddedComponentError.swift in Sources */, + E640C9CC2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift in Sources */, 410D0FDF2C6D3176009B0E26 /* ConnectWebViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -740,6 +767,7 @@ E6F485FE2C9E36B2000D914F /* PaymentDetailsViewControllerTests.swift in Sources */, 416E9E782C753B7900A0B917 /* ConnectComponentWebViewControllerTests.swift in Sources */, 410D0FD42C6D051B009B0E26 /* OpenAuthenticatedWebViewMessageHandlerTests.swift in Sources */, + E640C9D32CBF479E009D0C6E /* AuthenticatedWebViewManagerTests.swift in Sources */, E65691202CA5248300E0DB00 /* AccountManagementViewControllerTests.swift in Sources */, 410D0FCA2C6CFE27009B0E26 /* OnLoadErrorMessageHandlerTests.swift in Sources */, 41BCCFF32C8B449800797E01 /* TestHelpers.swift in Sources */, diff --git a/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewError.swift b/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewError.swift new file mode 100644 index 00000000000..8228f12a3ab --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewError.swift @@ -0,0 +1,22 @@ +// +// AuthenticatedWebViewError.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/15/24. +// + +enum AuthenticatedWebViewError: Int, Error { + + // NOTE: These integer values should remain stable as they are used as + // error codes in error logging + + /// ASWebAuthenticationSession could not be started + /// - Note: This can occur if the app is backgrounded when attempting to present the web view + case cannotStartSession = 0 + + /// There's no window on which to present + case notInViewHierarchy = 1 + + /// An ASWebAuthenticationSession is currently already being presented + case alreadyPresenting = 2 +} diff --git a/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewManager.swift b/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewManager.swift new file mode 100644 index 00000000000..6801d8c2c0d --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/AuthenticatedWebView/AuthenticatedWebViewManager.swift @@ -0,0 +1,83 @@ +// +// AuthenticatedWebViewManager.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/15/24. +// + +import AuthenticationServices + +/// Manages authenticated web views for a single component +class AuthenticatedWebViewManager: NSObject { + typealias SessionFactory = ( + _ url: URL, + _ callbackURLScheme: String?, + _ completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler + ) -> ASWebAuthenticationSession + + /// Used to dependency inject in tests and wrap `ASWebAuthenticationSession.init` + private let sessionFactory: SessionFactory + + /// The currently presented auth session, if there is one + weak var authSession: ASWebAuthenticationSession? + + init(sessionFactory: @escaping SessionFactory = ASWebAuthenticationSession.init) { + self.sessionFactory = sessionFactory + } + + /// Returns the redirect URL or nil if the user cancelled the flow + @MainActor + func present(with url: URL, from view: UIView) async throws -> URL? { + guard authSession == nil else { + throw AuthenticatedWebViewError.alreadyPresenting + } + guard let window = view.window else { + throw AuthenticatedWebViewError.notInViewHierarchy + } + + let presentationContextProvider = AuthenticatedWebViewPresentationContextProvider(window: window) + + let returnUrl: URL? = try await withCheckedThrowingContinuation { continuation in + let authSession = sessionFactory(url, StripeConnectConstants.authenticatedWebViewReturnUrlScheme) { returnUrl, error in + + if let authenticationSessionError = error as? ASWebAuthenticationSessionError, + authenticationSessionError.code == .canceledLogin { + // The user either selected "Cancel" in the initial modal + // prompting them to "Sign In" or they hit the "Cancel" + // button in presented browser view + continuation.resume(returning: nil) + return + } else if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: returnUrl) + } + authSession.presentationContextProvider = presentationContextProvider + self.authSession = authSession + + guard authSession.canStart, + authSession.start() else { + continuation.resume(throwing: AuthenticatedWebViewError.cannotStartSession) + return + } + } + + return returnUrl + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +private class AuthenticatedWebViewPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + let window: UIWindow + + init(window: UIWindow) { + self.window = window + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return window + } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/StripeConnectConstants.swift b/StripeConnect/StripeConnect/Source/Internal/StripeConnectConstants.swift index d55bffea4e3..b8000a3cd9d 100644 --- a/StripeConnect/StripeConnect/Source/Internal/StripeConnectConstants.swift +++ b/StripeConnect/StripeConnect/Source/Internal/StripeConnectConstants.swift @@ -20,4 +20,8 @@ enum StripeConnectConstants { ] static let connectJSBaseURL = URL(string: "https://connect-js.stripe.com/v1.0/ios_webview.html")! + + /// The authenticated web view will redirect back to the SDK when redirecting + /// to the URL scheme `stripe-connect://` + static let authenticatedWebViewReturnUrlScheme = "stripe-connect" } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift index 8b036d1f033..7f8f75be32b 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift @@ -25,6 +25,9 @@ class ConnectComponentWebViewController: ConnectWebViewController { /// The current notification center instance private let notificationCenter: NotificationCenter + /// Manages authenticated web views + private let authenticatedWebViewManager: AuthenticatedWebViewManager + private let setterMessageHandler: OnSetterFunctionCalledMessageHandler = .init() private var didFailLoadWithError: (Error) -> Void @@ -44,11 +47,13 @@ class ConnectComponentWebViewController: ConnectWebViewController { didFailLoadWithError: @escaping (Error) -> Void, // Should only be overridden for tests notificationCenter: NotificationCenter = NotificationCenter.default, - webLocale: Locale = Locale.autoupdatingCurrent + webLocale: Locale = Locale.autoupdatingCurrent, + authenticatedWebViewManager: AuthenticatedWebViewManager = .init() ) { self.componentManager = componentManager self.notificationCenter = notificationCenter self.webLocale = webLocale + self.authenticatedWebViewManager = authenticatedWebViewManager self.didFailLoadWithError = didFailLoadWithError let config = WKWebViewConfiguration() @@ -93,14 +98,16 @@ class ConnectComponentWebViewController: ConnectWebViewController { didFailLoadWithError: @escaping (Error) -> Void, // Should only be overridden for tests notificationCenter: NotificationCenter = NotificationCenter.default, - webLocale: Locale = Locale.autoupdatingCurrent) { + webLocale: Locale = Locale.autoupdatingCurrent, + authenticatedWebViewManager: AuthenticatedWebViewManager = .init()) { self.init(componentManager: componentManager, componentType: componentType, loadContent: loadContent, fetchInitProps: VoidPayload.init, didFailLoadWithError: didFailLoadWithError, notificationCenter: notificationCenter, - webLocale: webLocale) + webLocale: webLocale, + authenticatedWebViewManager: authenticatedWebViewManager) } required init?(coder: NSCoder) { @@ -204,6 +211,9 @@ private extension ConnectComponentWebViewController { addMessageHandler(AccountSessionClaimedMessageHandler{ _ in // TODO: MXMOBILE-2491 Use this for analytics }) + addMessageHandler(OpenAuthenticatedWebViewMessageHandler { [weak self] payload in + self?.openAuthenticatedWebView(payload) + }) } /// Adds NotificationCenter observers @@ -229,4 +239,19 @@ private extension ConnectComponentWebViewController { didFailLoadWithError(error) activityIndicator.stopAnimating() } + + /// Opens the url in the given payload in an ASWebAuthenticationSession and sends the resulting redirect to + func openAuthenticatedWebView(_ payload: OpenAuthenticatedWebViewMessageHandler.Payload) { + Task { @MainActor in + do { + // TODO: MXMOBILE-2491 log `component.authenticated_web.*` analytic + let returnUrl = try await authenticatedWebViewManager.present(with: payload.url, from: view) + + sendMessage(ReturnedFromAuthenticatedWebViewSender(payload: .init(url: returnUrl, id: payload.id))) + } catch { + // TODO: MXMOBILE-2491 log `component.authenticated_web.error` analytic + debugPrint("Error returning from authenticated web view: \(error)") + } + } + } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift index 9382eaf5d4f..3b37b184354 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift @@ -36,8 +36,8 @@ class ScriptMessageHandlerWithReply: NS return (response, nil) } catch { - debugPrint("Error processing message: \(error.localizedDescription)") - return (nil, error.localizedDescription) + debugPrint("Error processing message: \((error as NSError).debugDescription)") + return (nil, (error as NSError).debugDescription) } } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSender.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSender.swift index 3853d0ae9b5..b0f97364f9e 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSender.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSender.swift @@ -10,8 +10,10 @@ import Foundation /// Notifies that the user finished the flow within the `ASWebAuthenticationSession` struct ReturnedFromAuthenticatedWebViewSender: MessageSender { struct Payload: Codable, Equatable { - /// The return URL from the `ASWebAuthenticationSession` redirect. This value will be nil if the user canceled out fo the view - let url: String? + /// The return URL from the `ASWebAuthenticationSession` redirect. This value will be nil if the user canceled out of the view + let url: URL? + /// The unique identifier sent from the web view in `openAuthenticatedWebView` + let id: String } let name: String = "returnedFromAuthenticatedWebView" let payload: Payload diff --git a/StripeConnect/StripeConnectTests/Internal/AuthenticatedWebView/AuthenticatedWebViewManagerTests.swift b/StripeConnect/StripeConnectTests/Internal/AuthenticatedWebView/AuthenticatedWebViewManagerTests.swift new file mode 100644 index 00000000000..8c7163ef764 --- /dev/null +++ b/StripeConnect/StripeConnectTests/Internal/AuthenticatedWebView/AuthenticatedWebViewManagerTests.swift @@ -0,0 +1,174 @@ +// +// AuthenticatedWebViewManagerTests.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/15/24. +// + +import AuthenticationServices +@testable import StripeConnect +import XCTest + +class AuthenticatedWebViewManagerTests: XCTestCase { + + /// Hold onto reference to UIWindow + private let mockWindow = UIWindow() + + /// A UIView embedded in a window + private let mockViewInWindow = UIView() + + override func setUp() { + super.setUp() + mockWindow.addSubview(mockViewInWindow) + } + + @MainActor + func testPresent_whileAlreadyPresentingThrowsError() async { + do { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + XCTFail("Manager should not be instantiated") + return MockWebAuthenticationSession(url: url, callbackURLScheme: scheme, completionHandler: handler) + } + let alreadyPresentingSession = MockWebAuthenticationSession(url: URL(string: "https://already_presenting")!, callbackURLScheme: nil, completionHandler: { _, _ in }) + manager.authSession = alreadyPresentingSession + + _ = try await manager.present(with: URL(string: "https://new_present")!, from: mockViewInWindow) + + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? AuthenticatedWebViewError, AuthenticatedWebViewError.alreadyPresenting) + } + } + + func testPresent_withoutWindowThrowsError() async { + do { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + XCTFail("Manager should not be instantiated") + return MockWebAuthenticationSession(url: url, callbackURLScheme: scheme, completionHandler: handler) + } + + _ = try await manager.present(with: URL(string: "https://stripe.com")!, from: UIView()) + + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? AuthenticatedWebViewError, AuthenticatedWebViewError.notInViewHierarchy) + } + } + + func testPresent_cannotStartThrowsError() async { + var mockAuthSession: MockWebAuthenticationSession? + do { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + mockAuthSession = MockWebAuthenticationSession(url: url, callbackURLScheme: scheme, completionHandler: handler) + + // Mock that auth session can't start + mockAuthSession?.overrideCanStart = false + return mockAuthSession! + } + + _ = try await manager.present(with: URL(string: "https://stripe.com")!, from: mockViewInWindow) + + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? AuthenticatedWebViewError, AuthenticatedWebViewError.cannotStartSession) + } + XCTAssertEqual(mockAuthSession?.didStart, false) + } + + @MainActor + func testPresent_startsSession_success() async throws { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + let mockAuthSession = MockWebAuthenticationSession( + url: url, + callbackURLScheme: scheme, + completionHandler: handler + ) + // Mock that completion handler completes as soon as the session is started with URL + mockAuthSession.overrideCompletionResult = .success(URL(string: "stripe-connect://success")!) + return mockAuthSession + } + + let result = try await manager.present(with: URL(string: "https://stripe.com")!, from: mockViewInWindow) + + XCTAssertEqual(result?.absoluteString, "stripe-connect://success") + } + + @MainActor + func testPresent_startsSession_userCanceled() async throws { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + let mockAuthSession = MockWebAuthenticationSession( + url: url, + callbackURLScheme: scheme, + completionHandler: handler + ) + // Mock that completion handler completes as soon as the session is started with canceledLogin error + mockAuthSession.overrideCompletionResult = .failure( + ASWebAuthenticationSessionError( + _nsError: NSError(domain: ASWebAuthenticationSessionError.errorDomain, code: ASWebAuthenticationSessionError.canceledLogin.rawValue) + ) + ) + return mockAuthSession + } + + let result = try await manager.present(with: URL(string: "https://stripe.com")!, from: mockViewInWindow) + + XCTAssertEqual(result, nil) + } + + @MainActor + func testPresent_startsSession_error() async { + let manager = AuthenticatedWebViewManager { url, scheme, handler in + let mockAuthSession = MockWebAuthenticationSession( + url: url, + callbackURLScheme: scheme, + completionHandler: handler + ) + // Mock that completion handler completes as soon as the session is started with error + mockAuthSession.overrideCompletionResult = .failure( + NSError(domain: "custom_error", code: 111) + ) + return mockAuthSession + } + + do { + _ = try await manager.present(with: URL(string: "https://stripe.com")!, from: mockViewInWindow) + XCTFail("Expected error") + } catch { + XCTAssertEqual((error as NSError).domain, "custom_error") + XCTAssertEqual((error as NSError).code, 111) + } + } +} + +private class MockWebAuthenticationSession: ASWebAuthenticationSession { + var overrideCanStart: Bool = true + + var didStart = false + + var overrideCompletionResult: Result? + + private let completionHandler: CompletionHandler + + override init(url: URL, callbackURLScheme: String?, completionHandler: @escaping CompletionHandler) { + self.completionHandler = completionHandler + super.init(url: url, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler) + } + + override var canStart: Bool { + overrideCanStart + } + + override func start() -> Bool { + didStart = true + + if let overrideCompletionResult { + do { + completionHandler(try overrideCompletionResult.get(), nil) + } catch { + completionHandler(nil, error) + } + } + + return overrideCanStart + } +} diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift index ee03d2f5462..204ada941e6 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift @@ -229,6 +229,30 @@ class ConnectComponentWebViewControllerTests: XCTestCase { // Loading indicator should stop XCTAssertFalse(webVC.activityIndicator.isAnimating) } + + func testOpenAuthenticatedWebView() throws { + let componentManager = componentManagerAssertingOnFetch() + let authenticatedWebViewManager = MockAuthenticatedWebViewManager { url, _ in + XCTAssertEqual(url.absoluteString, "https://stripe.com/start") + return URL(string: "stripe-connect://return_url")! + } + let webVC = ConnectComponentWebViewController(componentManager: componentManager, + componentType: .payouts, + loadContent: false, + didFailLoadWithError: { _ in }, + authenticatedWebViewManager: authenticatedWebViewManager) + + let expectation = try webVC.webView.expectationForMessageReceived( + sender: ReturnedFromAuthenticatedWebViewSender(payload: .init( + url: URL(string: "stripe-connect://return_url"), + id: "1234" + )) + ) + + webVC.webView.evaluateOpenAuthenticatedWebView(url: "https://stripe.com/start", id: "1234") + + wait(for: [expectation], timeout: TestHelpers.defaultTimeout) + } } // MARK: - Helpers @@ -243,3 +267,17 @@ private extension ConnectComponentWebViewControllerTests { }) } } + +private class MockAuthenticatedWebViewManager: AuthenticatedWebViewManager { + var overridePresent: (_ url: URL, _ view: UIView) async throws -> URL? + + init(overridePresent: @escaping (_ url: URL, _ view: UIView) async throws -> URL?) { + self.overridePresent = overridePresent + super.init() + } + + @MainActor + override func present(with url: URL, from view: UIView) async throws -> URL? { + try await overridePresent(url, view) + } +} diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift index 485f12b5621..88216bca75a 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift @@ -11,14 +11,14 @@ import XCTest class ReturnedFromAuthenticatedWebViewSenderTests: ScriptWebTestBase { func testSendMessage() throws { - try validateMessageSent(sender: ReturnedFromAuthenticatedWebViewSender(payload: .init(url: "https://dashboard.stripe.com"))) + try validateMessageSent(sender: ReturnedFromAuthenticatedWebViewSender(payload: .init(url: URL(string: "https://dashboard.stripe.com")!, id: "123"))) } func testSenderSignature() { XCTAssertEqual( - ReturnedFromAuthenticatedWebViewSender(payload: .init(url: "https://dashboard.stripe.com")).javascriptMessage, + ReturnedFromAuthenticatedWebViewSender(payload: .init(url: URL(string: "https://dashboard.stripe.com")!, id: "123")).javascriptMessage, """ - window.returnedFromAuthenticatedWebView({"url":"https:\\/\\/dashboard.stripe.com"}); + window.returnedFromAuthenticatedWebView({"id":"123","url":"https:\\/\\/dashboard.stripe.com"}); """ ) }