From 141c3f8a7e95f156ea54b06da107cfa55e9d93c5 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 10:35:27 +0100 Subject: [PATCH 1/3] [Swift] Handle Cloudflare managed challenges --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 32 ++++++- .../CloudflareManagedChallengeHandler.swift | 28 +++++++ .../CheckoutWebViewTests.swift | 21 +++++ ...oudflareManagedChallengeHandlerTests.swift | 84 +++++++++++++++++++ .../PreloadObservabilityTests.swift | 61 ++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index de597e96f..ac2f14d56 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -442,6 +442,8 @@ class CheckoutWebView: WKWebView { /// of whether it originated from `ec.error` or WebKit process termination. private var hasHandledTerminalFailure = false + private let cloudflareManagedChallengeHandler = CloudflareManagedChallengeHandler() + /// The checkout URL passed to `load(checkout:)`. Used to derive the trusted /// cart-url origin for incoming message validation. var loadedCheckoutURL: URL? @@ -808,13 +810,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 +834,24 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } + switch cloudflareManagedChallengeHandler.disposition( + for: response, + isForMainFrame: isForMainFrame, + isBackgroundedPreload: isPreloadBackgrounded + ) { + case .notChallenge: + break + case .render: + return .allow + case .discardPreload: + stopLoading() + handleCachedViewFailure( + .httpError(statusCode: statusCode), + message: "HTTP response returned status code \(statusCode)." + ) + return .cancel + } + if statusCode >= 400 { handleCachedViewFailure( .httpError(statusCode: statusCode), diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift new file mode 100644 index 000000000..ba84a2167 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift @@ -0,0 +1,28 @@ +import Foundation + +final class CloudflareManagedChallengeHandler { + enum Disposition: Equatable { + case notChallenge + case render + case discardPreload + } + + func disposition( + for response: HTTPURLResponse, + isForMainFrame: Bool, + isBackgroundedPreload: Bool + ) -> Disposition { + guard response.value(forHTTPHeaderField: "cf-mitigated")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("challenge") == .orderedSame + else { + return .notChallenge + } + + guard isForMainFrame, isBackgroundedPreload else { + return .render + } + + return .discardPreload + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 22277d6df..9610772f4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -154,6 +154,27 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(error.httpStatusCode, 403) } + func testCloudflareManagedChallengeRendersWithoutReportingCheckoutFailure() throws { + try view.load(checkout: XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123"))) + 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"] + ) + ) + + XCTAssertEqual(view.handleResponse(response), .allow) + wait(for: [didFail], timeout: 0.1) + XCTAssertNil(mockDelegate.errorReceived) + } + 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/CloudflareManagedChallengeHandlerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift new file mode 100644 index 000000000..d656a352e --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift @@ -0,0 +1,84 @@ +@testable import ShopifyCheckoutKit +import WebKit +import XCTest + +final class CloudflareManagedChallengeHandlerTests: XCTestCase { + private let handler = CloudflareManagedChallengeHandler() + private let url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! + + func testReturnsNotChallengeWithoutManagedChallengeHeader() throws { + let response = try response(headers: nil) + + XCTAssertEqual( + handler.disposition( + for: response, + isForMainFrame: true, + isBackgroundedPreload: true + ), + .notChallenge + ) + } + + func testReturnsNotChallengeForDifferentMitigationValue() throws { + let response = try response(headers: ["cf-mitigated": "block"]) + + XCTAssertEqual( + handler.disposition( + for: response, + isForMainFrame: true, + isBackgroundedPreload: true + ), + .notChallenge + ) + } + + func testHeaderNameAndValueMatchingIsCaseInsensitiveAndTrimsWhitespace() throws { + let response = try response(headers: ["CF-MITIGATED": " ChAlLeNgE\t"]) + + XCTAssertEqual( + handler.disposition( + for: response, + isForMainFrame: true, + isBackgroundedPreload: true + ), + .discardPreload + ) + } + + func testPresentedChallengeRenders() throws { + let response = try response(headers: ["cf-mitigated": "challenge"]) + + XCTAssertEqual( + handler.disposition( + for: response, + isForMainFrame: true, + isBackgroundedPreload: false + ), + .render + ) + } + + func testSubframeChallengeDoesNotDiscardBackgroundedPreload() throws { + let response = try response(headers: ["cf-mitigated": "challenge"]) + + XCTAssertEqual( + handler.disposition( + for: response, + isForMainFrame: false, + isBackgroundedPreload: true + ), + .render + ) + } + + private func response(headers: [String: String]?) throws -> HTTPURLResponse { + try XCTUnwrap( + HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: nil, + headerFields: headers + ) + ) + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f800bb67..84f5245c3 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -162,6 +162,67 @@ 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"] + ) + ) + + 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)) + ) + 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 testNavigationFailureTransitionsToFailed() { let preload = ShopifyCheckoutKit.preload(checkout: url) let view = CheckoutWebView(entryPoint: nil) From b47f3e6a08cd9e5296881de1b98d6e2a7ee9550b Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 10:42:07 +0100 Subject: [PATCH 2/3] [Swift] Generalize HTTP response handling --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 6 +++--- ...Handler.swift => HTTPResponseHandler.swift} | 17 ++++++++++------- ...ts.swift => HTTPResponseHandlerTests.swift} | 18 +++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) rename platforms/swift/Sources/ShopifyCheckoutKit/{CloudflareManagedChallengeHandler.swift => HTTPResponseHandler.swift} (64%) rename platforms/swift/Tests/ShopifyCheckoutKitTests/{CloudflareManagedChallengeHandlerTests.swift => HTTPResponseHandlerTests.swift} (77%) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index ac2f14d56..833e0c607 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -442,7 +442,7 @@ class CheckoutWebView: WKWebView { /// of whether it originated from `ec.error` or WebKit process termination. private var hasHandledTerminalFailure = false - private let cloudflareManagedChallengeHandler = CloudflareManagedChallengeHandler() + private let httpResponseHandler = HTTPResponseHandler() /// The checkout URL passed to `load(checkout:)`. Used to derive the trusted /// cart-url origin for incoming message validation. @@ -834,12 +834,12 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } - switch cloudflareManagedChallengeHandler.disposition( + switch httpResponseHandler.disposition( for: response, isForMainFrame: isForMainFrame, isBackgroundedPreload: isPreloadBackgrounded ) { - case .notChallenge: + case .handleNormally: break case .render: return .allow diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift b/platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift similarity index 64% rename from platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift rename to platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift index ba84a2167..066be0a85 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CloudflareManagedChallengeHandler.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift @@ -1,8 +1,8 @@ import Foundation -final class CloudflareManagedChallengeHandler { +final class HTTPResponseHandler { enum Disposition: Equatable { - case notChallenge + case handleNormally case render case discardPreload } @@ -12,11 +12,8 @@ final class CloudflareManagedChallengeHandler { isForMainFrame: Bool, isBackgroundedPreload: Bool ) -> Disposition { - guard response.value(forHTTPHeaderField: "cf-mitigated")? - .trimmingCharacters(in: .whitespacesAndNewlines) - .caseInsensitiveCompare("challenge") == .orderedSame - else { - return .notChallenge + guard isManagedChallenge(response) else { + return .handleNormally } guard isForMainFrame, isBackgroundedPreload else { @@ -25,4 +22,10 @@ final class CloudflareManagedChallengeHandler { return .discardPreload } + + private func isManagedChallenge(_ response: HTTPURLResponse) -> Bool { + response.value(forHTTPHeaderField: "cf-mitigated")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("challenge") == .orderedSame + } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift similarity index 77% rename from platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift rename to platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift index d656a352e..b8e94a6ae 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CloudflareManagedChallengeHandlerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift @@ -2,11 +2,11 @@ import WebKit import XCTest -final class CloudflareManagedChallengeHandlerTests: XCTestCase { - private let handler = CloudflareManagedChallengeHandler() +final class HTTPResponseHandlerTests: XCTestCase { + private let handler = HTTPResponseHandler() private let url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! - func testReturnsNotChallengeWithoutManagedChallengeHeader() throws { + func testHandlesResponseNormallyWithoutManagedChallengeHeader() throws { let response = try response(headers: nil) XCTAssertEqual( @@ -15,11 +15,11 @@ final class CloudflareManagedChallengeHandlerTests: XCTestCase { isForMainFrame: true, isBackgroundedPreload: true ), - .notChallenge + .handleNormally ) } - func testReturnsNotChallengeForDifferentMitigationValue() throws { + func testHandlesResponseNormallyForDifferentMitigationValue() throws { let response = try response(headers: ["cf-mitigated": "block"]) XCTAssertEqual( @@ -28,11 +28,11 @@ final class CloudflareManagedChallengeHandlerTests: XCTestCase { isForMainFrame: true, isBackgroundedPreload: true ), - .notChallenge + .handleNormally ) } - func testHeaderNameAndValueMatchingIsCaseInsensitiveAndTrimsWhitespace() throws { + func testManagedChallengeHeaderNameAndValueMatchingIsCaseInsensitiveAndTrimsWhitespace() throws { let response = try response(headers: ["CF-MITIGATED": " ChAlLeNgE\t"]) XCTAssertEqual( @@ -45,7 +45,7 @@ final class CloudflareManagedChallengeHandlerTests: XCTestCase { ) } - func testPresentedChallengeRenders() throws { + func testPresentedManagedChallengeRenders() throws { let response = try response(headers: ["cf-mitigated": "challenge"]) XCTAssertEqual( @@ -58,7 +58,7 @@ final class CloudflareManagedChallengeHandlerTests: XCTestCase { ) } - func testSubframeChallengeDoesNotDiscardBackgroundedPreload() throws { + func testSubframeManagedChallengeDoesNotDiscardBackgroundedPreload() throws { let response = try response(headers: ["cf-mitigated": "challenge"]) XCTAssertEqual( From eeb6295ffb5954e7e9376e8d47be250778430171 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 25 Aug 2026 11:13:01 +0100 Subject: [PATCH 3/3] Align Swift managed challenge handling --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 33 +++++--- .../CheckoutWebViewController.swift | 2 + .../HTTPResponseHandler.swift | 31 ------- .../CheckoutWebViewControllerTests.swift | 13 +++ .../CheckoutWebViewTests.swift | 23 ++++- .../HTTPResponseHandlerTests.swift | 84 ------------------- .../PreloadObservabilityTests.swift | 60 ++++++++++++- 7 files changed, 117 insertions(+), 129 deletions(-) delete mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift delete mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 833e0c607..192814834 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -438,12 +438,15 @@ 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 - private let httpResponseHandler = HTTPResponseHandler() - /// The checkout URL passed to `load(checkout:)`. Used to derive the trusted /// cart-url origin for incoming message validation. var loadedCheckoutURL: URL? @@ -834,16 +837,16 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } - switch httpResponseHandler.disposition( - for: response, - isForMainFrame: isForMainFrame, - isBackgroundedPreload: isPreloadBackgrounded - ) { - case .handleNormally: - break - case .render: - return .allow - case .discardPreload: + 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), @@ -870,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/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift b/platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift deleted file mode 100644 index 066be0a85..000000000 --- a/platforms/swift/Sources/ShopifyCheckoutKit/HTTPResponseHandler.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -final class HTTPResponseHandler { - enum Disposition: Equatable { - case handleNormally - case render - case discardPreload - } - - func disposition( - for response: HTTPURLResponse, - isForMainFrame: Bool, - isBackgroundedPreload: Bool - ) -> Disposition { - guard isManagedChallenge(response) else { - return .handleNormally - } - - guard isForMainFrame, isBackgroundedPreload else { - return .render - } - - return .discardPreload - } - - private func isManagedChallenge(_ response: HTTPURLResponse) -> Bool { - response.value(forHTTPHeaderField: "cf-mitigated")? - .trimmingCharacters(in: .whitespacesAndNewlines) - .caseInsensitiveCompare("challenge") == .orderedSame - } -} 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 9610772f4..9b2834fe1 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -156,6 +156,7 @@ class CheckoutWebViewTests: XCTestCase { 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 @@ -166,7 +167,7 @@ class CheckoutWebViewTests: XCTestCase { url: link, statusCode: 403, httpVersion: nil, - headerFields: ["cf-mitigated": "challenge"] + headerFields: ["CF-MITIGATED": " ChAlLeNgE\t"] ) ) @@ -175,6 +176,26 @@ class CheckoutWebViewTests: XCTestCase { 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/HTTPResponseHandlerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift deleted file mode 100644 index b8e94a6ae..000000000 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/HTTPResponseHandlerTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -@testable import ShopifyCheckoutKit -import WebKit -import XCTest - -final class HTTPResponseHandlerTests: XCTestCase { - private let handler = HTTPResponseHandler() - private let url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! - - func testHandlesResponseNormallyWithoutManagedChallengeHeader() throws { - let response = try response(headers: nil) - - XCTAssertEqual( - handler.disposition( - for: response, - isForMainFrame: true, - isBackgroundedPreload: true - ), - .handleNormally - ) - } - - func testHandlesResponseNormallyForDifferentMitigationValue() throws { - let response = try response(headers: ["cf-mitigated": "block"]) - - XCTAssertEqual( - handler.disposition( - for: response, - isForMainFrame: true, - isBackgroundedPreload: true - ), - .handleNormally - ) - } - - func testManagedChallengeHeaderNameAndValueMatchingIsCaseInsensitiveAndTrimsWhitespace() throws { - let response = try response(headers: ["CF-MITIGATED": " ChAlLeNgE\t"]) - - XCTAssertEqual( - handler.disposition( - for: response, - isForMainFrame: true, - isBackgroundedPreload: true - ), - .discardPreload - ) - } - - func testPresentedManagedChallengeRenders() throws { - let response = try response(headers: ["cf-mitigated": "challenge"]) - - XCTAssertEqual( - handler.disposition( - for: response, - isForMainFrame: true, - isBackgroundedPreload: false - ), - .render - ) - } - - func testSubframeManagedChallengeDoesNotDiscardBackgroundedPreload() throws { - let response = try response(headers: ["cf-mitigated": "challenge"]) - - XCTAssertEqual( - handler.disposition( - for: response, - isForMainFrame: false, - isBackgroundedPreload: true - ), - .render - ) - } - - private func response(headers: [String: String]?) throws -> HTTPURLResponse { - try XCTUnwrap( - HTTPURLResponse( - url: url, - statusCode: 403, - httpVersion: nil, - headerFields: headers - ) - ) - } -} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 84f5245c3..d8a5c86ab 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -174,7 +174,7 @@ class PreloadObservabilityTests: XCTestCase { url: url, statusCode: 403, httpVersion: nil, - headerFields: ["cf-mitigated": "challenge"] + headerFields: ["CF-MITIGATED": " ChAlLeNgE\t"] ) ) @@ -202,6 +202,7 @@ class PreloadObservabilityTests: XCTestCase { 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( @@ -223,6 +224,63 @@ class PreloadObservabilityTests: XCTestCase { 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)