diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 50a894fcf..26ea384d1 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -270,29 +270,57 @@ internal class CheckoutWebView private constructor( errorResponse: WebResourceResponse? ) { val isMainFrame = request?.isForMainFrame == true - if (isMainFrame) { - val statusCode = errorResponse?.statusCode ?: 0 - preloadCache.evict( - PreloadState.Failed( - PreloadState.FailureReason.HttpError(statusCode), - "HTTP response returned status code $statusCode.", - ), - view = this@CheckoutWebView, - ) + val statusCode = errorResponse?.statusCode ?: 0 + val failureReason = PreloadState.FailureReason.HttpError(statusCode) + val failureMessage = "HTTP response returned status code $statusCode." + + if (errorResponse.isCloudflareManagedChallenge()) { + if (isMainFrame && preloadCache.contains(this@CheckoutWebView) && !isPresented) { + log.d(LOG_TAG, "Discarding cached Cloudflare managed challenge response.") + stopLoading() + evictForTerminalFailure( + this@CheckoutWebView, + failureReason, + failureMessage, + ) + } else { + log.d(LOG_TAG, "Allowing Cloudflare managed challenge response to render.") + } + } else { + if (isMainFrame) { + preloadCache.evict( + PreloadState.Failed( + failureReason, + failureMessage, + ), + view = this@CheckoutWebView, + ) + } + errorResponse?.let { + handleHttpError( + request, + it.statusCode, + it.reasonPhrase.ifBlank { "HTTP ${it.statusCode} Error" }, + ) + } } super.onReceivedHttpError(view, request, errorResponse) - errorResponse?.let { - handleHttpError( - request, - it.statusCode, - it.reasonPhrase.ifBlank { "HTTP ${it.statusCode} Error" }, - ) - } if (isMainFrame) { resetCheckoutRequestRetryState() } } + private fun WebResourceResponse?.isCloudflareManagedChallenge(): Boolean { + val mitigation = this + ?.responseHeaders + ?.entries + ?.firstOrNull { (name) -> name.equals("cf-mitigated", ignoreCase = true) } + ?.value + ?.trim() + + return mitigation.equals("challenge", ignoreCase = true) + } + override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt index c22e5ecbd..12be03590 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/PreloadObservabilityTest.kt @@ -13,6 +13,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @@ -349,6 +350,7 @@ class PreloadObservabilityTest { val response = mock { whenever(it.statusCode).thenReturn(500) whenever(it.reasonPhrase).thenReturn("Internal Server Error") + whenever(it.responseHeaders).thenReturn(mapOf("cf-mitigated" to "block")) } shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) ShadowLooper.shadowMainLooper().idle() @@ -362,6 +364,117 @@ class PreloadObservabilityTest { ) } + @Test + fun `cloudflare managed challenge discards backgrounded preload without checkout failure`() { + val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + val view = CheckoutWebView.cachedPreloadViewForTesting()!! + val listener = mock() + view.setListener(listener) + val request = mock { + whenever(it.isForMainFrame).thenReturn(true) + whenever(it.url).thenReturn(Uri.parse(url)) + } + val response = mock { + whenever(it.statusCode).thenReturn(403) + whenever(it.reasonPhrase).thenReturn("Forbidden") + whenever(it.responseHeaders).thenReturn(mapOf("CF-MITIGATED" to " ChAlLeNgE\t")) + } + + shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) + ShadowLooper.shadowMainLooper().idle() + + assertThat(preload.state) + .isEqualTo( + PreloadState.Failed( + PreloadState.FailureReason.HttpError(403), + "HTTP response returned status code 403.", + ), + ) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + verifyNoInteractions(listener) + } + + @Test + fun `cloudflare managed challenge renders for presented preload`() { + val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + val view = CheckoutWebView.checkoutViewFor(url, activity, webMessageTransport) + view.markPresented() + val listener = mock() + view.setListener(listener) + val request = mock { + whenever(it.isForMainFrame).thenReturn(true) + whenever(it.url).thenReturn(Uri.parse(url)) + } + val response = mock { + whenever(it.statusCode).thenReturn(403) + whenever(it.reasonPhrase).thenReturn("Forbidden") + whenever(it.responseHeaders).thenReturn(mapOf("cf-mitigated" to "challenge")) + } + + shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) + ShadowLooper.shadowMainLooper().idle() + + assertThat(preload.state).isEqualTo(PreloadState.Loading) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isSameAs(view) + verifyNoInteractions(listener) + } + + @Test + fun `cloudflare subframe challenge does not discard backgrounded preload`() { + val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + val view = CheckoutWebView.cachedPreloadViewForTesting()!! + val listener = mock() + view.setListener(listener) + val request = mock { + whenever(it.isForMainFrame).thenReturn(false) + whenever(it.url).thenReturn(Uri.parse(url)) + } + val response = mock { + whenever(it.statusCode).thenReturn(403) + whenever(it.reasonPhrase).thenReturn("Forbidden") + whenever(it.responseHeaders).thenReturn(mapOf("cf-mitigated" to "challenge")) + } + + shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) + ShadowLooper.shadowMainLooper().idle() + + assertThat(preload.state).isEqualTo(PreloadState.Loading) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isSameAs(view) + verifyNoInteractions(listener) + } + + @Test + fun `cloudflare managed challenge discards dismissed cached view without checkout failure`() { + val preload = ShopifyCheckoutKit.preload(url, activity, webMessageTransport)!! + ShadowLooper.shadowMainLooper().idle() + val view = CheckoutWebView.checkoutViewFor(url, activity, webMessageTransport) + view.markPresented() + assertThat(CheckoutWebView.retainAfterPresentation(view)).isTrue() + assertThat(view.isPresented).isFalse() + assertThat(view.isPreloadRequest).isFalse() + val listener = mock() + view.setListener(listener) + val request = mock { + whenever(it.isForMainFrame).thenReturn(true) + whenever(it.url).thenReturn(Uri.parse(url)) + } + val response = mock { + whenever(it.statusCode).thenReturn(403) + whenever(it.reasonPhrase).thenReturn("Forbidden") + whenever(it.responseHeaders).thenReturn(mapOf("cf-mitigated" to "challenge")) + } + + shadowOf(view).webViewClient.onReceivedHttpError(view, request, response) + ShadowLooper.shadowMainLooper().idle() + + assertThat(preload.state).isEqualTo(PreloadState.Loading) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + verifyNoInteractions(listener) + } + @Test fun `http failure callback can start a replacement preload`() { val replacementStates = mutableListOf()