diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 80462416a..5ab7faeb5 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -341,6 +341,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: Pre-warm Gradle for the Kotlin harness client working-directory: harness/kotlin-client run: gradle installDist --no-daemon diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..3e653b565 --- /dev/null +++ b/greptile.json @@ -0,0 +1 @@ +{"autoReview": []} 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..ea32c2abc --- /dev/null +++ b/harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt @@ -0,0 +1,94 @@ +// 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.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) + return buildJsonObject { + put("id", challenge.id) + put("realm", challenge.realm) + put("method", challenge.method) + put("intent", challenge.intent) + put("request", decodeJson(challenge.request)) + challenge.expires?.takeIf { it.isNotEmpty() }?.let { put("expires", it) } + challenge.digest?.takeIf { it.isNotEmpty() }?.let { put("digest", it) } + challenge.opaque?.let { put("opaque", decodeJson(it)) } + } +} + +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..c3ae43320 --- /dev/null +++ b/harness/kotlin-protocol-runner/src/test/kotlin/com/solana/paykit/protocolrunner/MainTest.kt @@ -0,0 +1,73 @@ +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 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/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 5468dbaf9..41651fca9 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,102 @@ 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 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 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 CredentialPayload has no `hash` field. + [ + "credential.format :: credential_with_source", + { success: false, error: expect.stringContaining("unknown key 'hash'"), error_type: "format_error" }, + ], + ]), +}; + +// 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 runners = discoverProtocolRunners(); +const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); +const runners = discoverProtocolRunners().filter( + (runner) => !allowlist || allowlist.has(runner.language), +); 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 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) || known.has(keyOf(c)) || reparsed.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, + ); + }); }); }