diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 4497415c8..3a0d9692d 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -391,6 +391,14 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: kotlin run: pnpm exec vitest run test/conformance.test.ts + - name: Build and test the Kotlin protocol runner + working-directory: harness/kotlin-protocol-runner + run: gradle installDist test --no-daemon + - name: Run Kotlin mpp-protocol conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: kotlin + run: pnpm exec vitest run test/protocol-conformance.test.ts - name: Run Kotlin client harness smoke working-directory: harness run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "kotlin client pays typescript server" --testTimeout 180000 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index e164403d5..c84c9a394 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -41,6 +41,13 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: swift run: pnpm exec vitest run test/conformance.test.ts + - name: Build Swift protocol runner + run: swift build -c release --product mpp-protocol-runner + - name: Run Swift mpp-protocol conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: swift + run: pnpm exec vitest run test/protocol-conformance.test.ts - name: Run Swift client harness smoke against TypeScript server working-directory: harness env: diff --git a/Package.swift b/Package.swift index 2e7cccd6b..25aad4692 100644 --- a/Package.swift +++ b/Package.swift @@ -35,10 +35,20 @@ let package = Package( dependencies: ["SolanaPayKit"], path: "swift/Sources/mpp-conformance" ), + .executableTarget( + name: "mpp-protocol-runner", + dependencies: ["SolanaPayKit"], + path: "swift/Sources/mpp-protocol-runner" + ), .testTarget( name: "SolanaPayKitTests", dependencies: ["SolanaPayKit"], path: "swift/Tests/SolanaPayKitTests" ), + .testTarget( + name: "MppProtocolRunnerTests", + dependencies: ["mpp-protocol-runner"], + path: "swift/Tests/MppProtocolRunnerTests" + ), ] ) diff --git a/harness/kotlin-protocol-runner/.gitignore b/harness/kotlin-protocol-runner/.gitignore new file mode 100644 index 000000000..67bcc2f72 --- /dev/null +++ b/harness/kotlin-protocol-runner/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/harness/kotlin-protocol-runner/build.gradle.kts b/harness/kotlin-protocol-runner/build.gradle.kts new file mode 100644 index 000000000..3380edfe9 --- /dev/null +++ b/harness/kotlin-protocol-runner/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + kotlin("jvm") version "2.3.21" + application +} + +dependencies { + // Path-included build, see settings.gradle.kts. + implementation("com.solana.paykit:solana-pay-kit-kotlin") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + testImplementation(kotlin("test")) +} + +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("com.solana.paykit.protocolrunner.MainKt") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/harness/kotlin-protocol-runner/settings.gradle.kts b/harness/kotlin-protocol-runner/settings.gradle.kts new file mode 100644 index 000000000..80d7e332f --- /dev/null +++ b/harness/kotlin-protocol-runner/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "mpp-kotlin-protocol-runner" +includeBuild("../../kotlin") diff --git a/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt new file mode 100644 index 000000000..6836927c4 --- /dev/null +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -0,0 +1,91 @@ +// Kotlin mpp-protocol conformance runner. +// Reads one adapter-ABI request on stdin and writes one response line on stdout, +// per the contract in harness/src/protocol/runners/spawn.ts. +package com.solana.paykit.protocolrunner + +import com.solana.paykit.protocols.mpp.core.MppHeaders +import com.solana.paykit.protocols.mpp.core.PaymentChallenge +import com.solana.paykit.protocols.mpp.core.PaymentCredential +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.Base64 +import kotlin.system.exitProcess + +private val families = mapOf( + "challenge.parse" to "parse_error", + "credential.parse" to "parse_error", + "receipt.parse" to "parse_error", + "challenge.format" to "format_error", + "credential.format" to "format_error", + "receipt.format" to "format_error", + "base64url.encode" to "encoding_error", + "base64url.decode" to "encoding_error", + "challenge.id" to "generation_error", +) + +private val sdkGaps = families.keys - setOf("challenge.parse", "credential.format") + +fun main() { + val response = reply(System.`in`.readBytes().decodeToString()) + println(response) + if (response["error_type"] == JsonPrimitive("runner_error")) exitProcess(1) +} + +internal fun respond(line: String): String = reply(line).toString() + +private fun reply(line: String): JsonObject { + val (op, input) = try { + val request = Json.parseToJsonElement(line).jsonObject + request["op"]?.jsonPrimitive?.content.orEmpty() to (request["input"] ?: JsonNull) + } catch (error: IllegalArgumentException) { + return fail(error.message, "runner_error") + } + val family = families[op] ?: return fail("unknown operation: $op", "unsupported_operation") + if (op in sdkGaps) return fail("$op unsupported by the Kotlin SDK", family) + return try { + val result = if (op == "challenge.parse") parseChallenge(input) else formatCredential(input) + buildJsonObject { + put("success", true) + put("result", result) + } + } catch (error: Exception) { + fail(error.message, family) + } +} + +private fun fail(error: String?, errorType: String) = buildJsonObject { + put("success", false) + put("error", error) + put("error_type", errorType) +} + +private fun parseChallenge(input: JsonElement): JsonObject { + val challenge = MppHeaders.parseWWWAuthenticate(input.jsonObject.getValue("header").jsonPrimitive.content) + val fields = Json.encodeToJsonElement(PaymentChallenge.serializer(), challenge).jsonObject + return JsonObject( + fields.filter { (name, value) -> name !in setOf("expires", "digest") || value != JsonPrimitive("") }.mapValues { (name, value) -> + if (name == "request" || name == "opaque") decodeJson(value.jsonPrimitive.content) else value + }, + ) +} + +private fun decodeJson(base64Url: String): JsonElement = + Json.parseToJsonElement(Base64.getUrlDecoder().decode(base64Url).decodeToString()) + +private fun formatCredential(input: JsonElement): JsonObject { + val credential = input.jsonObject + val challenge = credential.getValue("challenge").jsonObject + val request = (challenge["request"] ?: JsonObject(emptyMap())).toString().encodeToByteArray() + val echo = challenge + ("request" to JsonPrimitive(Base64.getUrlEncoder().withoutPadding().encodeToString(request))) + val wire = JsonObject(credential + ("challenge" to JsonObject(echo))) + return buildJsonObject { + put("header", MppHeaders.formatAuthorization(Json.decodeFromJsonElement(PaymentCredential.serializer(), wire))) + } +} diff --git a/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt new file mode 100644 index 000000000..deac0121e --- /dev/null +++ b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt @@ -0,0 +1,101 @@ +package com.solana.paykit.protocolrunner + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MainTest { + private fun answer(line: String): JsonObject { + val output = respond(line) + assertFalse('\n' in output) + return Json.parseToJsonElement(output).jsonObject + } + + private fun JsonObject.field(name: String) = getValue(name).jsonPrimitive.content + + @Test + fun parsesBasicChallengeWithRequestObject() { + val request = """{"amount":"1000000","currency":"0x20c0000000000000000000000000000000000001","recipient":"0x1234567890abcdef1234567890abcdef12345678"}""" + val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(request.encodeToByteArray()) + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { + put("header", """Payment id="ch_abc123", realm="api.example.com", method="tempo", intent="charge", request="$encoded"""") + } + }.toString() + + val response = answer(line) + + assertEquals("true", response.field("success")) + assertEquals( + Json.parseToJsonElement("""{"id":"ch_abc123","realm":"api.example.com","method":"tempo","intent":"charge","request":$request}"""), + response["result"], + ) + } + + @Test + fun dropsEmptyOptionalField() { + val header = """Payment id="ch", realm="r", method="tempo", intent="charge", request="e30", expires=""""" + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { put("header", header) } + }.toString() + + assertEquals( + Json.parseToJsonElement("""{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{}}"""), + answer(line)["result"], + ) + } + + @Test + fun emptyRequestIsAParseError() { + val header = """Payment id="ch", realm="r", method="tempo", intent="charge", request=""""" + val line = buildJsonObject { + put("op", "challenge.parse") + putJsonObject("input") { put("header", header) } + }.toString() + + val response = answer(line) + + assertEquals("false", response.field("success")) + assertEquals("parse_error", response.field("error_type")) + } + + @Test + fun refusesNonJson() { + assertEquals("runner_error", answer("not json").field("error_type")) + } + + @Test + fun unknownOpIsUnsupportedOperation() { + assertEquals("unsupported_operation", answer("""{"op":"nope.op","input":{}}""").field("error_type")) + } + + @Test + fun sdkGapAnswersItsFamilyErrorType() { + val response = answer("""{"op":"receipt.parse","input":{"header":"x"}}""") + + assertEquals("false", response.field("success")) + assertEquals("parse_error", response.field("error_type")) + assertTrue("unsupported" in response.field("error")) + } + + @Test + fun credentialPayloadHashIsAFormatError() { + val credential = """{"challenge":{"id":"ch","realm":"r","method":"tempo","intent":"charge","request":{}},"payload":{"type":"hash","hash":"0x12"}}""" + + val response = answer("""{"op":"credential.format","input":$credential}""") + + assertEquals("format_error", response.field("error_type")) + assertTrue("unknown key 'hash'" in response.field("error")) + } +} diff --git a/harness/protocol-runners/kotlin.json b/harness/protocol-runners/kotlin.json new file mode 100644 index 000000000..c4fde3360 --- /dev/null +++ b/harness/protocol-runners/kotlin.json @@ -0,0 +1,5 @@ +{ + "language": "kotlin", + "command": ["sh", "-c", "exec build/install/mpp-kotlin-protocol-runner/bin/mpp-kotlin-protocol-runner"], + "cwd": "harness/kotlin-protocol-runner" +} diff --git a/harness/protocol-runners/swift.json b/harness/protocol-runners/swift.json new file mode 100644 index 000000000..71a6e211d --- /dev/null +++ b/harness/protocol-runners/swift.json @@ -0,0 +1,5 @@ +{ + "language": "swift", + "command": ["swift", "run", "-c", "release", "mpp-protocol-runner"], + "cwd": "swift" +} diff --git a/harness/src/protocol/runners/spawn.ts b/harness/src/protocol/runners/spawn.ts index d6695979f..8815d525a 100644 --- a/harness/src/protocol/runners/spawn.ts +++ b/harness/src/protocol/runners/spawn.ts @@ -82,7 +82,15 @@ export function spawnedProtocolAdapter(runner: DiscoveredProtocolRunner): Protoc child.on("error", (err) => { resolve({ success: false, error: `spawn failed: ${err.message}`, error_type: "runner_error" }); }); - child.on("close", () => { + child.on("close", (code) => { + if (code !== 0) { + resolve({ + success: false, + error: `runner exited ${code}; stderr: ${stderr.slice(-512)}`, + error_type: "runner_error", + }); + return; + } const line = stdout.trim().split("\n").filter(Boolean).pop(); if (!line) { resolve({ diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 5468dbaf9..8d3b36e98 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest"; import { caseRunsOnAdapter, collectProtocolCases } from "../src/protocol/vectors"; -import { runCase } from "../src/protocol/driver"; +import { runCase, type ProtocolAdapter } from "../src/protocol/driver"; +import { parseLanguageAllowlist } from "../src/conformance/select"; import { typescriptProtocolAdapter } from "../src/protocol/runners/typescript"; import { discoverProtocolRunners, @@ -162,35 +163,146 @@ const smokeCases = (() => { // Each entry is `${op} :: ${scenario}` and is asserted to STILL diverge so the // gap fails loudly the moment the SDK conforms (mirrors KNOWN_TS_DIVERGENCES). // -// Empty: every SDK now conforms to the canonical receipt shape. The Go +// Only Kotlin's and Swift's remain: every other SDK now conforms to the canonical receipt shape. The Go // (`challengeId:""` injected) and Ruby (`challengeId` hard-required) schema // mismatches on `receipt.parse :: success_receipt` were both fixed in the -// per-SDK protocol-conformance round, so there are no remaining known runner -// divergences. -const KNOWN_RUNNER_DIVERGENCES: Record> = {}; +// per-SDK protocol-conformance round. Each entry maps to the exact response the +// runner gives. +const kotlinUnsupported = (op: string, error_type: string) => ({ + success: false, + error: `${op} unsupported by the Kotlin SDK`, + error_type, +}); +// Kotlin's PaymentChallenge has no `description` field. +const kotlinWithoutDescription = (scenario: string) => { + const parse = cases.find((c) => c.op === "challenge.parse" && c.scenario === scenario); + const { description: _, ...result } = (parse as { golden: Record }).golden; + return { success: true, result }; +}; +const swiftUnsupported = (op: string, error_type: string, thing: string) => ({ + success: false, + error: `${op} unsupported: SolanaPayKit has no ${thing}`, + error_type, +}); +const KNOWN_RUNNER_DIVERGENCES: Record> = { + kotlin: new Map([ + ["base64url.encode :: empty_string", kotlinUnsupported("base64url.encode", "encoding_error")], + ["base64url.decode :: empty_string", kotlinUnsupported("base64url.decode", "encoding_error")], + ["challenge.id :: required_fields_only", kotlinUnsupported("challenge.id", "generation_error")], + ["challenge.format :: basic_challenge", kotlinUnsupported("challenge.format", "format_error")], + ["credential.parse :: basic_credential", kotlinUnsupported("credential.parse", "parse_error")], + ["receipt.parse :: success_receipt", kotlinUnsupported("receipt.parse", "parse_error")], + ["receipt.format :: success_receipt", kotlinUnsupported("receipt.format", "format_error")], + ["challenge.parse :: full_challenge", kotlinWithoutDescription("full_challenge")], + [ + "challenge.parse :: escaped_quotes_in_description", + kotlinWithoutDescription("escaped_quotes_in_description"), + ], + // Kotlin's parser rejects text after a closing quote; the canonical parser truncates there. + [ + "challenge.parse :: unescaped_quotes_in_description", + { success: false, error: "invalid Payment header", error_type: "parse_error" }, + ], + // Kotlin's parser accepts an empty `id`. + [ + "challenge.parse :: error_empty_id", + { + success: true, + result: { id: "", realm: "api", method: "tempo", intent: "charge", request: {} }, + }, + ], + // Kotlin's CredentialPayload has no `hash` field. + [ + "credential.format :: credential_with_source", + { success: false, error: expect.stringContaining("unknown key 'hash'"), error_type: "format_error" }, + ], + ]), + swift: new Map([ + ["challenge.format :: basic_challenge", swiftUnsupported("challenge.format", "format_error", "WWW-Authenticate formatter")], + ["credential.parse :: basic_credential", swiftUnsupported("credential.parse", "parse_error", "Authorization parser")], + ["receipt.parse :: success_receipt", swiftUnsupported("receipt.parse", "parse_error", "Payment-Receipt parser")], + ["base64url.encode :: empty_string", swiftUnsupported("base64url.encode", "encoding_error", "public base64url encoder")], + ["base64url.decode :: empty_string", swiftUnsupported("base64url.decode", "encoding_error", "public base64url decoder")], + ["challenge.id :: required_fields_only", swiftUnsupported("challenge.id", "generation_error", "challenge-id generator")], + ]), +}; + +describe("mpp-protocol conformance (spawned runner failure)", () => { + it("a missing swift executable matches no known swift divergence", async () => { + const adapter = spawnedProtocolAdapter({ + language: "swift", + command: ["mpp-protocol-runner-missing"], + cwd: process.cwd(), + }); + const response = await adapter.runProtocolRequest({ op: "challenge.format", input: {} }); + expect(response).toMatchObject({ success: false, error_type: "runner_error" }); + expect([...KNOWN_RUNNER_DIVERGENCES.swift.values()]).not.toContainEqual(response); + }); +}); + +// Ops the runner implements run every case; the rest run only the smoke slice. +const IMPLEMENTED_OPS: Record> = { + kotlin: new Set(["challenge.parse", "credential.format"]), +}; -const runners = discoverProtocolRunners(); +// Format cases whose paired parse op the runner lacks: the TS reference re-parses its wire. +const REFERENCE_REPARSED_CASES: Record> = { + kotlin: new Set(["credential.format :: basic_credential"]), +}; +const withReferenceReparse = (adapter: ProtocolAdapter, op: string): ProtocolAdapter => ({ + name: adapter.name, + runProtocolRequest: (request) => + (request.op === op ? adapter : typescriptProtocolAdapter).runProtocolRequest(request), +}); + +const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); +const runners = discoverProtocolRunners().filter( + (runner) => !allowlist || allowlist.has(runner.language), +); +it("every language in MPP_CONFORMANCE_LANGUAGES has a protocol runner", () => { + const found = new Set(runners.map((runner) => runner.language)); + expect([...(allowlist ?? [])].filter((language) => !found.has(language))).toEqual([]); +}); for (const runner of runners) { - const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Set(); + const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Map(); + const reparsed = REFERENCE_REPARSED_CASES[runner.language] ?? new Set(); + const implemented = IMPLEMENTED_OPS[runner.language] ?? new Set(); + const keyOf = (testCase: (typeof cases)[number]) => `${testCase.op} :: ${testCase.scenario}`; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); - for (const testCase of smokeCases) { + for (const testCase of cases.filter( + (c) => smokeCases.includes(c) || implemented.has(c.op) || known.has(keyOf(c)), + )) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; - const key = `${testCase.op} :: ${testCase.scenario}`; + const key = keyOf(testCase); if (known.has(key)) { it(`KNOWN DIVERGENCE: ${key}`, async () => { - const result = await runCase(adapter, testCase); expect( - result.ok, - `${key} now conforms — remove from KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, - ).toBe(false); + await adapter.runProtocolRequest({ op: testCase.op, input: testCase.input }), + `${key} changed — update or remove KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, + ).toEqual(known.get(key)); }); continue; } it(key, async () => { - const result = await runCase(adapter, testCase); + const result = await runCase( + reparsed.has(key) ? withReferenceReparse(adapter, testCase.op) : adapter, + testCase, + ); expect(result.ok, result.detail).toBe(true); }); } + if (known.size === 0) return; + it("a runner that cannot start fails its known divergences", async () => { + const missing = spawnedProtocolAdapter({ + ...runner, + command: ["sh", "-c", "exec build/install/missing/bin/missing"], + }); + const [key, expected] = [...known][0]; + const testCase = cases.find((c) => keyOf(c) === key)!; + expect(await missing.runProtocolRequest({ op: testCase.op, input: testCase.input })).not.toEqual( + expected, + ); + }); }); } diff --git a/swift/Sources/mpp-protocol-runner/main.swift b/swift/Sources/mpp-protocol-runner/main.swift new file mode 100644 index 000000000..65dc7cfca --- /dev/null +++ b/swift/Sources/mpp-protocol-runner/main.swift @@ -0,0 +1,158 @@ +// Command mpp-protocol-runner is the Swift mpp-protocol conformance runner: +// one adapter-ABI request on stdin, one response line on stdout, per the +// contract in harness/src/protocol/runners/spawn.ts. + +import Foundation +import SolanaPayKit + +struct RunnerError: Error, CustomStringConvertible { + var errorType: String? + let description: String +} + +func respond(to raw: Data) -> (line: String, exitCode: Int32) { + guard let request = (try? JSONSerialization.jsonObject(with: raw)) as? [String: Any], + let op = request["op"] as? String + else { + return (failure("request must be a JSON object with a string op", "runner_error"), 1) + } + do { + return (serialize(["success": true, "result": try run(op, request["input"])]), 0) + } catch { + return (failure(String(describing: error), (error as? RunnerError)?.errorType ?? family(of: op)), 0) + } +} + +func run(_ op: String, _ input: Any?) throws -> Any { + switch op { + case "challenge.parse": + return try parseChallenge(object(input, at: "input")) + case "credential.format": + return ["header": try MppHeaders.formatAuthorization(credential(from: object(input, at: "input")))] + case "challenge.format": + throw unsupported(op, "WWW-Authenticate formatter") + case "credential.parse": + throw unsupported(op, "Authorization parser") + case "receipt.parse": + throw unsupported(op, "Payment-Receipt parser") + case "receipt.format": + throw unsupported(op, "Payment-Receipt formatter") + case "base64url.encode": + throw unsupported(op, "public base64url encoder") + case "base64url.decode": + throw unsupported(op, "public base64url decoder") + case "challenge.id": + throw unsupported(op, "challenge-id generator") + default: + throw RunnerError(errorType: "unsupported_operation", description: "Unknown operation: \(op)") + } +} + +func family(of op: String) -> String { + if op.hasPrefix("base64url.") { return "encoding_error" } + if op == "challenge.id" { return "generation_error" } + return op.hasSuffix(".parse") ? "parse_error" : "format_error" +} + +func unsupported(_ op: String, _ thing: String) -> RunnerError { + RunnerError(description: "\(op) unsupported: SolanaPayKit has no \(thing)") +} + +enum ChallengeFields { + static let all: Set = ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"] + // Spec challenge params PaymentChallenge cannot carry; extension params are ignored per spec. + static let unrepresentable: Set = ["description"] +} + +func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { + let header = try string(input, "header", at: "") ?? "" + let challenge = try MppHeaders.parseWWWAuthenticate(header) + let params = header.matches(of: #/[\s,]*([^=\s,"]+)\s*=\s*"(?:[^"\\]|\\.)*"/#).map { String($0.1) } + if let param = params.first(where: ChallengeFields.unrepresentable.contains) { + throw RunnerError(description: "unsupported field \(param)") + } + var result: [String: Any] = [ + "id": challenge.id, + "realm": challenge.realm, + "method": challenge.method, + "intent": challenge.intent, + "request": try decodeJSON(challenge.request), + ] + if let expires = challenge.expires { result["expires"] = expires } + if let digest = challenge.digest { result["digest"] = digest } + if let opaque = challenge.opaque { result["opaque"] = try decodeJSON(opaque) } + return result +} + +func credential(from input: [String: Any]) throws -> PaymentCredential { + try rejectUnknownKeys(input, ["challenge", "payload", "source"], at: "") + let challenge = try object(input["challenge"], at: "challenge") + try rejectUnknownKeys(challenge, ChallengeFields.all, at: "challenge.") + let payload = try object(input["payload"], at: "payload") + try rejectUnknownKeys(payload, ["type", "transaction", "signature"], at: "payload.") + guard let request = challenge["request"] else { throw RunnerError(description: "missing challenge.request") } + let echo = try PaymentChallenge( + id: required(challenge, "id", at: "challenge."), + realm: required(challenge, "realm", at: "challenge."), + method: required(challenge, "method", at: "challenge."), + intent: required(challenge, "intent", at: "challenge."), + request: encodeJSON(request), + expires: string(challenge, "expires", at: "challenge."), + digest: string(challenge, "digest", at: "challenge."), + opaque: challenge["opaque"].map(encodeJSON) + ).echo() + return PaymentCredential( + challenge: echo, + payload: try JSONDecoder().decode(CredentialPayload.self, from: JSONSerialization.data(withJSONObject: payload)), + source: try string(input, "source", at: "") + ) +} + +func object(_ value: Any?, at path: String) throws -> [String: Any] { + guard let object = value as? [String: Any] else { throw RunnerError(description: "\(path) must be a JSON object") } + return object +} + +func string(_ object: [String: Any], _ key: String, at path: String) throws -> String? { + guard let value = object[key] else { return nil } + guard let string = value as? String else { throw RunnerError(description: "\(path)\(key) must be a string") } + return string +} + +func required(_ object: [String: Any], _ key: String, at path: String) throws -> String { + guard let value = try string(object, key, at: path) else { throw RunnerError(description: "missing \(path)\(key)") } + return value +} + +func rejectUnknownKeys(_ object: [String: Any], _ allowed: Set, at path: String) throws { + if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) { + throw RunnerError(description: "unsupported field \(path)\(key)") + } +} + +func decodeJSON(_ base64url: String) throws -> Any { + var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64) else { throw RunnerError(description: "invalid base64url: \(base64url)") } + return try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) +} + +func encodeJSON(_ value: Any) throws -> String { + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed]) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +func failure(_ message: String, _ errorType: String) -> String { + serialize(["success": false, "error": message, "error_type": errorType]) +} + +func serialize(_ response: [String: Any]) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: response, options: .sortedKeys), as: UTF8.self) +} + +let response = respond(to: FileHandle.standardInput.readDataToEndOfFile()) +FileHandle.standardOutput.write(Data((response.line + "\n").utf8)) +exit(response.exitCode) diff --git a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift new file mode 100644 index 000000000..08f043468 --- /dev/null +++ b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest + +@testable import mpp_protocol_runner + +final class AbiFramingTests: XCTestCase { + private let basicChallenge = #"Payment id="ch_abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2NzgifQ""# + + private func call(_ request: Any) throws -> (response: [String: Any], exitCode: Int32) { + let (line, exitCode) = respond(to: try JSONSerialization.data(withJSONObject: request)) + XCTAssertFalse(line.contains("\n")) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + return (response, exitCode) + } + + private func formatCredential(challenge: [String: Any] = [:], payload: [String: Any]) throws -> [String: Any] { + var fullChallenge: [String: Any] = [ + "id": "ch_abc123", "realm": "api.example.com", "method": "tempo", "intent": "charge", + "request": ["amount": "1000000"], + ] + fullChallenge.merge(challenge) { $1 } + let (response, exitCode) = try call([ + "op": "credential.format", "input": ["challenge": fullChallenge, "payload": payload], + ]) + XCTAssertEqual(exitCode, 0) + return response + } + + func testBasicChallengeParseIsOneLine() throws { + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": basicChallenge]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(result["id"] as? String, "ch_abc123") + XCTAssertEqual((result["request"] as? [String: Any])?["amount"] as? String, "1000000") + } + + func testChallengeParseRejectsDescription() throws { + let header = basicChallenge + #", description="x""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "parse_error") + XCTAssertEqual(response["error"] as? String, "unsupported field description") + } + + func testChallengeParseIgnoresExtensionParams() throws { + let header = basicChallenge + #", foo="bar", baz="qux""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(Set(result.keys), ["id", "realm", "method", "intent", "request"]) + } + + func testMalformedRequestIsRunnerError() throws { + for raw in [Data("not json".utf8), Data(#"{"op":1}"#.utf8)] { + let (line, exitCode) = respond(to: raw) + XCTAssertEqual(exitCode, 1) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + XCTAssertEqual(response["error_type"] as? String, "runner_error") + } + } + + func testUnknownOperationIsUnsupported() throws { + let (response, exitCode) = try call(["op": "voucher.sign", "input": [:]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "unsupported_operation") + XCTAssertEqual(response["error"] as? String, "Unknown operation: voucher.sign") + } + + func testCredentialFormatRejectsUnknownChallengeField() throws { + let response = try formatCredential( + challenge: ["description": "x"], payload: ["type": "transaction", "transaction": "AQ"] + ) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field challenge.description") + } + + func testCredentialFormatRejectsUnknownPayloadField() throws { + let response = try formatCredential(payload: ["type": "hash", "hash": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field payload.hash") + } + + func testCredentialFormatRequiresTransaction() throws { + let response = try formatCredential(payload: ["type": "transaction", "signature": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertTrue((response["error"] as? String ?? "").contains("transaction")) + } +}