Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,48 @@
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)

Check warning on line 180 in platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift

View workflow job for this annotation

GitHub Actions / Swift / package-tests / Run Package Tests

no calls to throwing functions occur within 'try' expression
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading