Skip to content

Commit 4b0d021

Browse files
committed
ignore unknown messages
1 parent 0480241 commit 4b0d021

7 files changed

Lines changed: 148 additions & 89 deletions

File tree

platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutCommunicationClient.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ public interface CheckoutCommunicationClient {
1010
/**
1111
* Process a JSON-RPC 2.0 ECP message from the checkout web page.
1212
*
13-
* Called for EC notifications (ec.start, ec.error, ec.complete, ec.*.change),
14-
* merchant-overridable delegations such as `ec.window.open_request`, and any
15-
* unknown methods the kit doesn't handle natively. For requests, return a JSON-RPC
16-
* 2.0 response string; for notifications, return null (no response is sent).
13+
* Called for supported EC notifications (ec.start, ec.error, ec.complete,
14+
* ec.*.change) and merchant-overridable delegations such as
15+
* `ec.window.open_request`. For requests, return a JSON-RPC 2.0 response string;
16+
* for notifications, return null (no response is sent).
1717
*
1818
* @param message JSON-RPC 2.0 encoded message string
1919
* @return JSON-RPC 2.0 encoded response string, or null to send no response

platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocol.kt

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,11 @@ internal class EmbeddedCheckoutProtocol(
5656
log.d(LOG_TAG, "Received bridge message: method=${request.method} id=${request.id}")
5757
when {
5858
request.method == METHOD_READY -> handleReady(request)
59-
// Respond with explicit "not supported" so web-side promises don't hang
60-
request.method in UNSUPPORTED_METHODS ->
61-
sendError(request.id, CODE_METHOD_NOT_SUPPORTED, "Method not supported by this SDK")
62-
// ep.cart.* is out of scope for the checkout bridge
63-
request.method.startsWith("ep.") ->
64-
log.d(LOG_TAG, "Ignoring out-of-scope ep method: ${request.method}.")
6559
request.method == CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message)
6660
request.method == CheckoutProtocol.start.method -> handleStart(message)
6761
request.method == CheckoutProtocol.complete.method -> handleComplete(message)
68-
else -> handleClientMessage(request.method, message)
62+
request.method in SUPPORTED_CLIENT_METHODS -> handleClientMessage(request.method, message)
63+
else -> log.d(LOG_TAG, "Ignoring unsupported ECP method: ${request.method}.")
6964
}
7065
} catch (e: SerializationException) {
7166
log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message")
@@ -145,9 +140,10 @@ internal class EmbeddedCheckoutProtocol(
145140
}
146141

147142
/**
148-
* Dispatch a message through the consumer client. `ec.error` also runs through the
149-
* kit-owned [defaultClient] regardless of the consumer response so unrecoverable
150-
* session errors always close checkout while still reaching `CheckoutProtocol.error`.
143+
* Dispatch a supported protocol message through the consumer client. `ec.error` also
144+
* runs through the kit-owned [defaultClient] regardless of the consumer response so
145+
* unrecoverable session errors always close checkout while still reaching
146+
* `CheckoutProtocol.error`.
151147
*/
152148
private fun handleClientMessage(method: String, message: String) {
153149
log.d(LOG_TAG, "Delegating $method to client.")
@@ -230,17 +226,16 @@ internal class EmbeddedCheckoutProtocol(
230226
// `ec_delegate` URL param emitted from [UriExtensions.appendEcpParams].
231227
private val KIT_SUPPORTED_DELEGATIONS = setOf("window.open")
232228

233-
// Requests the SDK explicitly does not support — send a protocol-level error so the
234-
// web-side promise resolves rather than hanging indefinitely.
235-
private val UNSUPPORTED_METHODS = setOf(
236-
"ec.auth",
237-
"ec.payment.instruments_change_request",
238-
"ec.payment.credential_request",
239-
"ec.fulfillment.address_change_request",
229+
// Keep this aligned with the web component's CHECKOUT_PROTOCOL_MESSAGES allow-list.
230+
private val SUPPORTED_CLIENT_METHODS = setOf(
231+
CheckoutProtocol.error.method,
232+
CheckoutProtocol.lineItemsChange.method,
233+
CheckoutProtocol.buyerChange.method,
234+
CheckoutProtocol.messagesChange.method,
235+
CheckoutProtocol.totalsChange.method,
240236
)
241237

242238
private const val CODE_PARSE_ERROR = -32700
243-
private const val CODE_METHOD_NOT_SUPPORTED = -32601
244239
}
245240
}
246241

platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ public object ShopifyCheckoutKit {
7878
* @param context The context the checkout is being presented from
7979
* @param checkoutListener provides callbacks to allow clients to listen for and respond to checkout lifecycle events
8080
* (failure, cancellation, permission prompts, file chooser).
81-
* @param communicationClient optional handler for Embedded Checkout Protocol (ECP) messages.
82-
* Implement [CheckoutCommunicationClient] to intercept arbitrary ECP messages from the checkout
83-
* web page. Built-in messages ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and
84-
* [ec.start][CheckoutProtocol.start]) are handled automatically by the SDK.
81+
* @param communicationClient optional handler for supported Embedded Checkout Protocol (ECP)
82+
* messages from the checkout web page. Built-in messages
83+
* ([ec.ready][EmbeddedCheckoutProtocol.METHOD_READY] and [ec.start][CheckoutProtocol.start])
84+
* are handled automatically by the SDK.
8585
* @return An instance of [CheckoutKitDialog] if the dialog was successfully created and displayed.
8686
*/
8787
@JvmOverloads

platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutPresentationTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ class CheckoutPresentationTest {
8080

8181
@Test
8282
fun `present builder forwards connected client to embedded checkout protocol`() {
83-
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"1"}"""
83+
val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}"""
8484
val client = mock<CheckoutCommunicationClient>()
8585
whenever(client.process(rawMessage)).thenReturn(null)
8686

platforms/android/lib/src/test/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolTest.kt

Lines changed: 34 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -148,68 +148,37 @@ class EmbeddedCheckoutProtocolTest {
148148

149149
// endregion
150150

151-
// region unsupported methods — explicit error response
151+
// region unsupported methods — silently ignored
152152

153153
@Test
154-
fun `ec auth sends method not supported error`() {
155-
val js = captureEvaluatedJs {
156-
ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
157-
}
158-
assertThat(js).contains("\"error\"")
159-
assertThat(js).contains("-32601")
154+
fun `ec auth is silently ignored and not delegated to client`() {
155+
assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
160156
}
161157

162158
@Test
163-
fun `ec auth does not invoke client`() {
164-
val client = mock<CheckoutCommunicationClient>()
165-
ecp.setClient(client)
166-
ecp.postMessage("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""")
167-
verify(client, never()).process(any())
168-
}
169-
170-
@Test
171-
fun `ec payment instruments change request sends method not supported error`() {
172-
val js = captureEvaluatedJs {
173-
ecp.postMessage(
174-
"""{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}"""
175-
)
176-
}
177-
assertThat(js).contains("\"error\"")
178-
assertThat(js).contains("-32601")
159+
fun `ec payment instruments change request is silently ignored and not delegated to client`() {
160+
assertIgnoredByBridge(
161+
"""{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}"""
162+
)
179163
}
180164

181165
@Test
182-
fun `ec payment credential request sends method not supported error`() {
183-
val js = captureEvaluatedJs {
184-
ecp.postMessage(
185-
"""{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}"""
186-
)
187-
}
188-
assertThat(js).contains("\"error\"")
189-
assertThat(js).contains("-32601")
166+
fun `ec payment credential request is silently ignored and not delegated to client`() {
167+
assertIgnoredByBridge(
168+
"""{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}"""
169+
)
190170
}
191171

192172
@Test
193-
fun `ec fulfillment address change request sends method not supported error`() {
194-
val js = captureEvaluatedJs {
195-
ecp.postMessage(
196-
"""{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}"""
197-
)
198-
}
199-
assertThat(js).contains("\"error\"")
200-
assertThat(js).contains("-32601")
173+
fun `ec fulfillment address change request is silently ignored and not delegated to client`() {
174+
assertIgnoredByBridge(
175+
"""{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}"""
176+
)
201177
}
202178

203179
@Test
204-
fun `ep cart methods are silently ignored and not delegated to client`() {
205-
val client = mock<CheckoutCommunicationClient>()
206-
ecp.setClient(client)
207-
208-
ecp.postMessage("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""")
209-
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
210-
211-
verify(viewSpy, never()).evaluateJavascript(any(), any())
212-
verify(client, never()).process(any())
180+
fun `ep cart methods fall through as unsupported and are not delegated to client`() {
181+
assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""")
213182
}
214183

215184
// endregion
@@ -494,21 +463,14 @@ class EmbeddedCheckoutProtocolTest {
494463
// region client delegation — requests
495464

496465
@Test
497-
fun `unknown method is delegated to client`() {
466+
fun `unknown method is silently ignored and not delegated to client`() {
498467
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"8","params":{}}"""
499-
val client = mock<CheckoutCommunicationClient>()
500-
whenever(client.process(rawMessage)).thenReturn(null)
501-
ecp.setClient(client)
502-
503-
ecp.postMessage(rawMessage)
504-
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
505-
506-
verify(client).process(rawMessage)
468+
assertIgnoredByBridge(rawMessage)
507469
}
508470

509471
@Test
510-
fun `non-null client response is sent back to checkout`() {
511-
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"9"}"""
472+
fun `non-null client response for supported request is sent back to checkout`() {
473+
val rawMessage = windowOpenRequest(id = "\"9\"", url = "https://example.com")
512474
val clientResponse = """{"jsonrpc":"2.0","id":"9","result":{"data":"ok"}}"""
513475
val client = mock<CheckoutCommunicationClient>()
514476
whenever(client.process(rawMessage)).thenReturn(clientResponse)
@@ -522,8 +484,8 @@ class EmbeddedCheckoutProtocolTest {
522484
}
523485

524486
@Test
525-
fun `null client response sends nothing to checkout`() {
526-
val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"10"}"""
487+
fun `null client response for supported notification sends nothing to checkout`() {
488+
val rawMessage = """{"jsonrpc":"2.0","method":"ec.messages.change","params":{"checkout":{}}}"""
527489
val client = mock<CheckoutCommunicationClient>()
528490
whenever(client.process(rawMessage)).thenReturn(null)
529491
ecp.setClient(client)
@@ -594,6 +556,17 @@ class EmbeddedCheckoutProtocolTest {
594556
private fun windowOpenRequest(id: String, url: String): String =
595557
"""{"jsonrpc":"2.0","method":"ec.window.open_request","id":$id,"params":{"url":"$url"}}"""
596558

559+
private fun assertIgnoredByBridge(rawMessage: String) {
560+
val client = mock<CheckoutCommunicationClient>()
561+
ecp.setClient(client)
562+
563+
ecp.postMessage(rawMessage)
564+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
565+
566+
verify(viewSpy, never()).evaluateJavascript(any(), any())
567+
verify(client, never()).process(any())
568+
}
569+
597570
private fun ecErrorMessage(severity: String): String {
598571
val messages =
599572
"""[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]"""

platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@ extension CheckoutWebView: WKScriptMessageHandler {
181181
return
182182
}
183183

184-
if let response = CheckoutProtocol.acknowledgeReady(body) {
184+
guard let method = Self.supportedProtocolMethod(body) else {
185+
return
186+
}
187+
188+
if method == Self.readyMethod, let response = CheckoutProtocol.acknowledgeReady(body) {
185189
Task { @MainActor in
186190
await checkoutBridge.sendResponse(self, messageBody: response)
187191
}
@@ -200,6 +204,34 @@ extension CheckoutWebView: WKScriptMessageHandler {
200204
}
201205
}
202206

207+
extension CheckoutWebView {
208+
fileprivate static let readyMethod = "ec.ready"
209+
210+
fileprivate static let supportedProtocolMethods: Set<String> = [
211+
readyMethod,
212+
CheckoutProtocol.start.method,
213+
CheckoutProtocol.complete.method,
214+
CheckoutProtocol.error.method,
215+
CheckoutProtocol.lineItemsChange.method,
216+
CheckoutProtocol.messagesChange.method,
217+
CheckoutProtocol.totalsChange.method,
218+
CheckoutProtocol.windowOpen.method
219+
]
220+
221+
fileprivate static func supportedProtocolMethod(_ body: String) -> String? {
222+
guard
223+
let object = try? JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
224+
object["jsonrpc"] as? String == "2.0",
225+
let method = object["method"] as? String,
226+
supportedProtocolMethods.contains(method)
227+
else {
228+
return nil
229+
}
230+
231+
return method
232+
}
233+
}
234+
203235
extension CheckoutWebView: WKNavigationDelegate {
204236
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
205237
// Handle rare cases where the url is nil

platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,29 @@ class CheckoutWebViewTests: XCTestCase {
333333
XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
334334
}
335335

336+
@MainActor
337+
func testUnsupportedProtocolMethodsDoNotInvokeClient() async {
338+
let client = RecordingBridgeClient(response: #"{"jsonrpc":"2.0","id":"raw","result":{}}"#)
339+
view.client = client
340+
let notSent = expectation(description: "sendResponse must not fire")
341+
notSent.isInverted = true
342+
MockCheckoutBridge.sendResponseExpectation = notSent
343+
let messages = [
344+
#"{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"unsupported","params":{}}"#,
345+
#"{"jsonrpc":"2.0","method":"ep.cart.ready","id":"ep","params":{}}"#,
346+
#"{"jsonrpc":"2.0","method":"customMethod","id":"custom","params":{}}"#
347+
]
348+
349+
for body in messages {
350+
view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body))
351+
}
352+
353+
await fulfillment(of: [notSent], timeout: 1.0)
354+
XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
355+
let receivedMessages = await client.messages()
356+
XCTAssertEqual(receivedMessages, [])
357+
}
358+
336359
@MainActor
337360
func testReadyAckFiresWhenNoClientIsAttached() async {
338361
view.client = nil
@@ -348,6 +371,24 @@ class CheckoutWebViewTests: XCTestCase {
348371
XCTAssertTrue(MockCheckoutBridge.sendResponseCalled)
349372
}
350373

374+
@MainActor
375+
func testSupportedRequestUsesRawClientResponse() async {
376+
let id = "req-window-raw"
377+
let body = #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"\#(id)","params":{"url":"https://example.com/terms"}}"#
378+
let rawResponse = #"{"jsonrpc":"2.0","id":"\#(id)","result":{"data":"ok"}}"#
379+
let responseSent = expectation(description: "response sent")
380+
MockCheckoutBridge.sendResponseExpectation = responseSent
381+
let client = RecordingBridgeClient(response: rawResponse)
382+
view.client = client
383+
384+
view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body))
385+
386+
await fulfillment(of: [responseSent], timeout: 5.0)
387+
XCTAssertEqual(MockCheckoutBridge.lastResponseBody, rawResponse)
388+
let receivedMessages = await client.messages()
389+
XCTAssertEqual(receivedMessages, [body])
390+
}
391+
351392
@MainActor
352393
func testWindowOpenRequestUsesConsumerOverride() async throws {
353394
let id = "req-window-1"
@@ -514,6 +555,24 @@ class CheckoutWebViewTests: XCTestCase {
514555
}
515556
}
516557

558+
private actor RecordingBridgeClient: CheckoutCommunicationProtocol {
559+
let response: String?
560+
private var receivedMessages: [String] = []
561+
562+
init(response: String? = nil) {
563+
self.response = response
564+
}
565+
566+
func messages() -> [String] {
567+
receivedMessages
568+
}
569+
570+
func process(_ message: String) async -> String? {
571+
receivedMessages.append(message)
572+
return response
573+
}
574+
}
575+
517576
@MainActor
518577
class MockCheckoutBridge: CheckoutBridgeProtocol {
519578
static var sendResponseCalled = false

0 commit comments

Comments
 (0)