diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index de597e96f..192814834 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -438,6 +438,11 @@ class CheckoutWebView: WKWebView { /// longer drive preload state, even after dismissal or reuse. var hasBeenPresented = false + /// Tracks whether checkout is currently visible. Unlike `hasBeenPresented`, + /// this resets when the presentation disappears so retained cached views + /// can discard challenges that cannot be completed in the background. + var checkoutIsVisible = false + /// Ensures one terminal failure is handled per checkout session, regardless /// of whether it originated from `ec.error` or WebKit process termination. private var hasHandledTerminalFailure = false @@ -808,13 +813,21 @@ extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) { if let response = navigationResponse.response as? HTTPURLResponse { - decisionHandler(handleResponse(response)) + decisionHandler( + handleResponse( + response, + isForMainFrame: navigationResponse.isForMainFrame + ) + ) return } decisionHandler(.allow) } - func handleResponse(_ response: HTTPURLResponse) -> WKNavigationResponsePolicy { + func handleResponse( + _ response: HTTPURLResponse, + isForMainFrame: Bool = true + ) -> WKNavigationResponsePolicy { let statusCode = response.statusCode let errorMessageForStatusCode = HTTPURLResponse.localizedString( forStatusCode: statusCode @@ -824,6 +837,24 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } + if isCloudflareManagedChallenge(response) { + guard isForMainFrame, + CheckoutWebView.preloadCache.contains(self), + !checkoutIsVisible + else { + OSLogger.shared.debug("Allowing Cloudflare managed challenge response to render") + return .allow + } + + OSLogger.shared.debug("Discarding cached Cloudflare managed challenge response") + stopLoading() + handleCachedViewFailure( + .httpError(statusCode: statusCode), + message: "HTTP response returned status code \(statusCode)." + ) + return .cancel + } + if statusCode >= 400 { handleCachedViewFailure( .httpError(statusCode: statusCode), @@ -842,6 +873,12 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } + private func isCloudflareManagedChallenge(_ response: HTTPURLResponse) -> Bool { + response.value(forHTTPHeaderField: "cf-mitigated")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("challenge") == .orderedSame + } + func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { let url = LogSafeURL.string(webView.url) OSLogger.shared.info("Started provisional navigation - url:\(url)") diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index 1fd0c6a0b..e9cfb523e 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -94,6 +94,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + checkoutView?.checkoutIsVisible = true view.backgroundColor = ShopifyCheckoutKit.configuration.backgroundColor } @@ -126,6 +127,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + checkoutView?.checkoutIsVisible = false if isBeingDismissed || navigationController?.isBeingDismissed == true || isMovingFromParent { cleanUpCheckoutView() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 4ed30cb75..94ea2a68c 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -107,6 +107,19 @@ class CheckoutWebViewControllerTests: XCTestCase { XCTAssertEqual(delegate.didDismissCount, 1) } + func test_viewVisibility_isSetBeforePresentationAndResetAfterDisappearance() throws { + let viewController = TestableCheckoutWebViewController(checkoutURL: url, entryPoint: nil) + let checkoutView = try XCTUnwrap(viewController.checkoutView) + + XCTAssertFalse(checkoutView.checkoutIsVisible) + + viewController.viewWillAppear(false) + XCTAssertTrue(checkoutView.checkoutIsVisible) + + viewController.viewDidDisappear(false) + XCTAssertFalse(checkoutView.checkoutIsVisible) + } + func test_presentationControllerDidDismiss_doesNotCleanUpBeforeViewDisappears() throws { ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.preload(checkout: url) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 22277d6df..9b2834fe1 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -154,6 +154,48 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(error.httpStatusCode, 403) } + func testCloudflareManagedChallengeRendersWithoutReportingCheckoutFailure() throws { + try view.load(checkout: XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123"))) + view.checkoutIsVisible = true + let link = try XCTUnwrap(view.url) + let didFail = expectation(description: "checkout failure was not reported") + didFail.isInverted = true + mockDelegate.didFailWithErrorExpectation = didFail + + let response = try XCTUnwrap( + HTTPURLResponse( + url: link, + statusCode: 403, + httpVersion: nil, + headerFields: ["CF-MITIGATED": " ChAlLeNgE\t"] + ) + ) + + XCTAssertEqual(view.handleResponse(response), .allow) + wait(for: [didFail], timeout: 0.1) + XCTAssertNil(mockDelegate.errorReceived) + } + + func testDifferentCloudflareMitigationValueUsesNormalHTTPErrorHandling() throws { + try view.load(checkout: url) + let link = try XCTUnwrap(view.url) + let didFail = expectation(description: "checkout failure was reported") + mockDelegate.didFailWithErrorExpectation = didFail + let response = try XCTUnwrap( + HTTPURLResponse( + url: link, + statusCode: 403, + httpVersion: nil, + headerFields: ["cf-mitigated": "block"] + ) + ) + + XCTAssertEqual(view.handleResponse(response), .cancel) + + wait(for: [didFail], timeout: 2) + XCTAssertEqual(mockDelegate.errorReceived?.code, .httpError) + } + func test401responseOnCheckoutURLCodeDelegation() throws { try view.load(checkout: XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123"))) let link = try XCTUnwrap(view.url) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f800bb67..d8a5c86ab 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -162,6 +162,125 @@ class PreloadObservabilityTests: XCTestCase { } } + func testCloudflareManagedChallengeDiscardsBackgroundedPreloadWithoutCheckoutFailure() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + view.load(checkout: url, isPreload: true) + let delegate = MockCheckoutWebViewDelegate() + view.viewDelegate = delegate + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: nil, + headerFields: ["CF-MITIGATED": " ChAlLeNgE\t"] + ) + ) + + XCTAssertEqual(view.handleResponse(response), .cancel) + + withExtendedLifetime(preload) { + XCTAssertEqual( + preload?.state, + .failed( + reason: .httpError(statusCode: 403), + message: "HTTP response returned status code 403." + ) + ) + } + XCTAssertFalse(CheckoutWebView.preloadCache.contains(view)) + XCTAssertNil(delegate.errorReceived) + XCTAssertEqual(delegate.failureCount, 0) + } + + func testCloudflareManagedChallengeRendersForPresentedPreload() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + view.load(checkout: url, isPreload: true) + let presentedView = try XCTUnwrap( + CheckoutWebView.preloadCache.view(for: PreloadKey(url: url, entryPoint: nil)) + ) + presentedView.checkoutIsVisible = true + let delegate = MockCheckoutWebViewDelegate() + presentedView.viewDelegate = delegate + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: nil, + headerFields: ["cf-mitigated": "challenge"] + ) + ) + + XCTAssertEqual(presentedView.handleResponse(response), .allow) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload?.state, .loading) + } + XCTAssertTrue(CheckoutWebView.preloadCache.contains(presentedView)) + XCTAssertNil(delegate.errorReceived) + XCTAssertEqual(delegate.failureCount, 0) + } + + func testCloudflareSubframeChallengeDoesNotDiscardBackgroundedPreload() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + view.load(checkout: url, isPreload: true) + let delegate = MockCheckoutWebViewDelegate() + view.viewDelegate = delegate + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: nil, + headerFields: ["cf-mitigated": "challenge"] + ) + ) + + XCTAssertEqual(view.handleResponse(response, isForMainFrame: false), .allow) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload?.state, .loading) + } + XCTAssertTrue(CheckoutWebView.preloadCache.contains(view)) + XCTAssertNil(delegate.errorReceived) + XCTAssertEqual(delegate.failureCount, 0) + } + + func testCloudflareManagedChallengeDiscardsDismissedCachedViewWithoutCheckoutFailure() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + view.load(checkout: url, isPreload: true) + let presentedView = try XCTUnwrap( + CheckoutWebView.preloadCache.view(for: PreloadKey(url: url, entryPoint: nil)) + ) + presentedView.checkoutIsVisible = true + presentedView.checkoutIsVisible = false + let delegate = MockCheckoutWebViewDelegate() + presentedView.viewDelegate = delegate + let response = try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: nil, + headerFields: ["cf-mitigated": "challenge"] + ) + ) + + XCTAssertEqual(presentedView.handleResponse(response), .cancel) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload?.state, .idle) + } + XCTAssertFalse(CheckoutWebView.preloadCache.contains(presentedView)) + XCTAssertNil(delegate.errorReceived) + XCTAssertEqual(delegate.failureCount, 0) + } + func testNavigationFailureTransitionsToFailed() { let preload = ShopifyCheckoutKit.preload(checkout: url) let view = CheckoutWebView(entryPoint: nil)