From ff31aa5f82d1621335db1190e769fc4fd2168b94 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Tue, 16 Jun 2026 12:41:40 +0100 Subject: [PATCH] respond to unknown requests with error --- .../checkoutkit/EmbeddedCheckoutProtocol.kt | 18 ++- .../EmbeddedCheckoutProtocolTest.kt | 89 +++++++++--- .../ShopifyCheckoutKit/CheckoutWebView.swift | 5 + .../CheckoutWebViewTests.swift | 43 +++++- .../swift/api/ShopifyCheckoutProtocol.json | 130 ++++++++++++++++++ platforms/web/src/checkout.test.ts | 102 ++++++++++++++ platforms/web/src/checkout.ts | 48 ++++++- .../CheckoutProtocol.swift | 47 +++++++ .../DescriptorTests.swift | 30 ++++ 9 files changed, 484 insertions(+), 28 deletions(-) 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 e3d35d06..2e23785f 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 @@ -11,6 +11,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject @@ -55,15 +56,22 @@ internal class EmbeddedCheckoutProtocol( @JavascriptInterface fun postMessage(message: String) { try { - val request = decoder.decodeFromString(message) + val requestObject = decoder.decodeFromString(message) + val request = decoder.decodeFromJsonElement(requestObject) val method = CheckoutProtocol.supportedProtocolMethod(request) + val requestId = jsonRpcRequestId(requestObject["id"]) log.d(LOG_TAG, "Received bridge message: method=${request.method} id=${request.id}") when (method) { CheckoutProtocol.READY_METHOD -> handleReady(request) CheckoutProtocol.windowOpen.method -> handleWindowOpenRequest(message) CheckoutProtocol.start.method -> handleStart(message) CheckoutProtocol.complete.method -> handleComplete(message) - null -> log.d(LOG_TAG, "Ignoring unsupported ECP method: ${request.method}.") + null -> { + log.d(LOG_TAG, "Ignoring unsupported ECP method: ${request.method}.") + if (requestId != null) { + sendError(requestId, CODE_METHOD_NOT_FOUND, "Method not found") + } + } else -> handleClientMessage(method, message) } } catch (e: SerializationException) { @@ -236,5 +244,11 @@ internal class EmbeddedCheckoutProtocol( private val KIT_SUPPORTED_DELEGATIONS = setOf("window.open") private const val CODE_PARSE_ERROR = -32700 + private const val CODE_METHOD_NOT_FOUND = -32601 } } + +private fun jsonRpcRequestId(id: JsonElement?): JsonElement? { + val primitive = id as? JsonPrimitive ?: return null + return primitive.takeIf { it.isString || it.contentOrNull?.toDoubleOrNull() != null } +} 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 b245b3b6..5736f252 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 @@ -150,42 +150,58 @@ class EmbeddedCheckoutProtocolTest { // endregion - // region unsupported methods — silently ignored + // region unsupported methods @Test - fun `ec auth is silently ignored and not delegated to client`() { - assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""") + fun `ec auth request returns method not found and is not delegated to client`() { + assertMethodNotFoundByBridge( + """{"jsonrpc":"2.0","method":"ec.auth","id":"1","params":{"type":"oauth"}}""", + """"id":"1"""", + ) } @Test - fun `ec payment instruments change request is silently ignored and not delegated to client`() { - assertIgnoredByBridge( - """{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}""" + fun `ec payment instruments change request returns method not found and is not delegated to client`() { + assertMethodNotFoundByBridge( + """{"jsonrpc":"2.0","method":"ec.payment.instruments_change_request","id":"2","params":{}}""", + """"id":"2"""", ) } @Test - fun `ec payment credential request is silently ignored and not delegated to client`() { - assertIgnoredByBridge( - """{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}""" + fun `ec payment credential request returns method not found and is not delegated to client`() { + assertMethodNotFoundByBridge( + """{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"3","params":{}}""", + """"id":"3"""", ) } @Test - fun `ec fulfillment address change request is silently ignored and not delegated to client`() { - assertIgnoredByBridge( - """{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}""" + fun `ec fulfillment address change request returns method not found and is not delegated to client`() { + assertMethodNotFoundByBridge( + """{"jsonrpc":"2.0","method":"ec.fulfillment.address_change_request","id":"4","params":{}}""", + """"id":"4"""", ) } @Test - fun `ec buyer change is silently ignored and not delegated to client`() { + fun `ep cart request returns method not found and is not delegated to client`() { + assertMethodNotFoundByBridge( + """{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""", + """"id":"5"""", + ) + } + + @Test + fun `ec buyer change notification is ignored and not delegated to client`() { assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.buyer.change","params":{"checkout":{}}}""") } @Test - fun `ep cart methods fall through as unsupported and are not delegated to client`() { - assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ep.cart.ready","id":"5","params":{}}""") + fun `unsupported notifications are ignored and not delegated to client`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.auth","params":{"type":"oauth"}}""") + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ec.buyer.change","params":{"checkout":{}}}""") + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"ep.cart.ready","params":{}}""") } // endregion @@ -496,9 +512,9 @@ class EmbeddedCheckoutProtocolTest { // region client delegation — requests @Test - fun `unknown method is silently ignored and not delegated to client`() { + fun `unknown request returns method not found and is not delegated to client`() { val rawMessage = """{"jsonrpc":"2.0","method":"customMethod","id":"8","params":{}}""" - assertIgnoredByBridge(rawMessage) + assertMethodNotFoundByBridge(rawMessage, """"id":"8"""") } @Test @@ -530,11 +546,27 @@ class EmbeddedCheckoutProtocolTest { } @Test - fun `unknown method with no client sends nothing to checkout`() { - ecp.postMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":"11"}""") - shadowOf(Looper.getMainLooper()).runToEndOfTasks() + fun `unknown notification sends nothing to checkout`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"unknownMethod"}""") + } - verify(viewSpy, never()).evaluateJavascript(any(), any()) + @Test + fun `unknown request with no client returns method not found`() { + val js = captureEvaluatedJs { + ecp.postMessage("""{"jsonrpc":"2.0","method":"unknownMethod","id":"11"}""") + } + + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32601") + assertThat(js).contains("Method not found") + assertThat(js).contains(""""id":"11"""") + } + + @Test + fun `unknown request with invalid id sends no response`() { + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"unknownMethod","id":{},"params":{}}""") + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"unknownMethod","id":null,"params":{}}""") + assertIgnoredByBridge("""{"jsonrpc":"2.0","method":"unknownMethod","id":true,"params":{}}""") } // endregion @@ -600,6 +632,21 @@ class EmbeddedCheckoutProtocolTest { verify(client, never()).process(any()) } + private fun assertMethodNotFoundByBridge(rawMessage: String, expectedId: String) { + val client = mock() + ecp.setClient(client) + + val js = captureEvaluatedJs { + ecp.postMessage(rawMessage) + } + + assertThat(js).contains("\"error\"") + assertThat(js).contains("-32601") + assertThat(js).contains("Method not found") + assertThat(js).contains(expectedId) + verify(client, never()).process(any()) + } + private fun ecErrorMessage(severity: String): String { val messages = """[{"type":"error","code":"session_failed","content":"Session failed","severity":"$severity"}]""" diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 1a3eaf95..b738e191 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -186,6 +186,11 @@ extension CheckoutWebView: WKScriptMessageHandler { } guard let method = CheckoutProtocol.supportedProtocolMethod(body) else { + if let response = CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: body) { + Task { @MainActor in + await checkoutBridge.sendResponse(self, messageBody: response) + } + } return } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 2acda1ac..5d0de639 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -350,16 +350,48 @@ class CheckoutWebViewTests: XCTestCase { } @MainActor - func testUnsupportedProtocolMethodsDoNotInvokeClient() async { + func testUnsupportedProtocolRequestsReturnMethodNotFoundAndDoNotInvokeClient() async throws { + let client = RecordingBridgeClient(response: #"{"jsonrpc":"2.0","id":"raw","result":{}}"#) + view.client = client + let responseSent = expectation(description: "method-not-found responses sent") + responseSent.expectedFulfillmentCount = 3 + MockCheckoutBridge.sendResponseExpectation = responseSent + let messages = [ + (#"{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"unsupported","params":{}}"#, "unsupported"), + (#"{"jsonrpc":"2.0","method":"ep.cart.ready","id":"ep","params":{}}"#, "ep"), + (#"{"jsonrpc":"2.0","method":"customMethod","id":"custom","params":{}}"#, "custom") + ] + + for (body, _) in messages { + view.userContentController(WKUserContentController(), didReceive: MockScriptMessage(body: body)) + } + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertEqual(MockCheckoutBridge.sendResponseCount, messages.count) + let parsedResponses = try MockCheckoutBridge.responseBodies.map { response -> [String: Any] in + try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + } + XCTAssertEqual(parsedResponses.map { $0["id"] as? String }, messages.map { $0.1 }) + for parsed in parsedResponses { + let error = try XCTUnwrap(parsed["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? Int, -32601) + XCTAssertEqual(error["message"] as? String, "Method not found") + } + let receivedMessages = await client.messages() + XCTAssertEqual(receivedMessages, []) + } + + @MainActor + func testUnsupportedProtocolNotificationsDoNotInvokeClient() async { let client = RecordingBridgeClient(response: #"{"jsonrpc":"2.0","id":"raw","result":{}}"#) view.client = client let notSent = expectation(description: "sendResponse must not fire") notSent.isInverted = true MockCheckoutBridge.sendResponseExpectation = notSent let messages = [ - #"{"jsonrpc":"2.0","method":"ec.payment.credential_request","id":"unsupported","params":{}}"#, - #"{"jsonrpc":"2.0","method":"ep.cart.ready","id":"ep","params":{}}"#, - #"{"jsonrpc":"2.0","method":"customMethod","id":"custom","params":{}}"# + #"{"jsonrpc":"2.0","method":"ec.payment.credential_request","params":{}}"#, + #"{"jsonrpc":"2.0","method":"ep.cart.ready","params":{}}"#, + #"{"jsonrpc":"2.0","method":"customMethod","params":{}}"# ] for body in messages { @@ -594,12 +626,14 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { static var sendResponseCalled = false static var sendResponseCount = 0 static var lastResponseBody: String? + static var responseBodies: [String] = [] static var sendResponseExpectation: XCTestExpectation? static func reset() { sendResponseCalled = false sendResponseCount = 0 lastResponseBody = nil + responseBodies = [] sendResponseExpectation = nil } @@ -607,6 +641,7 @@ class MockCheckoutBridge: CheckoutBridgeProtocol { sendResponseCalled = true sendResponseCount += 1 lastResponseBody = messageBody + responseBodies.append(messageBody) sendResponseExpectation?.fulfill() return true } diff --git a/platforms/swift/api/ShopifyCheckoutProtocol.json b/platforms/swift/api/ShopifyCheckoutProtocol.json index 006c63aa..824c23fa 100644 --- a/platforms/swift/api/ShopifyCheckoutProtocol.json +++ b/platforms/swift/api/ShopifyCheckoutProtocol.json @@ -206,6 +206,102 @@ } ] }, + { + "kind": "Var", + "name": "methodNotFoundCode", + "printedName": "methodNotFoundCode", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O18methodNotFoundCodeSivpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O18methodNotFoundCodeSivpZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:23ShopifyCheckoutProtocol0bC0O18methodNotFoundCodeSivgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O18methodNotFoundCodeSivgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "methodNotFoundMessage", + "printedName": "methodNotFoundMessage", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O21methodNotFoundMessageSSvpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O21methodNotFoundMessageSSvpZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:23ShopifyCheckoutProtocol0bC0O21methodNotFoundMessageSSvgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O21methodNotFoundMessageSSvgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, { "kind": "Var", "name": "complete", @@ -694,6 +790,40 @@ "isInternal": true, "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "methodNotFoundResponse", + "printedName": "methodNotFoundResponse(forUnsupportedProtocolRequest:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "s:23ShopifyCheckoutProtocol0bC0O22methodNotFoundResponse014forUnsupportedC7RequestSSSgSS_tFZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O22methodNotFoundResponse014forUnsupportedC7RequestSSSgSS_tFZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "isInternal": true, + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "url", diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index 8c9f5030..5bd4a53a 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -763,6 +763,81 @@ describe("", () => { }); }); + describe("unsupported protocol methods", () => { + it("posts method-not-found for unsupported requests", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const targetOrigin = new URL(checkout.src).origin; + + simulateRawMessageEvent( + checkout, + { + jsonrpc: "2.0", + method: "ep.cart.ready", + id: "unsupported-1", + params: {}, + }, + { source: mockCheckoutWindow }, + ); + + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "unsupported-1", + error: { + code: -32601, + message: "Method not found", + }, + }, + { targetOrigin }, + ); + }); + + it("ignores unsupported requests with invalid request ids", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + for (const id of [{}, null, true]) { + simulateRawMessageEvent( + checkout, + { + jsonrpc: "2.0", + method: "ep.cart.ready", + id, + params: {}, + }, + { source: mockCheckoutWindow }, + ); + } + + simulateRawMessageEvent( + checkout, + { + jsonrpc: "2.0", + method: "ep.cart.ready", + params: {}, + }, + { source: mockCheckoutWindow }, + ); + + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores unsupported notifications", () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + + simulateRawMessageEvent( + checkout, + { + jsonrpc: "2.0", + method: "customMethod", + params: {}, + }, + { source: mockCheckoutWindow }, + ); + + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + }); + }); + describe("checkout:start", () => { it("updates the checkout property and dispatches an ec:start event", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); @@ -1605,6 +1680,33 @@ function simulateProtocolMessageEvent unknown) { return new Promise((resolve) => { const handler = (event: Event) => { diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index b3490b50..1cbc2094 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -524,7 +524,10 @@ export class ShopifyCheckout } const message = CheckoutProtocolMessage.parse(event); - if (!message) return; + if (!message) { + respondToUnsupportedProtocolRequest(event); + return; + } // @see https://ucp.dev/2026-04-08/specification/embedded-checkout/ @@ -921,6 +924,11 @@ const CHECKOUT_PROTOCOL_MESSAGES: (keyof CheckoutProtocolMessageMap)[] = [ "ec.window.open_request", ]; +const METHOD_NOT_FOUND_ERROR = { + code: -32601, + message: "Method not found", +} as const; + class CheckoutProtocolMessage< MessageType extends keyof CheckoutProtocolMessageMap = keyof CheckoutProtocolMessageMap, > { @@ -958,6 +966,44 @@ function isEcErrorParams(params: unknown): params is CheckoutProtocolMessageMap[ return params != null && typeof params === "object" && "error" in params; } +function respondToUnsupportedProtocolRequest(event: MessageEvent) { + const request = parseUnsupportedProtocolRequest(event.data); + if (!request) return; + + event.source?.postMessage( + { + jsonrpc: "2.0" as const, + id: request.id, + error: METHOD_NOT_FOUND_ERROR, + }, + { targetOrigin: event.origin }, + ); +} + +function parseUnsupportedProtocolRequest(data: unknown): { id: string | number } | undefined { + if ( + data == null || + typeof data !== "object" || + !("jsonrpc" in data) || + data.jsonrpc !== "2.0" || + !("method" in data) || + typeof data.method !== "string" || + CHECKOUT_PROTOCOL_MESSAGES.includes(data.method as keyof CheckoutProtocolMessageMap) || + !("id" in data) || + !isJsonRpcRequestId(data.id) + ) { + return; + } + + return { id: data.id }; +} + +function isJsonRpcRequestId(id: unknown): id is string | number { + if (typeof id === "string") return true; + if (typeof id === "number" && Number.isFinite(id)) return true; + return false; +} + function isCheckoutProtocolMessage(data: unknown): data is CheckoutProtocolMessageData { return ( data != null && diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift index 611681b9..e9d4de0d 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift @@ -6,6 +6,8 @@ public enum CheckoutProtocol { public static let defaultDelegations: [String] = ["window.open"] package static let readyMethod = "ec.ready" + package static let methodNotFoundCode = -32601 + package static let methodNotFoundMessage = "Method not found" public static let complete = NotificationDescriptor(method: "ec.complete") public static let error = NotificationDescriptor(method: "ec.error") @@ -41,4 +43,49 @@ public enum CheckoutProtocol { return method } + + package static func methodNotFoundResponse(forUnsupportedProtocolRequest message: String) -> String? { + guard + let object = try? JSONSerialization.jsonObject(with: Data(message.utf8)) as? [String: Any], + object["jsonrpc"] as? String == "2.0", + let method = object["method"] as? String, + !supportedProtocolMethods.contains(method), + let id = jsonRpcRequestID(object["id"]) + else { + return nil + } + + let response: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": [ + "code": methodNotFoundCode, + "message": methodNotFoundMessage + ] + ] + + guard + JSONSerialization.isValidJSONObject(response), + let data = try? JSONSerialization.data(withJSONObject: response), + let body = String(data: data, encoding: .utf8) + else { + return nil + } + + return body + } + + private static func jsonRpcRequestID(_ id: Any?) -> Any? { + switch id { + case let value as String: + return value + case let value as NSNumber: + guard CFGetTypeID(value) != CFBooleanGetTypeID() else { + return nil + } + return value + default: + return nil + } + } } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift index 57acaacb..187e2ec7 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift @@ -1,4 +1,5 @@ @testable import ShopifyCheckoutProtocol +import Foundation import Testing @Suite("Descriptor Tests") @@ -68,5 +69,34 @@ struct DescriptorTests { #expect(CheckoutProtocol.supportedProtocolMethod(#"{"jsonrpc":"1.0","method":"ec.start"}"#) == nil) #expect(CheckoutProtocol.supportedProtocolMethod("not json") == nil) } + + @Test func methodNotFoundResponseEncodesUnsupportedRequests() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":"unsupported","params":{}}"# + ) + ) + let data = try #require(response.data(using: .utf8)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["jsonrpc"] as? String == "2.0") + #expect(object["id"] as? String == "unsupported") + let error = try #require(object["error"] as? [String: Any]) + #expect(error["code"] as? Int == CheckoutProtocol.methodNotFoundCode) + #expect(error["message"] as? String == CheckoutProtocol.methodNotFoundMessage) + } + + @Test func methodNotFoundResponseRejectsInvalidRequestIDs() { + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":true,"params":{}}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":null,"params":{}}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":{},"params":{}}"#) == nil) + } + + @Test func methodNotFoundResponseRejectsSupportedNotificationsOrInvalidMessages() { + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"ec.start","id":"supported"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"1.0","method":"custom","id":"unsupported"}"#) == nil) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: "not json") == nil) + } } }