diff --git a/platforms/android/README.md b/platforms/android/README.md index 8b891889d..36ae34249 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -293,7 +293,6 @@ ShopifyCheckoutKit.configure { | `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. | | `title` | `null` | Runtime override for the checkout sheet header title. When `null`, the SDK uses the localized `checkout_web_view_title` string resource. | | `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. | -| `onMessageRejected` | `null` | Observes messages rejected by origin validation. | ### Color schemes @@ -444,12 +443,25 @@ ShopifyCheckoutKit.configure { "https://checkout.example.com", "https://*.example.org", ) - it.onMessageRejected = { rejection -> - reportRejectedOrigin(rejection.origin, rejection.reason) +} + +val diagnosticSubscription = ShopifyCheckoutKit.diagnostics.subscribe { event -> + when (event) { + is CheckoutDiagnosticEvent.MessageRejected -> { + val rejection = event.rejection + reportRejectedOrigin(rejection.origin, rejection.reason) + } } } + +// Cancel when the observing application component is destroyed. +diagnosticSubscription.cancel() ``` +Diagnostics are process-wide, hot events and do not replay. Subscribe before preloading if the +application needs to observe rejections from a background checkout WebView. Rejected message bodies +are intentionally omitted because they are untrusted and may contain sensitive data. + Exact entries accept an optional trailing slash, but not credentials, paths, queries, or fragments. For example, `https://checkout.example.com/` is accepted, while `https://user@checkout.example.com` and `https://checkout.example.com/path` are ignored. Wildcard diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index 5c1a46ded..ba6282f3a 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -67,6 +67,37 @@ public final class com/shopify/checkoutkit/CheckoutAppearance$Storefront$Compani public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public abstract interface class com/shopify/checkoutkit/CheckoutDiagnosticEvent { +} + +public final class com/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected : com/shopify/checkoutkit/CheckoutDiagnosticEvent { + public fun (Lcom/shopify/checkoutkit/CheckoutMessageRejection;)V + public final fun component1 ()Lcom/shopify/checkoutkit/CheckoutMessageRejection; + public final fun copy (Lcom/shopify/checkoutkit/CheckoutMessageRejection;)Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected; + public static synthetic fun copy$default (Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected;Lcom/shopify/checkoutkit/CheckoutMessageRejection;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected; + public fun equals (Ljava/lang/Object;)Z + public final fun getRejection ()Lcom/shopify/checkoutkit/CheckoutMessageRejection; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/shopify/checkoutkit/CheckoutDiagnostics { + public final fun subscribe (Lcom/shopify/checkoutkit/CheckoutDiagnostics$Listener;)Lcom/shopify/checkoutkit/CheckoutDiagnostics$Subscription; +} + +public abstract interface class com/shopify/checkoutkit/CheckoutDiagnostics$Listener { + public abstract fun onDiagnosticEvent (Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent;)V +} + +public abstract interface class com/shopify/checkoutkit/CheckoutDiagnostics$Subscription : java/lang/AutoCloseable { + public abstract fun cancel ()V + public abstract fun close ()V +} + +public final class com/shopify/checkoutkit/CheckoutDiagnostics$Subscription$DefaultImpls { + public static fun close (Lcom/shopify/checkoutkit/CheckoutDiagnostics$Subscription;)V +} + public final class com/shopify/checkoutkit/CheckoutErrorCode : java/lang/Enum { public static final field CART_COMPLETED Lcom/shopify/checkoutkit/CheckoutErrorCode; public static final field CART_EXPIRED Lcom/shopify/checkoutkit/CheckoutErrorCode; @@ -107,6 +138,28 @@ public abstract interface class com/shopify/checkoutkit/CheckoutListener { public abstract fun onShowFileChooser (Landroid/webkit/WebView;Landroid/webkit/ValueCallback;Landroid/webkit/WebChromeClient$FileChooserParams;)Z } +public final class com/shopify/checkoutkit/CheckoutMessageRejection { + public fun (Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public final fun copy (Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;)Lcom/shopify/checkoutkit/CheckoutMessageRejection; + public static synthetic fun copy$default (Lcom/shopify/checkoutkit/CheckoutMessageRejection;Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutMessageRejection; + public fun equals (Ljava/lang/Object;)Z + public final fun getOrigin ()Ljava/lang/String; + public final fun getReason ()Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/shopify/checkoutkit/CheckoutMessageRejection$Reason : java/lang/Enum { + public static final field CHILD_FRAME Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public static final field ORIGIN_NOT_ALLOWED Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public static final field UNSUPPORTED_PORT Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; + public static fun values ()[Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason; +} + public final class com/shopify/checkoutkit/CheckoutPreload { public final fun getListener ()Lcom/shopify/checkoutkit/PreloadStateListener; public final fun getState ()Lcom/shopify/checkoutkit/PreloadState; @@ -494,12 +547,10 @@ public final class com/shopify/checkoutkit/Configuration { public final fun component5 ()Lcom/shopify/checkoutkit/Preloading; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/util/Set; - public final fun component8 ()Lkotlin/jvm/functions/Function1; public fun equals (Ljava/lang/Object;)Z public final fun getAllowedMessageOrigins ()Ljava/util/Set; public final fun getAppearance ()Lcom/shopify/checkoutkit/CheckoutAppearance; public final fun getLogLevel ()Lcom/shopify/checkoutkit/LogLevel; - public final fun getOnMessageRejected ()Lkotlin/jvm/functions/Function1; public final fun getPlatform ()Lcom/shopify/checkoutkit/Platform; public final fun getPreloading ()Lcom/shopify/checkoutkit/Preloading; public final fun getSheet ()Lcom/shopify/checkoutkit/CheckoutSheetOptions; @@ -508,7 +559,6 @@ public final class com/shopify/checkoutkit/Configuration { public final fun setAllowedMessageOrigins (Ljava/util/Set;)V public final fun setAppearance (Lcom/shopify/checkoutkit/CheckoutAppearance;)V public final fun setLogLevel (Lcom/shopify/checkoutkit/LogLevel;)V - public final fun setOnMessageRejected (Lkotlin/jvm/functions/Function1;)V public final fun setPlatform (Lcom/shopify/checkoutkit/Platform;)V public final fun setPreloading (Lcom/shopify/checkoutkit/Preloading;)V public final fun setSheet (Lcom/shopify/checkoutkit/CheckoutSheetOptions;)V @@ -682,21 +732,6 @@ public final class com/shopify/checkoutkit/Preloading { public fun toString ()Ljava/lang/String; } -public final class com/shopify/checkoutkit/RejectedMessage { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/shopify/checkoutkit/RejectedMessage; - public static synthetic fun copy$default (Lcom/shopify/checkoutkit/RejectedMessage;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/shopify/checkoutkit/RejectedMessage; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public final fun getOrigin ()Ljava/lang/String; - public final fun getReason ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class com/shopify/checkoutkit/ShopifyCheckout : android/widget/FrameLayout { public static final field Companion Lcom/shopify/checkoutkit/ShopifyCheckout$Companion; public fun (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V @@ -712,6 +747,7 @@ public final class com/shopify/checkoutkit/ShopifyCheckout$Companion { public final class com/shopify/checkoutkit/ShopifyCheckoutKit { public static final field INSTANCE Lcom/shopify/checkoutkit/ShopifyCheckoutKit; public static final field VERSION Ljava/lang/String; + public static final field diagnostics Lcom/shopify/checkoutkit/CheckoutDiagnostics; public static final fun configure (Lcom/shopify/checkoutkit/ConfigurationUpdater;)V public static final fun getConfiguration ()Lcom/shopify/checkoutkit/Configuration; public static final fun invalidate ()V diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDiagnostics.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDiagnostics.kt new file mode 100644 index 000000000..5ddb53268 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutDiagnostics.kt @@ -0,0 +1,125 @@ +package com.shopify.checkoutkit + +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicBoolean + +/** An SDK diagnostic that applications may observe for integration telemetry. */ +public sealed interface CheckoutDiagnosticEvent { + /** An incoming checkout message was denied before protocol dispatch. */ + public data class MessageRejected( + public val rejection: CheckoutMessageRejection, + ) : CheckoutDiagnosticEvent +} + +/** + * Details about an incoming checkout message denied by the ingress policy. + * + * The raw message body is intentionally omitted because rejected input is untrusted and may + * contain sensitive or arbitrarily large data. + */ +public data class CheckoutMessageRejection( + /** Origin the message was received from, for example `https://example.com`. */ + public val origin: String, + + /** Stable reason the message was denied. */ + public val reason: Reason, +) { + public enum class Reason { + /** The message was sent from a child frame rather than the checkout's main frame. */ + CHILD_FRAME, + + /** The message origin used explicit port zero while origin validation was enabled. */ + UNSUPPORTED_PORT, + + /** The message origin did not match the effective allowlist. */ + ORIGIN_NOT_ALLOWED, + } +} + +/** + * SDK-wide diagnostic events emitted by Checkout Kit. + * + * Subscriptions are hot and do not replay earlier events. Subscribe before calling `preload` when + * preload diagnostics are required. Listener failures are isolated so diagnostics cannot interrupt + * checkout processing or prevent other listeners from receiving an event. + */ +public class CheckoutDiagnostics internal constructor() { + /** Receives diagnostic events on the Android main thread. */ + public fun interface Listener { + public fun onDiagnosticEvent(event: CheckoutDiagnosticEvent) + } + + /** A cancellable diagnostic subscription. */ + public interface Subscription : AutoCloseable { + /** Stops this listener from receiving future events. */ + public fun cancel() + + /** Equivalent to [cancel], enabling Java try-with-resources usage. */ + override fun close(): Unit = cancel() + } + + private val listeners = CopyOnWriteArraySet() + + /** + * Subscribes [listener] to future diagnostics. + * + * The returned subscription retains the listener until it is cancelled. + */ + public fun subscribe(listener: Listener): Subscription { + listeners.add(listener) + return ListenerSubscription(listeners, listener) + } + + internal fun emit(event: CheckoutDiagnosticEvent) { + log(event) + + // Snapshot before dispatch so a listener added after emission cannot observe an earlier + // event, and listeners may cancel themselves without mutating the traversed collection. + val currentListeners = listeners.toList() + onMainThread { + currentListeners.forEach { listener -> + try { + listener.onDiagnosticEvent(event) + } catch (error: Exception) { + ShopifyCheckoutKit.log.e(DIAGNOSTICS_LOG_TAG, "Diagnostic listener threw", error) + } + } + } + } + + private fun log(event: CheckoutDiagnosticEvent) { + when (event) { + is CheckoutDiagnosticEvent.MessageRejected -> { + val rejection = event.rejection + ShopifyCheckoutKit.log.d( + DIAGNOSTICS_LOG_TAG, + "Rejected checkout message from ${rejection.origin}: ${rejection.reason.logDescription}", + ) + } + } + } + + private class ListenerSubscription( + private val listeners: CopyOnWriteArraySet, + private val listener: Listener, + ) : Subscription { + private val cancelled = AtomicBoolean(false) + + override fun cancel() { + if (cancelled.compareAndSet(false, true)) { + listeners.remove(listener) + } + } + } + + private companion object { + private const val DIAGNOSTICS_LOG_TAG = "CheckoutDiagnostics" + } +} + +private val CheckoutMessageRejection.Reason.logDescription: String + get() = when (this) { + CheckoutMessageRejection.Reason.CHILD_FRAME -> "message was sent from a child frame" + CheckoutMessageRejection.Reason.UNSUPPORTED_PORT -> "origin uses unsupported port 0" + CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED -> "origin is not in the allowlist" + } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicy.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicy.kt new file mode 100644 index 000000000..c376f62e3 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicy.kt @@ -0,0 +1,57 @@ +package com.shopify.checkoutkit + +import java.net.URI + +/** Transport metadata captured before an incoming message enters protocol dispatch. */ +internal data class IncomingCheckoutMessage( + val body: String, + val origin: String, + val isMainFrame: Boolean, +) + +/** + * Applies the SDK's admission rules to incoming checkout messages. + * + * A message may be valid JSON and valid checkout protocol while still being rejected because its + * transport metadata is not admitted. Keeping this decision outside the protocol client ensures + * the client only receives messages that the native WebView boundary has already trusted. + */ +internal class CheckoutMessageIngressPolicy( + private val configuredOrigins: Set, + private val checkoutOrigin: String?, +) { + internal sealed interface Decision { + data object Accepted : Decision + data class Rejected(val rejection: CheckoutMessageRejection) : Decision + } + + internal fun evaluate(message: IncomingCheckoutMessage): Decision { + if (!message.isMainFrame) { + return rejected(message, CheckoutMessageRejection.Reason.CHILD_FRAME) + } + + val patterns = OriginAllowlist.effectivePatterns( + checkoutOrigin = checkoutOrigin, + configured = configuredOrigins, + ) + + return when { + patterns == null -> Decision.Accepted + // AndroidX supplies an authenticated source origin, but explicit port zero is not a + // useful web origin. Reject it only when validation is enabled to preserve native's + // open default. + runCatching { URI(message.origin).port == 0 }.getOrDefault(false) -> + rejected(message, CheckoutMessageRejection.Reason.UNSUPPORTED_PORT) + !OriginAllowlist.isAllowed(message.origin, patterns) -> + rejected(message, CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED) + else -> Decision.Accepted + } + } + + private fun rejected( + message: IncomingCheckoutMessage, + reason: CheckoutMessageRejection.Reason, + ): Decision.Rejected = Decision.Rejected( + CheckoutMessageRejection(origin = message.origin, reason = reason), + ) +} diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt index 632fc92d7..1d66688b5 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt @@ -12,9 +12,6 @@ import android.content.Context * effective allowlist is these origins plus the cart URL origin and `shop.app` (including its * subdomains). Entries may be exact origins (`https://example.com`), scheme-qualified wildcard * subdomains (`https://*.example.com`), or `"*"` to explicitly trust every origin. - * @property onMessageRejected Invoked when an incoming message is dropped by origin validation. When - * null, drops are logged at debug level. Treat the payload as untrusted — it was dropped precisely - * because its origin was not in the allowlist. */ @ConsistentCopyVisibility public data class Configuration internal constructor( @@ -25,20 +22,6 @@ public data class Configuration internal constructor( var preloading: Preloading = Preloading(), var title: String? = null, var allowedMessageOrigins: Set = emptySet(), - var onMessageRejected: ((RejectedMessage) -> Unit)? = null, -) - -/** - * Details of an incoming message dropped by origin validation. - * - * @property origin Origin the dropped message was posted from. - * @property message Raw message payload. Treat as untrusted. - * @property reason Human-readable reason the message was dropped. - */ -public data class RejectedMessage( - val origin: String, - val message: String, - val reason: String, ) /** diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt index 2c2f3afd1..5551e8959 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt @@ -42,6 +42,7 @@ internal class EmbeddedCheckoutProtocolBridge( private val webMessageTransport: WebMessageTransport, @Volatile private var client: CheckoutProtocol.Client? = null, private val protocolMessageExecutor: Executor = ProtocolMessageExecutor.executor, + private val diagnostics: CheckoutDiagnostics = ShopifyCheckoutKit.diagnostics, ) { private var isTransportAttached = false private val defaultClient: CheckoutProtocol.Client = defaultDelegationClient() @@ -92,45 +93,21 @@ internal class EmbeddedCheckoutProtocolBridge( } private fun receiveWebMessage(message: String, sourceOrigin: String, isMainFrame: Boolean) { - if (!isMainFrame) { - log.d(LOG_TAG, "Ignoring ECP WebMessage from a child frame.") - return - } - - if (!isOriginAllowed(sourceOrigin)) { - rejectMessage(sourceOrigin, message) - return - } - - receiveMessage(message) - } - - /** - * Origin validation runs here (not at the WebView layer) so [ALLOWED_MESSAGE_ORIGIN_RULES] can - * stay `"*"` and deliver every message with its verified origin. That lets the kit surface - * drops through [Configuration.onMessageRejected] instead of the WebView silently discarding - * them. - */ - private fun isOriginAllowed(sourceOrigin: String): Boolean { - val configuration = ShopifyCheckoutKit.configuration - val patterns = OriginAllowlist.effectivePatterns( + val incomingMessage = IncomingCheckoutMessage( + body = message, + origin = sourceOrigin, + isMainFrame = isMainFrame, + ) + val ingressPolicy = CheckoutMessageIngressPolicy( + configuredOrigins = ShopifyCheckoutKit.configuration.allowedMessageOrigins, checkoutOrigin = view.checkoutOrigin, - configured = configuration.allowedMessageOrigins, ) - return OriginAllowlist.isAllowed(sourceOrigin, patterns) - } - private fun rejectMessage(sourceOrigin: String, message: String) { - val reason = "origin \"$sourceOrigin\" is not in the allowlist" - val callback = ShopifyCheckoutKit.configuration.onMessageRejected - if (callback != null) { - try { - callback(RejectedMessage(origin = sourceOrigin, message = message, reason = reason)) - } catch (error: Exception) { - log.e(LOG_TAG, "onMessageRejected callback threw", error) + when (val decision = ingressPolicy.evaluate(incomingMessage)) { + CheckoutMessageIngressPolicy.Decision.Accepted -> receiveMessage(incomingMessage.body) + is CheckoutMessageIngressPolicy.Decision.Rejected -> { + diagnostics.emit(CheckoutDiagnosticEvent.MessageRejected(decision.rejection)) } - } else { - log.d(LOG_TAG, "Dropped ECP WebMessage: $reason") } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt index 03bbc0b1c..604c50c81 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt @@ -13,6 +13,10 @@ public object ShopifyCheckoutKit { internal val log = LogWrapper() + /** SDK-wide diagnostics emitted by Checkout Kit. */ + @JvmField + public val diagnostics: CheckoutDiagnostics = CheckoutDiagnostics() + /** * Returns the current version of ShopifyCheckoutKit. diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDiagnosticsTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDiagnosticsTest.kt new file mode 100644 index 000000000..b8391d7fd --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutDiagnosticsTest.kt @@ -0,0 +1,91 @@ +package com.shopify.checkoutkit + +import android.os.Looper +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CheckoutDiagnosticsTest { + private val rejection = CheckoutMessageRejection( + origin = "https://evil.example.com", + reason = CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED, + ) + private val event = CheckoutDiagnosticEvent.MessageRejected(rejection) + + @Test + fun `each subscriber receives emitted diagnostics`() { + val diagnostics = CheckoutDiagnostics() + val first = mutableListOf() + val second = mutableListOf() + val firstSubscription = diagnostics.subscribe { first.add(it) } + val secondSubscription = diagnostics.subscribe { second.add(it) } + + diagnostics.emit(event) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(first).containsExactly(event) + assertThat(second).containsExactly(event) + firstSubscription.cancel() + secondSubscription.cancel() + } + + @Test + fun `cancelled subscription receives no later diagnostics`() { + val diagnostics = CheckoutDiagnostics() + val received = mutableListOf() + val subscription = diagnostics.subscribe(received::add) + + subscription.cancel() + subscription.cancel() + diagnostics.emit(event) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(received).isEmpty() + } + + @Test + fun `new subscription does not replay earlier diagnostics`() { + val diagnostics = CheckoutDiagnostics() + val received = mutableListOf() + + diagnostics.emit(event) + val subscription = diagnostics.subscribe(received::add) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(received).isEmpty() + subscription.cancel() + } + + @Test + fun `throwing listener does not prevent later listeners`() { + val diagnostics = CheckoutDiagnostics() + val received = mutableListOf() + val throwingSubscription = diagnostics.subscribe { error("listener failed") } + val receivingSubscription = diagnostics.subscribe(received::add) + + diagnostics.emit(event) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(received).containsExactly(event) + throwingSubscription.cancel() + receivingSubscription.cancel() + } + + @Test + fun `listeners are delivered on the main thread`() { + val diagnostics = CheckoutDiagnostics() + var deliveredLooper: Looper? = null + val subscription = diagnostics.subscribe { deliveredLooper = Looper.myLooper() } + + diagnostics.emit(event) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(deliveredLooper).isEqualTo(Looper.getMainLooper()) + subscription.cancel() + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicyTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicyTest.kt new file mode 100644 index 000000000..30dc20c38 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutMessageIngressPolicyTest.kt @@ -0,0 +1,69 @@ +package com.shopify.checkoutkit + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CheckoutMessageIngressPolicyTest { + @Test + fun `open by default accepts any main frame origin`() { + val decision = policy().evaluate(message(origin = "https://evil.example.com")) + + assertThat(decision).isEqualTo(CheckoutMessageIngressPolicy.Decision.Accepted) + } + + @Test + fun `child frame is rejected before origin evaluation`() { + val decision = policy().evaluate( + message(origin = "https://trusted.example.com", isMainFrame = false), + ) + + assertThat(decision).isEqualTo( + rejected("https://trusted.example.com", CheckoutMessageRejection.Reason.CHILD_FRAME), + ) + } + + @Test + fun `explicit port zero is rejected when origin validation is enabled`() { + val decision = policy(configuredOrigins = setOf("https://trusted.example.com")).evaluate( + message(origin = "https://trusted.example.com:0"), + ) + + assertThat(decision).isEqualTo( + rejected("https://trusted.example.com:0", CheckoutMessageRejection.Reason.UNSUPPORTED_PORT), + ) + } + + @Test + fun `origin outside allowlist is rejected`() { + val decision = policy(configuredOrigins = setOf("https://trusted.example.com")).evaluate( + message(origin = "https://evil.example.com"), + ) + + assertThat(decision).isEqualTo( + rejected("https://evil.example.com", CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED), + ) + } + + private fun policy( + configuredOrigins: Set = emptySet(), + ) = CheckoutMessageIngressPolicy( + configuredOrigins = configuredOrigins, + checkoutOrigin = "https://checkout.shopify.com", + ) + + private fun message( + origin: String, + isMainFrame: Boolean = true, + ) = IncomingCheckoutMessage( + body = "{}", + origin = origin, + isMainFrame = isMainFrame, + ) + + private fun rejected( + origin: String, + reason: CheckoutMessageRejection.Reason, + ) = CheckoutMessageIngressPolicy.Decision.Rejected( + CheckoutMessageRejection(origin = origin, reason = reason), + ) +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt index 44512ec07..288b94368 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt @@ -38,6 +38,7 @@ class CheckoutWebViewTest { private lateinit var activity: ComponentActivity private lateinit var initialConfiguration: Configuration private lateinit var webMessageTransport: FakeWebMessageTransport + private val diagnosticSubscriptions = mutableListOf() @Before fun setUp() { @@ -53,6 +54,8 @@ class CheckoutWebViewTest { CheckoutWebView.clearCache() ShadowLooper.shadowMainLooper().idle() CheckoutWebView.cacheClock = PreloadCache.Clock() + diagnosticSubscriptions.forEach(CheckoutDiagnostics.Subscription::cancel) + diagnosticSubscriptions.clear() ShopifyCheckoutKit.configure { it.appearance = initialConfiguration.appearance it.sheet = initialConfiguration.sheet @@ -60,7 +63,6 @@ class CheckoutWebViewTest { it.platform = initialConfiguration.platform it.logLevel = initialConfiguration.logLevel it.allowedMessageOrigins = initialConfiguration.allowedMessageOrigins - it.onMessageRejected = initialConfiguration.onMessageRejected } } @@ -316,10 +318,14 @@ class CheckoutWebViewTest { @Test fun `web message from an untrusted origin is dropped and reported when an allowlist is configured`() { - val rejected = mutableListOf() + val rejected = mutableListOf() + diagnosticSubscriptions += ShopifyCheckoutKit.diagnostics.subscribe { event -> + if (event is CheckoutDiagnosticEvent.MessageRejected) { + rejected += event.rejection + } + } ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") - it.onMessageRejected = { rejected.add(it) } } val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123") @@ -342,15 +348,15 @@ class CheckoutWebViewTest { assertThat(received).isFalse() assertThat(rejected).singleElement().satisfies({ assertThat(it.origin).isEqualTo("https://evil.example.com") - assertThat(it.reason).contains("not in the allowlist") + assertThat(it.reason).isEqualTo(CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED) }) } @Test - fun `callback failures do not interrupt later trusted messages`() { + fun `diagnostic listener failures do not interrupt later trusted messages`() { + diagnosticSubscriptions += ShopifyCheckoutKit.diagnostics.subscribe { error("listener failed") } ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") - it.onMessageRejected = { error("callback failed") } } val view = checkoutWebView(activity) view.loadCheckout("https://checkout.shopify.com/cart/123") diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java index 24ea0e6bf..da1e53b2b 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java @@ -58,6 +58,15 @@ public void onCheckoutDismissed() { assertThat(listener).isNotNull(); } + @Test + public void canSubscribeToDiagnosticsFromJava() { + List events = new ArrayList<>(); + CheckoutDiagnostics.Subscription subscription = ShopifyCheckoutKit.diagnostics.subscribe(events::add); + + assertThat(subscription).isNotNull(); + subscription.close(); + } + @Test public void canConstructCheckoutExceptionWithJavaOverloads() { CheckoutException withoutOptionalFields = new CheckoutException(