diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ComposedCheckoutCommunicationClient.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ComposedCheckoutCommunicationClient.swift index 0899eb0e..7e204836 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ComposedCheckoutCommunicationClient.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ComposedCheckoutCommunicationClient.swift @@ -37,15 +37,15 @@ struct ComposedCheckoutCommunicationClient: CheckoutCommunicationProtocol { } private static func method(_ message: String) -> String? { - guard - let object = try? JSONSerialization.jsonObject(with: Data(message.utf8)) as? [String: Any] - else { - return nil - } - return object["method"] as? String + guard let request = try? JSONDecoder().decode(MethodEnvelope.self, from: Data(message.utf8)) else { return nil } + return request.method } } +private struct MethodEnvelope: Decodable { + let method: String +} + struct DefaultClientBinding { let client: any CheckoutCommunicationProtocol let policy: DefaultClientPolicy diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 5b17f98e..4894c9f8 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -662,6 +662,27 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } + @MainActor + func testMalformedReadyParamsReturnParseError() async throws { + view.client = MockBridgeClient(responseMessage: "client-response") + let body = #"{"jsonrpc":"2.0","method":"ec.ready","id":"ready-bad","params":{"delegate":[null]}}"# + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: body) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + + let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + XCTAssertNotEqual(response, "client-response") + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, "ready-bad") + let error = try XCTUnwrap(parsed["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? Int, -32700) + XCTAssertEqual(error["message"] as? String, "Parse error") + } + @MainActor func testSupportedRequestUsesRawClientResponse() async { let id = "req-window-raw" @@ -726,17 +747,21 @@ class CheckoutWebViewTests: XCTestCase { } @MainActor - func testWindowOpenRequestIgnoresMalformedBody() async { + func testWindowOpenRequestReturnsInvalidParamsForMalformedBody() async throws { view.client = nil - let notFired = expectation(description: "sendResponse must not fire") - notFired.isInverted = true - MockCheckoutBridge.sendResponseExpectation = notFired + let responseSent = expectation(description: "sendResponse fires") + MockCheckoutBridge.sendResponseExpectation = responseSent let message = MockScriptMessage(body: #"{"jsonrpc":"2.0","method":"ec.window.open_request","id":"r","params":{}}"#) view.userContentController(WKUserContentController(), didReceive: message) - await fulfillment(of: [notFired], timeout: 1.0) - XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + await fulfillment(of: [responseSent], timeout: 1.0) + let response = try XCTUnwrap(MockCheckoutBridge.lastResponseBody) + let parsed = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + XCTAssertEqual(parsed["id"] as? String, "r") + let error = try XCTUnwrap(parsed["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? Int, -32602) + XCTAssertEqual(error["message"] as? String, "Invalid params") } // MARK: - ec.error severity-based dismissal diff --git a/platforms/swift/api/ShopifyCheckoutProtocol.json b/platforms/swift/api/ShopifyCheckoutProtocol.json index 824c23fa..15d3f7bd 100644 --- a/platforms/swift/api/ShopifyCheckoutProtocol.json +++ b/platforms/swift/api/ShopifyCheckoutProtocol.json @@ -206,6 +206,102 @@ } ] }, + { + "kind": "Var", + "name": "parseErrorCode", + "printedName": "parseErrorCode", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O14parseErrorCodeSivpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O14parseErrorCodeSivpZ", + "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:23ShopifyCheckoutProtocol0bC0O14parseErrorCodeSivgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O14parseErrorCodeSivgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "parseErrorMessage", + "printedName": "parseErrorMessage", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:23ShopifyCheckoutProtocol0bC0O17parseErrorMessageSSvpZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O17parseErrorMessageSSvpZ", + "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:23ShopifyCheckoutProtocol0bC0O17parseErrorMessageSSvgZ", + "mangledName": "$s23ShopifyCheckoutProtocol0bC0O17parseErrorMessageSSvgZ", + "moduleName": "ShopifyCheckoutProtocol", + "static": true, + "implicit": true, + "isInternal": true, + "accessorKind": "get" + } + ] + }, { "kind": "Var", "name": "methodNotFoundCode", diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/CheckoutProtocol.swift index e9d4de0d..55c6c31d 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 parseErrorCode = -32700 + package static let parseErrorMessage = "Parse error" package static let methodNotFoundCode = -32601 package static let methodNotFoundMessage = "Method not found" @@ -33,59 +35,33 @@ public enum CheckoutProtocol { package static func supportedProtocolMethod(_ 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 envelope = try? JSONDecoder().decode(JSONRPCEnvelope.self, from: Data(message.utf8)), + envelope.jsonrpc == "2.0", + supportedProtocolMethods.contains(envelope.method) else { return nil } - return method + return envelope.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"]) + let request = try? JSONDecoder().decode(JSONRPCEnvelope.self, from: Data(message.utf8)), + request.jsonrpc == "2.0", + !supportedProtocolMethods.contains(request.method), + let id = request.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 - } + let response = JSONRPCErrorResponse( + id: id, + error: JSONRPCError(code: methodNotFoundCode, message: methodNotFoundMessage) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(response) else { return nil } + return String(data: data, encoding: .utf8) } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift index 9b2ec178..9a3c992c 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Client.swift @@ -52,6 +52,9 @@ extension CheckoutProtocol { let accepted = requested.filter(Set(delegations).contains) return CheckoutProtocol.encodeReadyResponse(id: id, acceptedDelegations: accepted) + case let .error(id, code, message): + return CheckoutProtocol.encodeErrorResponse(id: id, code: code, message: message) + case let .notification(method, payload): await notificationHandlers[method]?(payload) return nil @@ -70,6 +73,6 @@ extension CheckoutProtocol { struct DelegationEntry { let delegation: String - let handler: @MainActor @Sendable (String, Data) async -> String? + let handler: @MainActor @Sendable (JSONRPCID, Data) async -> String? } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift index 06e55f98..2bde1ae7 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/Codec.swift @@ -8,9 +8,17 @@ extension CheckoutProtocol { _ message: String, supportedDelegations: [String] = CheckoutProtocol.defaultDelegations ) -> String? { - guard case let .ready(id, requested) = decode(jsonRpc: message) else { return nil } - let accepted = requested.filter(Set(supportedDelegations).contains) - return encodeReadyResponse(id: id, acceptedDelegations: accepted) + switch decode(jsonRpc: message) { + case let .ready(id, requested): + let accepted = requested.filter(Set(supportedDelegations).contains) + return encodeReadyResponse(id: id, acceptedDelegations: accepted) + + case let .error(id, code, message): + return encodeErrorResponse(id: id, code: code, message: message) + + default: + return nil + } } } @@ -20,46 +28,60 @@ extension CheckoutProtocol { return .unknown(method: "", rawParams: jsonRpc) } - guard let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) else { + guard let envelope = try? JSONDecoder().decode(JSONRPCEnvelope.self, from: data) else { return .unknown(method: "", rawParams: jsonRpc) } // Special case so we may intercept and send expected response to initialise the checkout - if request.method == "ec.ready", let id = request.id { - return .ready(id: id, delegations: request.params?.delegate ?? []) + if envelope.method == "ec.ready", let id = envelope.id { + guard let request = try? JSONDecoder().decode(JSONRPCReadyRequest.self, from: data) else { + return .error(id: id, code: parseErrorCode, message: parseErrorMessage) + } + return .ready(id: id, delegations: request.params.delegate) + } + + if envelope.method == "ec.error", + let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) { + return .notification(method: envelope.method, payload: request.params.error) } - if request.method == "ec.error", let error = request.params?.error { - return .notification(method: request.method, payload: error) + if let id = envelope.id, + let params = requestParamsData(for: envelope.method, from: data) { + return .request(id: id, method: envelope.method, params: params) } - if let id = request.id { - return .request( - id: id, - method: request.method, - params: extractParamsData(from: data) - ) + if let id = envelope.id, + envelope.method == "ec.window.open_request" { + return .error(id: id, code: invalidParamsCode, message: invalidParamsMessage) } - if let checkout = request.params?.checkout { - return .notification(method: request.method, payload: checkout) + if let request = try? JSONDecoder().decode(JSONRPCRequest.self, from: data) { + return .notification(method: envelope.method, payload: request.params.checkout) } - return .unknown(method: request.method, rawParams: jsonRpc) + return .unknown(method: envelope.method, rawParams: jsonRpc) } - private static func extractParamsData(from envelope: Data) -> Data { - guard - let object = try? JSONSerialization.jsonObject(with: envelope) as? [String: Any], - let params = object["params"], - let data = try? JSONSerialization.data(withJSONObject: params) - else { - return Data("{}".utf8) + private static func requestParamsData(for method: String, from data: Data) -> Data? { + switch method { + case "ec.window.open_request": + return try? encodeParams(JSONDecoder().decode(JSONRPCRequest.self, from: data).params) + default: + return try? encodeParams(JSONDecoder().decode(JSONRPCRequest.self, from: data).params) } - return data + } + + private static func encodeParams(_ params: some Encodable) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(params) } static func encodeResponse(id: String, result: some Encodable) -> String { + encodeResponse(id: .string(id), result: result) + } + + static func encodeResponse(id: JSONRPCID, result: some Encodable) -> String { let wrapper = JSONRPCResponse(id: id, result: result) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -68,18 +90,24 @@ extension CheckoutProtocol { } static func encodeReadyResponse(id: String, acceptedDelegations: [String]) -> String { + encodeReadyResponse(id: .string(id), acceptedDelegations: acceptedDelegations) + } + + static func encodeReadyResponse(id: JSONRPCID, acceptedDelegations: [String]) -> String { let result = UCPSuccessResult( ucp: UCPSuccess(version: specVersion), delegate: acceptedDelegations.isEmpty ? nil : acceptedDelegations ) return encodeResponse(id: id, result: result) } -} -private struct JSONRPCResponse: Encodable { - let jsonrpc = "2.0" - let id: String - let result: R + static func encodeErrorResponse(id: JSONRPCID, code: Int, message: String) -> String { + let wrapper = JSONRPCErrorResponse(id: id, error: JSONRPCError(code: code, message: message)) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(wrapper) else { return "{}" } + return String(data: data, encoding: .utf8) ?? "{}" + } } struct UCPSuccessResult: Encodable { @@ -101,3 +129,6 @@ struct UCPError: Encodable { let version: String let status = "error" } + +private let invalidParamsCode = -32602 +private let invalidParamsMessage = "Invalid params" diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift index b6f3240c..16680747 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/JSONRPCMessage.swift @@ -1,15 +1,180 @@ import Foundation -struct JSONRPCRequest: Decodable { +struct JSONRPCEnvelope: Decodable { let jsonrpc: String let method: String - let params: JSONRPCParams? - let id: String? + let id: JSONRPCID? + + private enum CodingKeys: String, CodingKey { + case jsonrpc + case method + case id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + jsonrpc = try container.decode(String.self, forKey: .jsonrpc) + method = try container.decode(String.self, forKey: .method) + id = try container.decodeJSONRPCIDIfPresent(forKey: .id) + } +} + +struct JSONRPCRequest: Decodable { + let jsonrpc: String + let method: String + let params: Params + let id: JSONRPCID? + + private enum CodingKeys: String, CodingKey { + case jsonrpc + case method + case params + case id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + jsonrpc = try container.decode(String.self, forKey: .jsonrpc) + method = try container.decode(String.self, forKey: .method) + params = try container.decode(Params.self, forKey: .params) + id = try container.decodeJSONRPCIDIfPresent(forKey: .id) + } +} + +struct JSONRPCReadyRequest: Decodable { + let jsonrpc: String + let method: String + let params: JSONRPCReadyParams + let id: JSONRPCID? + + private enum CodingKeys: String, CodingKey { + case jsonrpc + case method + case params + case id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + jsonrpc = try container.decode(String.self, forKey: .jsonrpc) + method = try container.decode(String.self, forKey: .method) + if container.contains(.params) { + params = try container.decode(JSONRPCReadyParams.self, forKey: .params) + } else { + params = JSONRPCReadyParams() + } + id = try container.decodeJSONRPCIDIfPresent(forKey: .id) + } +} + +struct JSONRPCResponse: Encodable { + let jsonrpc = "2.0" + let id: JSONRPCID + let result: Result +} + +struct JSONRPCErrorResponse: Encodable { + let jsonrpc = "2.0" + let id: JSONRPCID + let error: JSONRPCError +} + +struct JSONRPCError: Encodable { + let code: Int + let message: String +} + +enum JSONRPCID: Codable, Equatable, ExpressibleByStringLiteral, ExpressibleByIntegerLiteral { + case string(String) + case int(Int64) + case null + + init(stringLiteral value: String) { + self = .string(value) + } + + init(integerLiteral value: Int64) { + self = .int(value) + } + + var stringValue: String? { + guard case let .string(value) = self else { + return nil + } + + return value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Int64.self) { + self = .int(value) + } else { + throw DecodingError.typeMismatch( + JSONRPCID.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "JSON-RPC id must be a string, integer, or null" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +struct JSONRPCReadyParams: Codable { + let delegate: [String] + + init(delegate: [String] = []) { + self.delegate = delegate + } + + private enum CodingKeys: String, CodingKey { + case delegate + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.delegate) { + delegate = try container.decode([String].self, forKey: .delegate) + } else { + delegate = [] + } + } +} + +struct JSONRPCCheckoutParams: Codable { + let checkout: Checkout +} + +struct JSONRPCErrorParams: Codable { + let error: ErrorResponse +} + +struct JSONRPCWindowOpenParams: Codable { + let url: String } -struct JSONRPCParams: Decodable { - let checkout: Checkout? - let delegate: [String]? - let error: ErrorResponse? - let url: String? +private extension KeyedDecodingContainer { + func decodeJSONRPCIDIfPresent(forKey key: Key) throws -> JSONRPCID? { + guard contains(key) else { return nil } + return try decode(JSONRPCID.self, forKey: key) + } } diff --git a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift index 41bb68ab..9079d1c7 100644 --- a/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift +++ b/protocol/languages/swift/Sources/ShopifyCheckoutProtocol/UCPMessage.swift @@ -2,7 +2,8 @@ import Foundation enum UCPMessage { case notification(method: String, payload: any EventPayload & Sendable) - case request(id: String, method: String, params: Data) - case ready(id: String, delegations: [String]) + case request(id: JSONRPCID, method: String, params: Data) + case ready(id: JSONRPCID, delegations: [String]) + case error(id: JSONRPCID, code: Int, message: String) case unknown(method: String, rawParams: String) } diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift index 023787a7..3f32d899 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ClientTests.swift @@ -125,6 +125,22 @@ struct ClientTests { #expect(response == nil) } + @Test @MainActor func windowOpenRequestWithNullURLReturnsInvalidParamsError() async throws { + let client = CheckoutProtocol.Client() + .on(CheckoutProtocol.windowOpen) { _ in .success } + let request = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":null}} + """# + + let response = try #require(await client.process(request)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "req-window-1") + let error = try #require(parsed["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) + #expect(error["message"] as? String == "Invalid params") + } + @Test @MainActor func windowOpenRequestLastHandlerWins() async throws { let request = #""" {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com"}} @@ -156,6 +172,21 @@ struct ClientTests { #expect(delegate == ["window.open"]) } + @Test @MainActor func malformedReadyParamsReturnParseError() async throws { + let ready = #""" + {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} + """# + + let client = CheckoutProtocol.Client() + + let response = try #require(await client.process(ready)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + #expect(parsed["id"] as? String == "ready-bad") + let error = try #require(parsed["error"] as? [String: Any]) + #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) + #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + } + @Test @MainActor func readyReturnsResponse() async throws { let client = CheckoutProtocol.Client() diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift index 130f7dfa..c8d46cf3 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecDecodeTests.swift @@ -75,6 +75,22 @@ struct CodecDecodeTests { #expect(payload.url == URL(string: "https://example.com/terms")) } + @Test func windowOpenRequestDropsUnknownParamsBeforeDispatch() throws { + let json = #""" + {"jsonrpc":"2.0","id":"req-window-1","method":"ec.window.open_request","params":{"url":"https://example.com/terms","unknown":"value"}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .request(_, _, params) = message else { + Issue.record("Expected .request, got \(message)") + return + } + + let parsed = try #require(JSONSerialization.jsonObject(with: params) as? [String: Any]) + #expect(parsed["url"] as? String == "https://example.com/terms") + #expect(parsed["unknown"] == nil) + } + @Test func windowOpenDescriptorRejectsEmptyURL() { let params = Data(#"{"url":""}"#.utf8) #expect(CheckoutProtocol.windowOpen.decode(params) == nil) @@ -85,6 +101,27 @@ struct CodecDecodeTests { #expect(CheckoutProtocol.windowOpen.decode(params) == nil) } + @Test func windowOpenDescriptorRejectsNullURL() { + let params = Data(#"{"url":null}"#.utf8) + #expect(CheckoutProtocol.windowOpen.decode(params) == nil) + } + + @Test func decodesMalformedWindowOpenParamsAsInvalidParamsError() { + let json = #""" + {"jsonrpc":"2.0","id":"req-window-bad","method":"ec.window.open_request","params":{"url":null}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .error(id, code, responseMessage) = message else { + Issue.record("Expected .error, got \(message)") + return + } + + #expect(id == "req-window-bad") + #expect(code == -32602) + #expect(responseMessage == "Invalid params") + } + @Test func decodesUnknownMethod() { let json = """ {"jsonrpc":"2.0","method":"ec.unknown","params":{"something":"else"}} @@ -99,6 +136,97 @@ struct CodecDecodeTests { #expect(method == "ec.unknown") } + @Test func decodesReadyRequestWithNumericID() { + let json = #""" + {"jsonrpc":"2.0","id":1,"method":"ec.ready","params":{"delegate":[]}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .ready(id, delegations) = message else { + Issue.record("Expected .ready, got \(message)") + return + } + + #expect(id == .int(1)) + #expect(delegations.isEmpty) + } + + @Test func decodesReadyRequestWithNullID() { + let json = #""" + {"jsonrpc":"2.0","id":null,"method":"ec.ready","params":{"delegate":[]}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .ready(id, delegations) = message else { + Issue.record("Expected .ready, got \(message)") + return + } + + #expect(id == .null) + #expect(delegations.isEmpty) + } + + @Test func decodesReadyRequestWithMissingParamsAsEmptyDelegations() { + let json = #""" + {"jsonrpc":"2.0","id":"ready-no-params","method":"ec.ready"} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .ready(id, delegations) = message else { + Issue.record("Expected .ready, got \(message)") + return + } + + #expect(id == "ready-no-params") + #expect(delegations.isEmpty) + } + + @Test func decodesMalformedReadyParamsAsParseError() { + let json = #""" + {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .error(id, code, responseMessage) = message else { + Issue.record("Expected .error, got \(message)") + return + } + + #expect(id == "ready-bad") + #expect(code == CheckoutProtocol.parseErrorCode) + #expect(responseMessage == CheckoutProtocol.parseErrorMessage) + } + + @Test func decodesNullReadyParamsAsParseError() { + let json = #""" + {"jsonrpc":"2.0","id":"ready-null","method":"ec.ready","params":null} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .error(id, code, responseMessage) = message else { + Issue.record("Expected .error, got \(message)") + return + } + + #expect(id == "ready-null") + #expect(code == CheckoutProtocol.parseErrorCode) + #expect(responseMessage == CheckoutProtocol.parseErrorMessage) + } + + @Test func rejectsFractionalJSONRPCID() { + let json = #""" + {"jsonrpc":"2.0","id":1.5,"method":"ec.ready","params":{"delegate":[]}} + """# + let message = CheckoutProtocol.decode(jsonRpc: json) + + guard case let .unknown(method, _) = message else { + Issue.record("Expected .unknown for fractional id, got \(message)") + return + } + + #expect(method == "") + } + @Test func handlesMalformedJSON() { let json = "not valid json at all" let message = CheckoutProtocol.decode(jsonRpc: json) diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift index ed6f29c2..f0c431ca 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/CodecEncodeTests.swift @@ -52,6 +52,20 @@ struct CodecEncodeTests { #expect(delegate == ["window.open"]) } + @Test func encodesReadyResponseWithNumericID() throws { + let json = CheckoutProtocol.encodeReadyResponse(id: 7, acceptedDelegations: []) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? Int == 7) + } + + @Test func encodesReadyResponseWithNullID() throws { + let json = CheckoutProtocol.encodeReadyResponse(id: .null, acceptedDelegations: []) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["id"] is NSNull) + } + @Test func acknowledgeReadyReturnsResponseForReadyMessage() throws { let message = #""" {"jsonrpc":"2.0","id":"ready-1","method":"ec.ready","params":{"delegate":["payment.credential"]}} @@ -92,6 +106,47 @@ struct CodecEncodeTests { #expect(result["delegate"] == nil) } + @Test func acknowledgeReadyAcceptsMissingParamsAsEmptyDelegations() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-no-params","method":"ec.ready"} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "ready-no-params") + let result = try #require(parsed["result"] as? [String: Any]) + #expect(result["delegate"] == nil) + } + + @Test func acknowledgeReadyReturnsParseErrorForMalformedParams() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-bad","method":"ec.ready","params":{"delegate":[null]}} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "ready-bad") + let error = try #require(parsed["error"] as? [String: Any]) + #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) + #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + } + + @Test func acknowledgeReadyReturnsParseErrorForNullParams() throws { + let message = #""" + {"jsonrpc":"2.0","id":"ready-null","method":"ec.ready","params":null} + """# + + let response = try #require(CheckoutProtocol.acknowledgeReady(message)) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + + #expect(parsed["id"] as? String == "ready-null") + let error = try #require(parsed["error"] as? [String: Any]) + #expect(error["code"] as? Int == CheckoutProtocol.parseErrorCode) + #expect(error["message"] as? String == CheckoutProtocol.parseErrorMessage) + } + @Test func acknowledgeReadyReturnsNilForNonReadyMessage() { let message = #""" {"jsonrpc":"2.0","method":"ec.start","params":{"checkout":{"id":"c"}}} diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift index 187e2ec7..bada7607 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/DescriptorTests.swift @@ -86,10 +86,34 @@ struct DescriptorTests { #expect(error["message"] as? String == CheckoutProtocol.methodNotFoundMessage) } + @Test func methodNotFoundResponsePreservesNumericRequestID() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":7,"params":{}}"# + ) + ) + let data = try #require(response.data(using: .utf8)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["id"] as? Int == 7) + } + + @Test func methodNotFoundResponsePreservesNullRequestID() throws { + let response = try #require( + CheckoutProtocol.methodNotFoundResponse( + forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":null,"params":{}}"# + ) + ) + let data = try #require(response.data(using: .utf8)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["id"] is NSNull) + } + @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) + #expect(CheckoutProtocol.methodNotFoundResponse(forUnsupportedProtocolRequest: #"{"jsonrpc":"2.0","method":"custom","id":1.5,"params":{}}"#) == nil) } @Test func methodNotFoundResponseRejectsSupportedNotificationsOrInvalidMessages() { diff --git a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ModelDecodingTests.swift b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ModelDecodingTests.swift index 265902a6..2699b518 100644 --- a/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ModelDecodingTests.swift +++ b/protocol/languages/swift/Tests/ShopifyCheckoutProtocolTests/ModelDecodingTests.swift @@ -8,30 +8,29 @@ struct ModelDecodingTests { let json = try fixtureString("notification") let data = Data(json.utf8) - let envelope = try JSONDecoder().decode(JSONRPCRequest.self, from: data) - let checkout = envelope.params?.checkout + let envelope = try JSONDecoder().decode(JSONRPCRequest.self, from: data) + let checkout = envelope.params.checkout - #expect(checkout != nil) - #expect(checkout?.id == "checkout-123") - #expect(checkout?.status == .incomplete) - #expect(checkout?.currency == "USD") - #expect(checkout?.totals.first?.amount == 2999) - #expect(checkout?.links.first?.type == "privacy_policy") + #expect(checkout.id == "checkout-123") + #expect(checkout.status == .incomplete) + #expect(checkout.currency == "USD") + #expect(checkout.totals.first?.amount == 2999) + #expect(checkout.links.first?.type == "privacy_policy") let reEncoded = try JSONEncoder().encode(checkout) let reDecoded = try JSONDecoder().decode(Checkout.self, from: reEncoded) - #expect(reDecoded.id == checkout?.id) - #expect(reDecoded.currency == checkout?.currency) - #expect(reDecoded.lineItems.count == checkout?.lineItems.count) + #expect(reDecoded.id == checkout.id) + #expect(reDecoded.currency == checkout.currency) + #expect(reDecoded.lineItems.count == checkout.lineItems.count) } @Test func decodesLineItemDetails() throws { let json = try fixtureString("notification") let data = Data(json.utf8) - let envelope = try JSONDecoder().decode(JSONRPCRequest.self, from: data) - let lineItem = try #require(envelope.params?.checkout?.lineItems[0]) + let envelope = try JSONDecoder().decode(JSONRPCRequest.self, from: data) + let lineItem = envelope.params.checkout.lineItems[0] #expect(lineItem.id == "li-1") #expect(lineItem.quantity == 1)