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 @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -349,6 +350,7 @@ class PreloadObservabilityTest {
val response = mock<WebResourceResponse> {
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()
Expand All @@ -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<CheckoutWebViewListener>()
view.setListener(listener)
val request = mock<WebResourceRequest> {
whenever(it.isForMainFrame).thenReturn(true)
whenever(it.url).thenReturn(Uri.parse(url))
}
val response = mock<WebResourceResponse> {
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<CheckoutWebViewListener>()
view.setListener(listener)
val request = mock<WebResourceRequest> {
whenever(it.isForMainFrame).thenReturn(true)
whenever(it.url).thenReturn(Uri.parse(url))
}
val response = mock<WebResourceResponse> {
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<CheckoutWebViewListener>()
view.setListener(listener)
val request = mock<WebResourceRequest> {
whenever(it.isForMainFrame).thenReturn(false)
whenever(it.url).thenReturn(Uri.parse(url))
}
val response = mock<WebResourceResponse> {
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<CheckoutWebViewListener>()
view.setListener(listener)
val request = mock<WebResourceRequest> {
whenever(it.isForMainFrame).thenReturn(true)
whenever(it.url).thenReturn(Uri.parse(url))
}
val response = mock<WebResourceResponse> {
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<PreloadState>()
Expand Down
Loading