From 64de28d5397feea7aaa4acbe2b5eaf034eb54b7a Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Fri, 22 May 2026 11:40:31 +0100 Subject: [PATCH] catch more specific errors --- .../com/shopify/checkoutkit/CheckoutBridge.kt | 27 ++++++---- .../shopify/checkoutkit/CheckoutProtocol.kt | 38 ++++++++------ .../checkoutkit/EmbeddedCheckoutProtocol.kt | 26 +++++++--- .../checkoutkit/ExternalUriLauncher.kt | 2 +- .../errorevents/CheckoutErrorDecoder.kt | 6 ++- .../CheckoutCompletedEventDecoder.kt | 3 +- .../shopify/checkoutkit/CheckoutBridgeTest.kt | 15 ++++++ .../EmbeddedCheckoutProtocolTest.kt | 30 ++++++++++++ .../checkoutkit/ExternalUriLauncherTest.kt | 49 +++++++++++++++++++ 9 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/ExternalUriLauncherTest.kt diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt index 8142bf57..85ea33a7 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutBridge.kt @@ -28,6 +28,7 @@ import com.shopify.checkoutkit.CheckoutBridge.CheckoutWebOperation.MODAL import com.shopify.checkoutkit.ShopifyCheckoutKit.log import com.shopify.checkoutkit.errorevents.CheckoutErrorDecoder import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json internal class CheckoutBridge( @@ -85,16 +86,22 @@ internal class CheckoutBridge( else -> {} } - } catch (e: Exception) { - log.d(LOG_TAG, "Failed to decode message with error: $e. Calling onCheckoutFailedWithError") - onMainThread { - listener.onCheckoutViewFailedWithError( - CheckoutKitException( - errorDescription = "Error decoding message from checkout.", - errorCode = CheckoutKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT, - ), - ) - } + } catch (e: SerializationException) { + notifyDecodeFailure(e) + } catch (e: NoSuchElementException) { + notifyDecodeFailure(e) + } + } + + private fun notifyDecodeFailure(e: RuntimeException) { + log.d(LOG_TAG, "Failed to decode message with error: $e. Calling onCheckoutFailedWithError") + onMainThread { + listener.onCheckoutViewFailedWithError( + CheckoutKitException( + errorDescription = "Error decoding message from checkout.", + errorCode = CheckoutKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT, + ), + ) } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt index 680a8806..dbdb8626 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutProtocol.kt @@ -27,16 +27,17 @@ import android.os.Looper import androidx.core.net.toUri import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import java.util.concurrent.CountDownLatch @@ -70,10 +71,10 @@ public object CheckoutProtocol { public val error: NotificationDescriptor = NotificationDescriptor( method = "ec.error", decode = { params -> - params?.jsonObject?.get("error")?.let { + (params as? JsonObject)?.get("error")?.let { try { json.decodeFromJsonElement(it) - } catch (e: Exception) { + } catch (e: SerializationException) { log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$it") null } @@ -87,7 +88,7 @@ public object CheckoutProtocol { public val windowOpen: DelegationDescriptor = DelegationDescriptor( method = "ec.window.open_request", decode = { params -> - params?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull + ((params as? JsonObject)?.get("url") as? JsonPrimitive)?.contentOrNull ?.takeIf { it.isNotBlank() } ?.let { runCatching { it.toUri() }.getOrNull() } ?.let(::WindowOpenRequest) @@ -99,10 +100,10 @@ public object CheckoutProtocol { NotificationDescriptor( method = method, decode = { params -> - params?.jsonObject?.get("checkout")?.let { + (params as? JsonObject)?.get("checkout")?.let { try { json.decodeFromJsonElement(it) - } catch (e: Exception) { + } catch (e: SerializationException) { log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode $method checkout payload: $e raw=$it") null } @@ -179,16 +180,19 @@ public object CheckoutProtocol { ): Client = Client(handlers, delegations + (descriptor.method to Delegation.Typed(descriptor, handler))) /** Called by [EmbeddedCheckoutProtocol] for every delegated EC message. */ - override fun process(message: String): String? { - return try { - val request = json.decodeFromString(message) - delegations[request.method]?.let { return it.dispatch(request) } - dispatchNotification(request) - null - } catch (e: Exception) { - log.d(LOG_TAG, "Error processing ECP message in typed client: $e") - null + override fun process(message: String): String? = + decodeRequest(message)?.let { request -> + delegations[request.method]?.dispatch(request) ?: run { + dispatchNotification(request) + null + } } + + private fun decodeRequest(message: String): EcpRequest? = try { + json.decodeFromString(message) + } catch (e: SerializationException) { + log.d(LOG_TAG, "Error processing ECP message in typed client: $e") + null } /** @@ -228,7 +232,9 @@ public object CheckoutProtocol { private val handler: (P) -> R, ) : Delegation() { override fun dispatch(request: EcpRequest): String { - val payload = runCatching { descriptor.decode(request.params) }.getOrElse { e -> + val payload = try { + descriptor.decode(request.params) + } catch (e: SerializationException) { log.d(LOG_TAG, "Decode failed for ${request.method}: $e") null } ?: return jsonRpcError( diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt index f59f83c2..f155adfb 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt @@ -26,15 +26,15 @@ import android.content.Context import android.webkit.JavascriptInterface import com.shopify.checkoutkit.ShopifyCheckoutKit.log import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject @@ -75,16 +75,14 @@ internal class EmbeddedCheckoutProtocol( request.method == METHOD_COMPLETE -> handleComplete(message) else -> handleClientMessage(request.method, message) } - } catch (e: Exception) { + } catch (e: SerializationException) { log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message") sendError(null, CODE_PARSE_ERROR, "Parse error") } } private fun handleReady(request: EcpRequest) { - val checkoutAcceptedDelegations = request.params?.jsonObject?.get("delegate")?.jsonArray - ?.mapNotNull { it.jsonPrimitive.contentOrNull } - ?: emptyList() + val checkoutAcceptedDelegations = checkoutAcceptedDelegations(request.params) val negotiatedDelegations = checkoutAcceptedDelegations.filter { it in KIT_SUPPORTED_DELEGATIONS } log.d( LOG_TAG, @@ -96,6 +94,20 @@ internal class EmbeddedCheckoutProtocol( sendResult(request.id, ucpReadyResult(negotiatedDelegations)) } + private fun checkoutAcceptedDelegations(params: JsonElement?): List = when (params) { + null -> emptyList() + !is JsonObject -> throw SerializationException("$METHOD_READY params must be an object") + else -> params["delegate"]?.let(::delegationStrings) ?: emptyList() + } + + private fun delegationStrings(delegate: JsonElement): List { + val delegateArray = delegate as? JsonArray ?: throw SerializationException("$METHOD_READY delegate must be an array") + return delegateArray.mapNotNull(::delegationStringOrNull) + } + + private fun delegationStringOrNull(delegate: JsonElement): String? = + (delegate as? JsonPrimitive)?.contentOrNull + private fun ucpReadyResult(negotiatedDelegations: List): String = decoder.encodeToString( JsonObject.serializer(), diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt index 4338ad9a..bc30f0c6 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ExternalUriLauncher.kt @@ -48,7 +48,7 @@ internal object ExternalUriLauncher { Result.Launched } catch (e: ActivityNotFoundException) { Result.Rejected(reason = e.message ?: "No activity resolves $uri") - } catch (e: Exception) { + } catch (e: SecurityException) { Result.Rejected(reason = e.message) } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/errorevents/CheckoutErrorDecoder.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/errorevents/CheckoutErrorDecoder.kt index c177f29e..ce5e84d3 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/errorevents/CheckoutErrorDecoder.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/errorevents/CheckoutErrorDecoder.kt @@ -28,6 +28,7 @@ import com.shopify.checkoutkit.ClientException import com.shopify.checkoutkit.ConfigurationException import com.shopify.checkoutkit.LogWrapper import com.shopify.checkoutkit.WebToSdkEvent +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json internal class CheckoutErrorDecoder @JvmOverloads constructor( @@ -36,7 +37,10 @@ internal class CheckoutErrorDecoder @JvmOverloads constructor( ) { fun decode(message: WebToSdkEvent): CheckoutException? = try { decodeMessage(message).mapToCheckoutException() - } catch (e: Exception) { + } catch (e: SerializationException) { + log.e("CheckoutBridge", "Failed to decode CheckoutErrorPayload", e) + throw e + } catch (e: NoSuchElementException) { log.e("CheckoutBridge", "Failed to decode CheckoutErrorPayload", e) throw e } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEventDecoder.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEventDecoder.kt index 4a40360c..8784ab28 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEventDecoder.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/lifecycleevents/CheckoutCompletedEventDecoder.kt @@ -25,6 +25,7 @@ package com.shopify.checkoutkit.lifecycleevents import com.shopify.checkoutkit.LogWrapper import com.shopify.checkoutkit.WebToSdkEvent import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json @Serializable @@ -39,7 +40,7 @@ internal class CheckoutCompletedEventDecoder @JvmOverloads constructor( fun decode(decodedMsg: WebToSdkEvent): CheckoutCompletedEvent { return try { decoder.decodeFromString(decodedMsg.body) - } catch (e: Exception) { + } catch (e: SerializationException) { log.e("CheckoutBridge", "Failed to decode CheckoutCompleted event", e) emptyCompletedEvent() } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt index 99cb1b65..048ef326 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutBridgeTest.kt @@ -244,4 +244,19 @@ class CheckoutBridgeTest { assertThat(error.message).isEqualTo("Error decoding message from checkout.") assertThat(error.errorCode).isEqualTo(CheckoutKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT) } + + @Test + fun `should call onCheckoutViewFailedWithError if error payload is empty`() { + val eventString = """{"name":"error","body":"[]"}""" + + checkoutBridge.postMessage(eventString) + + val captor = argumentCaptor() + verify(mockListener).onCheckoutViewFailedWithError(captor.capture()) + + val error = captor.firstValue + assertThat(error).isInstanceOf(CheckoutKitException::class.java) + assertThat(error.message).isEqualTo("Error decoding message from checkout.") + assertThat(error.errorCode).isEqualTo(CheckoutKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT) + } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt index 1c81a593..82184e4c 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt @@ -125,6 +125,18 @@ class EmbeddedCheckoutProtocolTest { assertThat(js).doesNotContain("payment.credential") } + @Test + fun `ec ready ignores null and non-string delegate values`() { + val js = captureEvaluatedJs { + ecp.postMessage( + """{"jsonrpc":"2.0","method":"ec.ready","id":"r2","params":{"delegate":["window.open",null,{}]}}""" + ) + } + assertThat(js).contains("\"delegate\":[\"window.open\"]") + assertThat(js).contains("\"status\":\"success\"") + assertThat(js).doesNotContain("\"error\"") + } + @Test fun `ec ready omits delegate field when no supported delegations requested`() { val js = captureEvaluatedJs { @@ -471,6 +483,24 @@ class EmbeddedCheckoutProtocolTest { assertThat(js).contains("-32700") } + @Test + fun `ec ready with non-object params sends parse error`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"13","params":[]}""") + } + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32700") + } + + @Test + fun `ec ready with non-array delegate sends parse error`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.ready","id":"14","params":{"delegate":{}}}""") + } + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32700") + } + // endregion // region helpers diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ExternalUriLauncherTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ExternalUriLauncherTest.kt new file mode 100644 index 00000000..65964eab --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ExternalUriLauncherTest.kt @@ -0,0 +1,49 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutkit + +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExternalUriLauncherTest { + + @Test + fun `launch rejects when startActivity throws security exception`() { + val context = mock() + doThrow(SecurityException("blocked")).whenever(context).startActivity(any()) + + val result = ExternalUriLauncher.launch(context, Uri.parse("https://example.com")) + + assertThat(result).isEqualTo(ExternalUriLauncher.Result.Rejected(reason = "blocked")) + } +}