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 @@ -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(
Expand Down Expand Up @@ -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,
),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,10 +71,10 @@ public object CheckoutProtocol {
public val error: NotificationDescriptor<ErrorResponse> = NotificationDescriptor(
method = "ec.error",
decode = { params ->
params?.jsonObject?.get("error")?.let {
(params as? JsonObject)?.get("error")?.let {
try {
json.decodeFromJsonElement<ErrorResponse>(it)
} catch (e: Exception) {
} catch (e: SerializationException) {
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode ec.error params: $e raw=$it")
null
}
Expand All @@ -87,7 +88,7 @@ public object CheckoutProtocol {
public val windowOpen: DelegationDescriptor<WindowOpenRequest, WindowOpenResult> = 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)
Expand All @@ -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<Checkout>(it)
} catch (e: Exception) {
} catch (e: SerializationException) {
log.d(BaseWebView.ECP_LOG_TAG, "Failed to decode $method checkout payload: $e raw=$it")
null
}
Expand Down Expand Up @@ -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<EcpRequest>(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<EcpRequest>(message)
} catch (e: SerializationException) {
log.d(LOG_TAG, "Error processing ECP message in typed client: $e")
null
}

/**
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -96,6 +94,20 @@ internal class EmbeddedCheckoutProtocol(
sendResult(request.id, ucpReadyResult(negotiatedDelegations))
}

private fun checkoutAcceptedDelegations(params: JsonElement?): List<String> = 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<String> {
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>): String =
decoder.encodeToString(
JsonObject.serializer(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,7 +40,7 @@ internal class CheckoutCompletedEventDecoder @JvmOverloads constructor(
fun decode(decodedMsg: WebToSdkEvent): CheckoutCompletedEvent {
return try {
decoder.decodeFromString<CheckoutCompletedEvent>(decodedMsg.body)
} catch (e: Exception) {
} catch (e: SerializationException) {
log.e("CheckoutBridge", "Failed to decode CheckoutCompleted event", e)
emptyCompletedEvent()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckoutException>()
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()
doThrow(SecurityException("blocked")).whenever(context).startActivity(any<Intent>())

val result = ExternalUriLauncher.launch(context, Uri.parse("https://example.com"))

assertThat(result).isEqualTo(ExternalUriLauncher.Result.Rejected(reason = "blocked"))
}
}
Loading